Просмотр исходного кода

Merge branch 'master' into node-4.2

Henry Oswald 10 лет назад
Родитель
Сommit
7b83a941ee
100 измененных файлов с 3119 добавлено и 1616 удалено
  1. 10 12
      services/web/.gitignore
  2. 12 61
      services/web/Gruntfile.coffee
  3. 7 0
      services/web/app/coffee/Features/Analytics/AnalyticsController.coffee
  4. 43 0
      services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee
  5. 91 62
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  6. 3 0
      services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee
  7. 19 18
      services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee
  8. 6 5
      services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee
  9. 1 1
      services/web/app/coffee/Features/Blog/BlogController.coffee
  10. 6 2
      services/web/app/coffee/Features/Chat/ChatController.coffee
  11. 16 5
      services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee
  12. 25 2
      services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee
  13. 24 18
      services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
  14. 127 0
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
  15. 144 0
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee
  16. 62 0
      services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee
  17. 4 0
      services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee
  18. 1 0
      services/web/app/coffee/Features/Compile/ClsiManager.coffee
  19. 33 31
      services/web/app/coffee/Features/Compile/CompileController.coffee
  20. 22 23
      services/web/app/coffee/Features/Contacts/ContactController.coffee
  21. 8 5
      services/web/app/coffee/Features/Editor/EditorHttpController.coffee
  22. 32 7
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  23. 3 2
      services/web/app/coffee/Features/Email/EmailHandler.coffee
  24. 3 0
      services/web/app/coffee/Features/Email/EmailSender.coffee
  25. 6 4
      services/web/app/coffee/Features/Errors/ErrorController.coffee
  26. 11 0
      services/web/app/coffee/Features/Helpers/EmailHelper.coffee
  27. 19 4
      services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee
  28. 5 3
      services/web/app/coffee/Features/Notifications/NotificationsController.coffee
  29. 25 11
      services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee
  30. 24 18
      services/web/app/coffee/Features/Project/ProjectController.coffee
  31. 22 11
      services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee
  32. 2 0
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  33. 17 7
      services/web/app/coffee/Features/Project/ProjectLocator.coffee
  34. 4 2
      services/web/app/coffee/Features/Referal/ReferalController.coffee
  35. 0 11
      services/web/app/coffee/Features/Referal/ReferalMiddleware.coffee
  36. 7 7
      services/web/app/coffee/Features/References/ReferencesHandler.coffee
  37. 1 1
      services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee
  38. 6 8
      services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee
  39. 3 1
      services/web/app/coffee/Features/Spelling/SpellingController.coffee
  40. 3 2
      services/web/app/coffee/Features/StaticPages/HomeController.coffee
  41. 5 59
      services/web/app/coffee/Features/StaticPages/UniversityController.coffee
  42. 8 6
      services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
  43. 10 4
      services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee
  44. 184 173
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  45. 19 14
      services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee
  46. 3 0
      services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee
  47. 6 2
      services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee
  48. 1 0
      services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
  49. 5 0
      services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee
  50. 12 11
      services/web/app/coffee/Features/Tags/TagsController.coffee
  51. 12 13
      services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee
  52. 13 14
      services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee
  53. 6 8
      services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee
  54. 24 9
      services/web/app/coffee/Features/User/UserController.coffee
  55. 5 6
      services/web/app/coffee/Features/User/UserInfoController.coffee
  56. 18 4
      services/web/app/coffee/Features/User/UserPagesController.coffee
  57. 2 0
      services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
  58. 31 2
      services/web/app/coffee/Features/User/UserSessionsManager.coffee
  59. 70 44
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  60. 2 1
      services/web/app/coffee/infrastructure/GeoIpLookup.coffee
  61. 5 5
      services/web/app/coffee/infrastructure/Modules.coffee
  62. 15 0
      services/web/app/coffee/infrastructure/PackageVersions.coffee
  63. 21 2
      services/web/app/coffee/infrastructure/Server.coffee
  64. 29 10
      services/web/app/coffee/models/ProjectInvite.coffee
  65. 1 0
      services/web/app/coffee/models/User.coffee
  66. 29 10
      services/web/app/coffee/router.coffee
  67. 1 1
      services/web/app/views/beta_program/opt_in.jade
  68. 20 77
      services/web/app/views/layout.jade
  69. 2 2
      services/web/app/views/layout/navbar.jade
  70. 23 24
      services/web/app/views/project/editor.jade
  71. 6 5
      services/web/app/views/project/editor/chat.jade
  72. 1 0
      services/web/app/views/project/editor/editor.jade
  73. 22 4
      services/web/app/views/project/editor/file-tree.jade
  74. 369 109
      services/web/app/views/project/editor/header.jade
  75. 38 26
      services/web/app/views/project/editor/history.jade
  76. 25 0
      services/web/app/views/project/editor/hotkeys.jade
  77. 13 0
      services/web/app/views/project/editor/left-menu.jade
  78. 36 5
      services/web/app/views/project/editor/pdf.jade
  79. 21 3
      services/web/app/views/project/editor/share.jade
  80. 18 0
      services/web/app/views/project/invite/not-valid.jade
  81. 31 0
      services/web/app/views/project/invite/show.jade
  82. 3 3
      services/web/app/views/project/list.jade
  83. 46 0
      services/web/app/views/project/list/empty-project-list.jade
  84. 26 7
      services/web/app/views/project/list/notifications.jade
  85. 0 202
      services/web/app/views/project/list/project-list-minimal.jade
  86. 2 2
      services/web/app/views/project/list/project-list.jade
  87. 1 1
      services/web/app/views/project/list/side-bar.jade
  88. 5 0
      services/web/app/views/subscriptions/dashboard.jade
  89. 4 5
      services/web/app/views/subscriptions/edit-billing-details.jade
  90. 1 1
      services/web/app/views/subscriptions/group/invite.jade
  91. 193 205
      services/web/app/views/subscriptions/new.jade
  92. 8 2
      services/web/app/views/subscriptions/plans.jade
  93. 7 3
      services/web/app/views/user/register.jade
  94. 49 0
      services/web/app/views/user/sessions.jade
  95. 22 11
      services/web/app/views/user/settings.jade
  96. 8 6
      services/web/config/settings.defaults.coffee
  97. 176 171
      services/web/npm-shrinkwrap.json
  98. 5 0
      services/web/package.json
  99. 5 0
      services/web/public/coffee/directives/asyncForm.coffee
  100. 539 0
      services/web/public/coffee/directives/creditCards.coffee

+ 10 - 12
services/web/.gitignore

@@ -47,23 +47,21 @@ TpdsWorker.js
 BackgroundJobsWorker.js
 UserAndProjectPopulator.coffee
 
-public/js/history/versiondetail.js
-!public/js/libs/
-public/js/*
-!public/js/ace/*
-!public/js/libs/
+public/js/*.js
 public/js/libs/sharejs.js
-public/js/editor.js
-public/js/home.js
-public/js/forms.js
-public/js/gui.js
-public/js/admin.js
-public/js/history/*
+public/js/analytics/
+public/js/directives/
+public/js/filters/
+public/js/ide/
+public/js/main/
+public/js/modules/
+public/js/services/
+public/js/utils/
+
 public/stylesheets/style.css
 public/brand/plans.css
 public/minjs/
 
-public/js/main.js
 Gemfile.lock
 
 *.swp

+ 12 - 61
services/web/Gruntfile.coffee

@@ -1,4 +1,5 @@
 fs = require "fs"
+PackageVersions = require "./app/coffee/infrastructure/PackageVersions"
 
 module.exports = (grunt) ->
 	grunt.loadNpmTasks 'grunt-contrib-coffee'
@@ -157,12 +158,13 @@ module.exports = (grunt) ->
 					inlineText: false
 					preserveLicenseComments: false
 					paths:
-						"moment": "libs/moment-2.9.0"
+						"moment": "libs/#{PackageVersions.lib('moment')}"
 						"mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML"
-						"libs/pdf": "libs/pdfjs-1.3.91/pdf"
+						"pdfjs-dist/build/pdf": "libs/#{PackageVersions.lib('pdfjs')}/pdf"
+						"ace": "#{PackageVersions.lib('ace')}"
 					shim:
-						"libs/pdf":
-							deps: ["libs/pdfjs-1.3.91/compatibility"]
+						"pdfjs-dist/build/pdf":
+							deps: ["libs/#{PackageVersions.lib('pdfjs')}/compatibility"]
 
 					skipDirOptimize: true
 					modules: [
@@ -171,11 +173,13 @@ module.exports = (grunt) ->
 							exclude: ["libs"]
 						}, {
 							name: "ide",
-							exclude: ["libs", "libs/pdf"]
+							exclude: ["libs", "pdfjs-dist/build/pdf"]
 						}, {
 							name: "libs"
 						},{
 							name: "ace/mode-latex"
+						},{
+							name: "ace/worker-latex"
 						}
 
 					]
@@ -199,7 +203,7 @@ module.exports = (grunt) ->
 			acceptance:
 				src: ["test/acceptance/js/#{grunt.option('feature') or '**'}/*.js"]
 				options:
-					timeout: 10000
+					timeout: 40000
 					reporter: grunt.option('reporter') or 'spec'
 					grep: grunt.option("grep")
 
@@ -380,63 +384,10 @@ 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 'run', "Compile and run the web-sharelatex server", ['compile', 'env:run', 'parallel']
+	grunt.registerTask 'run:watch', "Compile and run the web-sharelatex server", ['compile', 'env:run', 'parallel']
+	grunt.registerTask 'run', "Compile and run the web-sharelatex server", ['compile', 'env:run', 'exec']
 
 	grunt.registerTask 'default', 'run'
 
 	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", () ->
-		done = @async()
-		email = grunt.option("email")
-		if !email?
-			console.error "Usage: grunt create-admin-user --email joe@example.com"
-			process.exit(1)
-
-		settings = require "settings-sharelatex"
-		UserRegistrationHandler = require "./app/js/Features/User/UserRegistrationHandler"
-		OneTimeTokenHandler = require "./app/js/Features/Security/OneTimeTokenHandler"
-		UserRegistrationHandler.registerNewUser {
-			email: email
-			password: require("crypto").randomBytes(32).toString("hex")
-		}, (error, user) ->
-			if error? and error?.message != "EmailAlreadyRegistered"
-				throw error
-			user.isAdmin = true
-			user.save (error) ->
-				throw error if error?
-				ONE_WEEK = 7 * 24 * 60 * 60 # seconds
-				OneTimeTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)->
-					return next(err) if err?
-					
-					console.log ""
-					console.log """
-						Successfully created #{email} as an admin user.
-						
-						Please visit the following URL to set a password for #{email} and log in:
-						
-							#{settings.siteUrl}/user/password/set?passwordResetToken=#{token}
-						
-					"""
-					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()

+ 7 - 0
services/web/app/coffee/Features/Analytics/AnalyticsController.coffee

@@ -0,0 +1,7 @@
+AnalyticsManager = require "./AnalyticsManager"
+
+module.exports = AnalyticsController =
+	recordEvent: (req, res, next) ->
+		AnalyticsManager.recordEvent req.session?.user?._id, req.params.event, req.body, (error) ->
+			return next(error) if error?
+			res.send 204

+ 43 - 0
services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee

@@ -0,0 +1,43 @@
+Settings = require "settings-sharelatex"
+logger = require "logger-sharelatex"
+_ = require "underscore"
+
+if !Settings.analytics?.postgres?
+	module.exports =
+		recordEvent: (user_id, event, segmentation, callback = () ->) ->
+			logger.log {user_id, event, segmentation}, "no event tracking configured, logging event"
+			callback()
+else
+	Sequelize = require "sequelize"
+	options = _.extend {logging:false}, Settings.analytics.postgres
+
+	sequelize = new Sequelize(
+		Settings.analytics.postgres.database,
+		Settings.analytics.postgres.username,
+		Settings.analytics.postgres.password,
+		options
+	)
+	
+	Event = sequelize.define("Event", {
+		user_id: Sequelize.STRING,
+		event: Sequelize.STRING,
+		segmentation: Sequelize.JSONB
+	})
+
+	module.exports =
+		recordEvent: (user_id, event, segmentation = {}, callback = (error) ->) ->
+			if user_id? and typeof(user_id) != "string"
+				user_id = user_id.toString()
+			if user_id == Settings.smokeTest?.userId
+				# Don't record smoke tests analytics
+				return callback()
+			Event
+				.create({ user_id, event, segmentation })
+				.then(
+					(result) -> callback(),
+					(error) ->
+						logger.err {err: error, user_id, event, segmentation}, "error recording analytics event"
+						callback(error)
+				)
+			
+		sync: () -> sequelize.sync()

+ 91 - 62
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -10,62 +10,115 @@ Settings = require "settings-sharelatex"
 basicAuth = require('basic-auth-connect')
 UserHandler = require("../User/UserHandler")
 UserSessionsManager = require("../User/UserSessionsManager")
+Analytics = require "../Analytics/AnalyticsManager"
+passport = require 'passport'
 
 module.exports = AuthenticationController =
-	login: (req, res, next = (error) ->) ->
-		AuthenticationController.doLogin req.body, req, res, next
 
-	doLogin: (options, req, res, next) ->
-		email = options.email?.toLowerCase()
-		password = options.password
-		redir = Url.parse(options.redir or "/project").path
+	serializeUser: (user, callback) ->
+		lightUser =
+			_id: user._id
+			first_name: user.first_name
+			last_name: user.last_name
+			isAdmin: user.isAdmin
+			email: user.email
+			referal_id: user.referal_id
+			session_created: (new Date()).toISOString()
+			ip_address: user._login_req_ip
+		callback(null, lightUser)
+
+	deserializeUser: (user, cb) ->
+		cb(null, user)
+
+	passportLogin: (req, res, next) ->
+		# This function is middleware which wraps the passport.authenticate middleware,
+		# so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
+		# and send a `{redir: ""}` response on success
+		passport.authenticate('local', (err, user, info) ->
+			if err?
+				return next(err)
+			if user # `user` is either a user object or false
+				req.login user, (err) ->
+					# Regenerate the session to get a new sessionID (cookie value) to
+					# protect against session fixation attacks
+					oldSession = req.session
+					req.session.destroy()
+					req.sessionStore.generate(req)
+					for key, value of oldSession
+						req.session[key] = value
+					# copy to the old `session.user` location, for backward-comptability
+					req.session.user = req.session.passport.user
+					req.session.save (err) ->
+						if err?
+							logger.err {user_id: user._id}, "error saving regenerated session after login"
+							return next(err)
+						UserSessionsManager.trackSession(user, req.sessionID, () ->)
+						res.json {redir: req._redir}
+			else
+				res.json message: info
+		)(req, res, next)
+
+	doPassportLogin: (req, username, password, done) ->
+		email = username.toLowerCase()
+		redir = Url.parse(req?.body?.redir or "/project").path
 		LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
+			return done(err) if err?
 			if !isAllowed
 				logger.log email:email, "too many login requests"
-				res.statusCode = 429
-				return res.send
-					message:
-						text: req.i18n.translate("to_many_login_requests_2_mins"),
-						type: 'error'
+				return done(null, null, {text: req.i18n.translate("to_many_login_requests_2_mins"), type: 'error'})
 			AuthenticationManager.authenticate email: email, password, (error, user) ->
-				return next(error) if error?
+				return done(error) if error?
 				if user?
-					UserHandler.setupLoginData user, ->
-					LoginRateLimiter.recordSuccessfulLogin email
-					AuthenticationController._recordSuccessfulLogin user._id
-					AuthenticationController.establishUserSession req, user, (error) ->
-						return next(error) if error?
-						req.session.justLoggedIn = true
-						logger.log email: email, user_id: user._id.toString(), "successful log in"
-						res.json redir: redir
+					# async actions
+					UserHandler.setupLoginData(user, ()->)
+					LoginRateLimiter.recordSuccessfulLogin(email)
+					AuthenticationController._recordSuccessfulLogin(user._id)
+					Analytics.recordEvent(user._id, "user-logged-in")
+					logger.log email: email, user_id: user._id.toString(), "successful log in"
+					req.session.justLoggedIn = true
+					# capture the request ip for use when creating the session
+					user._login_req_ip = req.ip
+					req._redir = redir
+					return done(null, user)
 				else
 					AuthenticationController._recordFailedLogin()
 					logger.log email: email, "failed log in"
-					res.json message:
-						text: req.i18n.translate("email_or_password_wrong_try_again"),
-						type: 'error'
-
-	getLoggedInUserId: (req, callback = (error, user_id) ->) ->
-		if req?.session?.user?._id?
-			callback null, req.session.user._id.toString()
+					return done(null, false, {text: req.i18n.translate("email_or_password_wrong_try_again"), type: 'error'})
+
+	setInSessionUser: (req, props) ->
+		for key, value of props
+			if req?.session?.passport?.user?
+				req.session.passport.user[key] = value
+			if req?.session?.user?
+				req.session.user[key] = value
+
+	isUserLoggedIn: (req) ->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		return (user_id not in [null, undefined, false])
+
+	# TODO: perhaps should produce an error if the current user is not present
+	getLoggedInUserId: (req) ->
+		user = AuthenticationController.getSessionUser(req)
+		if user
+			return user._id
 		else
-			callback null, null
+			return null
 
-	getLoggedInUser: (req, callback = (error, user) ->) ->
-		if req.session?.user?._id?
-			query = req.session.user._id
+	getSessionUser: (req) ->
+		if req?.session?.user?
+			return req.session.user
+		else if req?.session?.passport?.user
+			return req.session.passport.user
 		else
-			return callback null, null
-
-		UserGetter.getUser query, callback
+			return null
 
 	requireLogin: () ->
 		doRequest = (req, res, next = (error) ->) ->
-			if !req.session.user?
+			if !AuthenticationController.isUserLoggedIn(req)
 				AuthenticationController._redirectToLoginOrRegisterPage(req, res)
 			else
-				req.user = req.session.user
-				return next()
+				req.user = AuthenticationController.getSessionUser(req)
+				next()
 
 		return doRequest
 
@@ -79,7 +132,7 @@ module.exports = AuthenticationController =
 
 		if req.headers['authorization']?
 			return AuthenticationController.httpAuth(req, res, next)
-		else if req.session.user?
+		else if AuthenticationController.isUserLoggedIn(req)
 			return next()
 		else
 			logger.log url:req.url, "user trying to access endpoint not in global whitelist"
@@ -97,7 +150,6 @@ module.exports = AuthenticationController =
 		else
 			AuthenticationController._redirectToLoginPage(req, res)
 
-
 	_redirectToLoginPage: (req, res) ->
 		logger.log url: req.url, "user not logged in so redirecting to login page"
 		req.query.redir = req.path
@@ -124,26 +176,3 @@ module.exports = AuthenticationController =
 	_recordFailedLogin: (callback = (error) ->) ->
 		Metrics.inc "user.login.failed"
 		callback()
-
-	establishUserSession: (req, user, callback = (error) ->) ->
-		lightUser =
-			_id: user._id
-			first_name: user.first_name
-			last_name: user.last_name
-			isAdmin: user.isAdmin
-			email: user.email
-			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
-		# protect against session fixation attacks
-		oldSession = req.session
-		req.session.destroy()
-		req.sessionStore.generate(req)
-		for key, value of oldSession
-			req.session[key] = value
-
-		req.session.user = lightUser
-
-		UserSessionsManager.trackSession(user, req.sessionID, () ->)
-		callback()

+ 3 - 0
services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee

@@ -29,6 +29,9 @@ module.exports = AuthenticationManager =
 				callback null, null
 
 	setUserPassword: (user_id, password, callback = (error) ->) ->
+		if Settings.passwordStrengthOptions?.length?.max? and Settings.passwordStrengthOptions?.length?.max < password.length
+			return callback("password is too long")
+
 		bcrypt.genSalt BCRYPT_ROUNDS, (error, salt) ->
 			return callback(error) if error?
 			bcrypt.hash password, salt, (error, hash) ->

+ 19 - 18
services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee

@@ -3,6 +3,7 @@ async = require "async"
 logger = require "logger-sharelatex"
 ObjectId = require("mongojs").ObjectId
 Errors = require "../Errors/Errors"
+AuthenticationController = require "../Authentication/AuthenticationController"
 
 module.exports = AuthorizationMiddlewear =
 	ensureUserCanReadMultipleProjects: (req, res, next) ->
@@ -20,7 +21,7 @@ module.exports = AuthorizationMiddlewear =
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
 				else
 					next()
-		
+
 	ensureUserCanReadProject: (req, res, next) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 			return next(error) if error?
@@ -32,7 +33,7 @@ module.exports = AuthorizationMiddlewear =
 				else
 					logger.log {user_id, project_id}, "denying user read access to project"
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
-		
+
 	ensureUserCanWriteProjectSettings: (req, res, next) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 			return next(error) if error?
@@ -44,7 +45,7 @@ module.exports = AuthorizationMiddlewear =
 				else
 					logger.log {user_id, project_id}, "denying user write access to project settings"
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
-		
+
 	ensureUserCanWriteProjectContent: (req, res, next) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 			return next(error) if error?
@@ -56,7 +57,7 @@ module.exports = AuthorizationMiddlewear =
 				else
 					logger.log {user_id, project_id}, "denying user write access to project settings"
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
-		
+
 	ensureUserCanAdminProject: (req, res, next) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 			return next(error) if error?
@@ -68,7 +69,7 @@ module.exports = AuthorizationMiddlewear =
 				else
 					logger.log {user_id, project_id}, "denying user admin access to project"
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
-		
+
 	ensureUserIsSiteAdmin: (req, res, next) ->
 		AuthorizationMiddlewear._getUserId req, (error, user_id) ->
 			return next(error) if error?
@@ -90,22 +91,22 @@ module.exports = AuthorizationMiddlewear =
 		AuthorizationMiddlewear._getUserId req, (error, user_id) ->
 			return callback(error) if error?
 			callback(null, user_id, project_id)
-	
+
 	_getUserId: (req, callback = (error, user_id) ->) ->
-		if req.session?.user?._id?
-			user_id = req.session.user._id
-		else
-			user_id = null
-		callback null, user_id
-		
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		return callback(null, user_id)
+
 	redirectToRestricted: (req, res, next) ->
-		res.redirect "/restricted"
-	
+		res.redirect "/restricted?from=#{encodeURIComponent(req.url)}"
+
 	restricted : (req, res, next)->
-		if req.session.user?
+		if AuthenticationController.isUserLoggedIn(req)
 			res.render 'user/restricted',
 				title:'restricted'
 		else
-			logger.log "user not logged in and trying to access #{req.url}, being redirected to login"
-			res.redirect '/register'
-		
+			from = req.query.from
+			logger.log {from: from}, "redirecting to login"
+			redirect_to = "/login"
+			if from?
+				redirect_to += "?redir=#{encodeURIComponent(from)}"
+			res.redirect redirect_to

+ 6 - 5
services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee

@@ -2,14 +2,15 @@ BetaProgramHandler = require './BetaProgramHandler'
 UserLocator = require "../User/UserLocator"
 Settings = require "settings-sharelatex"
 logger = require 'logger-sharelatex'
+AuthenticationController = require '../Authentication/AuthenticationController'
 
 
 module.exports = BetaProgramController =
 
 	optIn: (req, res, next) ->
-		user_id = req?.session?.user?._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {user_id}, "user opting in to beta program"
-		if !user_id
+		if !user_id?
 			return next(new Error("no user id in session"))
 		BetaProgramHandler.optIn user_id, (err) ->
 			if err
@@ -17,9 +18,9 @@ module.exports = BetaProgramController =
 			return res.redirect "/beta/participate"
 
 	optOut: (req, res, next) ->
-		user_id = req?.session?.user?._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {user_id}, "user opting out of beta program"
-		if !user_id
+		if !user_id?
 			return next(new Error("no user id in session"))
 		BetaProgramHandler.optOut user_id, (err) ->
 			if err
@@ -27,7 +28,7 @@ module.exports = BetaProgramController =
 			return res.redirect "/beta/participate"
 
 	optInPage: (req, res, next)->
-		user_id = req.session?.user?._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {user_id}, "showing beta participation page for user"
 		UserLocator.findById user_id, (err, user)->
 			if err

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

@@ -28,7 +28,7 @@ module.exports = BlogController =
 			try
 				data = JSON.parse(data)
 				if settings.cdn?.web?.host?
-					data?.content = data?.content?.replace(/src="([^"]+)"/g, "src='#{settings.cdn?.web?.host}$1'");
+					data?.content = data?.content?.replace(/src="(\/[^"]+)"/g, "src='#{settings.cdn?.web?.host}$1'");
 			catch err
 				logger.err err:err, data:data, "error parsing data from data"
 			res.render "blog/blog_holder", data

+ 6 - 2
services/web/app/coffee/Features/Chat/ChatController.coffee

@@ -1,14 +1,18 @@
 ChatHandler = require("./ChatHandler")
 EditorRealTimeController = require("../Editor/EditorRealTimeController")
 logger = require("logger-sharelatex")
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 module.exports =
 
 
-	sendMessage: (req, res)->
+	sendMessage: (req, res, next)->
 		project_id = req.params.Project_id
-		user_id = req.session.user._id
 		messageContent = req.body.content
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		if !user_id?
+			err = new Error('no logged-in user')
+			return next(err)
 		ChatHandler.sendMessage project_id, user_id, messageContent, (err, builtMessge)->
 			if err?
 				logger.err err:err, project_id:project_id, user_id:user_id, messageContent:messageContent, "problem sending message to chat api"

+ 16 - 5
services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee

@@ -4,7 +4,9 @@ ProjectEditorHandler = require "../Project/ProjectEditorHandler"
 EditorRealTimeController = require "../Editor/EditorRealTimeController"
 LimitationsManager = require "../Subscription/LimitationsManager"
 UserGetter = require "../User/UserGetter"
-mimelib = require("mimelib")
+EmailHelper = require "../Helpers/EmailHelper"
+logger = require 'logger-sharelatex'
+
 
 module.exports = CollaboratorsController =
 	addUserToProject: (req, res, next) ->
@@ -16,11 +18,11 @@ module.exports = CollaboratorsController =
 				return res.json { user: false }
 			else
 				{email, privileges} = req.body
-				
-				email = mimelib.parseAddresses(email or "")[0]?.address?.toLowerCase()
+
+				email = EmailHelper.parseEmail(email)
 				if !email? or email == ""
 					return res.status(400).send("invalid email address")
-					
+
 				adding_user_id = req.session?.user?._id
 				CollaboratorsHandler.addEmailToProject project_id, adding_user_id, email, privileges, (error, user_id) =>
 					return next(error) if error?
@@ -35,8 +37,9 @@ module.exports = CollaboratorsController =
 		user_id    = req.params.user_id
 		CollaboratorsController._removeUserIdFromProject project_id, user_id, (error) ->
 			return next(error) if error?
+			EditorRealTimeController.emitToRoom project_id, 'project:membership:changed', {members: true}
 			res.sendStatus 204
-	
+
 	removeSelfFromProject: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_id
 		user_id    = req.session?.user?._id
@@ -50,3 +53,11 @@ module.exports = CollaboratorsController =
 			EditorRealTimeController.emitToRoom(project_id, 'userRemovedFromProject', user_id)
 			callback()
 
+	getAllMembers: (req, res, next) ->
+		projectId = req.params.Project_id
+		logger.log {projectId}, "getting all active members for project"
+		CollaboratorsHandler.getAllMembers projectId, (err, members) ->
+			if err?
+				logger.err {projectId}, "error getting members for project"
+				return next(err)
+			res.json({members: members})

+ 25 - 2
services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee

@@ -2,7 +2,15 @@ Project = require("../../models/Project").Project
 EmailHandler = require("../Email/EmailHandler")
 Settings = require "settings-sharelatex"
 
-module.exports =
+
+module.exports = CollaboratorsEmailHandler =
+
+	_buildInviteUrl: (project, invite) ->
+		"#{Settings.siteUrl}/project/#{project._id}/invite/token/#{invite.token}?" + [
+			"project_name=#{encodeURIComponent(project.name)}"
+			"user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
+		].join("&")
+
 	notifyUserOfProjectShare: (project_id, email, callback)->
 		Project
 			.findOne(_id: project_id )
@@ -22,4 +30,19 @@ module.exports =
 								"rs=ci" # referral source = collaborator invite
 							].join("&")
 					owner: project.owner_ref
-				EmailHandler.sendEmail "projectSharedWithYou", emailOptions, callback
+				EmailHandler.sendEmail "projectSharedWithYou", emailOptions, callback
+
+	notifyUserOfProjectInvite: (project_id, email, invite, callback)->
+		Project
+			.findOne(_id: project_id )
+			.select("name owner_ref")
+			.populate('owner_ref')
+			.exec (err, project)->
+				emailOptions =
+					to: email
+					replyTo: project.owner_ref.email
+					project:
+						name: project.name
+					inviteUrl: CollaboratorsEmailHandler._buildInviteUrl(project, invite)
+					owner: project.owner_ref
+				EmailHandler.sendEmail "projectInvite", emailOptions, callback

+ 24 - 18
services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee

@@ -1,6 +1,5 @@
 UserCreator = require('../User/UserCreator')
 Project = require("../../models/Project").Project
-mimelib = require("mimelib")
 logger = require('logger-sharelatex')
 UserGetter = require "../User/UserGetter"
 ContactManager = require "../Contacts/ContactManager"
@@ -8,8 +7,12 @@ CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler"
 async = require "async"
 PrivilegeLevels = require "../Authorization/PrivilegeLevels"
 Errors = require "../Errors/Errors"
+EmailHelper = require "../Helpers/EmailHelper"
+ProjectEditorHandler = require "../Project/ProjectEditorHandler"
+
 
 module.exports = CollaboratorsHandler =
+
 	getMemberIdsWithPrivilegeLevels: (project_id, callback = (error, members) ->) ->
 		Project.findOne { _id: project_id }, { owner_ref: 1, collaberator_refs: 1, readOnly_refs: 1 }, (error, project) ->
 			return callback(error) if error?
@@ -21,12 +24,12 @@ module.exports = CollaboratorsHandler =
 			for member_id in project.collaberator_refs or []
 				members.push { id: member_id.toString(), privilegeLevel: PrivilegeLevels.READ_AND_WRITE }
 			return callback null, members
-	
+
 	getMemberIds: (project_id, callback = (error, member_ids) ->) ->
 		CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members) ->
 			return callback(error) if error?
 			return callback null, members.map (m) -> m.id
-	
+
 	getMembersWithPrivilegeLevels: (project_id, callback = (error, members) ->) ->
 		CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members = []) ->
 			return callback(error) if error?
@@ -41,7 +44,7 @@ module.exports = CollaboratorsHandler =
 				(error) ->
 					return callback(error) if error?
 					callback null, result
-	
+
 	getMemberIdPrivilegeLevel: (user_id, project_id, callback = (error, privilegeLevel) ->) ->
 		# In future if the schema changes and getting all member ids is more expensive (multiple documents)
 		# then optimise this.
@@ -51,12 +54,12 @@ module.exports = CollaboratorsHandler =
 				if member.id == user_id?.toString()
 					return callback null, member.privilegeLevel
 			return callback null, PrivilegeLevels.NONE
-	
+
 	getMemberCount: (project_id, callback = (error, count) ->) ->
 		CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members) ->
 			return callback(error) if error?
 			return callback null, (members or []).length
-		
+
 	getCollaboratorCount: (project_id, callback = (error, count) ->) ->
 		CollaboratorsHandler.getMemberCount project_id, (error, count) ->
 			return callback(error) if error?
@@ -69,14 +72,14 @@ module.exports = CollaboratorsHandler =
 				if member.id.toString() == user_id.toString()
 					return callback null, true, member.privilegeLevel
 			return callback null, false, null
-			
+
 	getProjectsUserIsCollaboratorOf: (user_id, fields, callback = (error, readAndWriteProjects, readOnlyProjects) ->) ->
 		Project.find {collaberator_refs:user_id}, fields, (err, readAndWriteProjects)=>
 			return callback(err) if err?
 			Project.find {readOnly_refs:user_id}, fields, (err, readOnlyProjects)=>
 				return callback(err) if err?
 				callback(null, readAndWriteProjects, readOnlyProjects)
-		
+
 	removeUserFromProject: (project_id, user_id, callback = (error) ->)->
 		logger.log user_id: user_id, project_id: project_id, "removing user"
 		conditions = _id:project_id
@@ -86,7 +89,7 @@ module.exports = CollaboratorsHandler =
 			if err?
 				logger.error err: err, "problem removing user from project collaberators"
 			callback(err)
-	
+
 	removeUserFromAllProjets: (user_id, callback = (error) ->) ->
 		CollaboratorsHandler.getProjectsUserIsCollaboratorOf user_id, { _id: 1 }, (error, readAndWriteProjects = [], readOnlyProjects = []) ->
 			return callback(error) if error?
@@ -98,10 +101,9 @@ module.exports = CollaboratorsHandler =
 						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) ->) ->
-		emails = mimelib.parseAddresses(unparsed_email)
-		email = emails[0]?.address?.toLowerCase()
+		email = EmailHelper.parseEmail(unparsed_email)
 		if !email? or email == ""
 			return callback(new Error("no valid email provided: '#{unparsed_email}'"))
 		UserCreator.getUserOrCreateHoldingAccount email, (error, user) ->
@@ -118,7 +120,7 @@ module.exports = CollaboratorsHandler =
 			existing_users = existing_users.map (u) -> u.toString()
 			if existing_users.indexOf(user_id.toString()) > -1
 				return callback null # User already in Project
-				
+
 			if privilegeLevel == PrivilegeLevels.READ_AND_WRITE
 				level = {"collaberator_refs":user_id}
 				logger.log {privileges: "readAndWrite", user_id, project_id}, "adding user"
@@ -128,11 +130,6 @@ module.exports = CollaboratorsHandler =
 			else
 				return callback(new Error("unknown privilegeLevel: #{privilegeLevel}"))
 
-			# Do these in the background
-			UserGetter.getUser user_id, {email: 1}, (error, user) ->
-				if error?
-					logger.error {err: error, project_id, user_id}, "error getting user while adding to project"
-				CollaboratorsEmailHandler.notifyUserOfProjectShare project_id, user.email
 			ContactManager.addContact adding_user_id, user_id
 
 			Project.update { _id: project_id }, { $addToSet: level }, (error) ->
@@ -143,3 +140,12 @@ module.exports = CollaboratorsHandler =
 					if error?
 						logger.error {err: error, project_id, user_id}, "error flushing to TPDS after adding collaborator"
 				callback()
+
+	getAllMembers: (projectId, callback=(err, members)->) ->
+		logger.log {projectId}, "fetching all members"
+		CollaboratorsHandler.getMembersWithPrivilegeLevels projectId, (error, rawMembers) ->
+			if error?
+				logger.err {projectId, error}, "error getting members for project"
+				return callback(error)
+			{owner, members} = ProjectEditorHandler.buildOwnerAndMembersViews(rawMembers)
+			callback(null, members)

+ 127 - 0
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee

@@ -0,0 +1,127 @@
+ProjectGetter = require "../Project/ProjectGetter"
+LimitationsManager = require "../Subscription/LimitationsManager"
+UserGetter = require "../User/UserGetter"
+CollaboratorsHandler = require('./CollaboratorsHandler')
+CollaboratorsInviteHandler = require('./CollaboratorsInviteHandler')
+logger = require('logger-sharelatex')
+EmailHelper = require "../Helpers/EmailHelper"
+EditorRealTimeController = require("../Editor/EditorRealTimeController")
+NotificationsBuilder = require("../Notifications/NotificationsBuilder")
+AnalyticsManger = require("../Analytics/AnalyticsManager")
+AuthenticationController = require("../Authentication/AuthenticationController")
+
+module.exports = CollaboratorsInviteController =
+
+	getAllInvites: (req, res, next) ->
+		projectId = req.params.Project_id
+		logger.log {projectId}, "getting all active invites for project"
+		CollaboratorsInviteHandler.getAllInvites projectId, (err, invites) ->
+			if err?
+				logger.err {projectId}, "error getting invites for project"
+				return next(err)
+			res.json({invites: invites})
+
+	inviteToProject: (req, res, next) ->
+		projectId = req.params.Project_id
+		email = req.body.email
+		sendingUser = AuthenticationController.getSessionUser(req)
+		sendingUserId = sendingUser._id
+		logger.log {projectId, email, sendingUserId}, "inviting to project"
+		LimitationsManager.canAddXCollaborators projectId, 1, (error, allowed) =>
+			return next(error) if error?
+			if !allowed
+				logger.log {projectId, email, sendingUserId}, "not allowed to invite more users to project"
+				return res.json {invite: null}
+			{email, privileges} = req.body
+			email = EmailHelper.parseEmail(email)
+			if !email? or email == ""
+				logger.log {projectId, email, sendingUserId}, "invalid email address"
+				return res.sendStatus(400)
+			CollaboratorsInviteHandler.inviteToProject projectId, sendingUser, email, privileges, (err, invite) ->
+				if err?
+					logger.err {projectId, email, sendingUserId}, "error creating project invite"
+					return next(err)
+				logger.log {projectId, email, sendingUserId}, "invite created"
+				EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {invites: true})
+				return res.json {invite: invite}
+
+	revokeInvite: (req, res, next) ->
+		projectId = req.params.Project_id
+		inviteId = req.params.invite_id
+		logger.log {projectId, inviteId}, "revoking invite"
+		CollaboratorsInviteHandler.revokeInvite projectId, inviteId, (err) ->
+			if err?
+				logger.err {projectId, inviteId}, "error revoking invite"
+				return next(err)
+			EditorRealTimeController.emitToRoom projectId, 'project:membership:changed', {invites: true}
+			res.sendStatus(201)
+
+	resendInvite: (req, res, next) ->
+		projectId = req.params.Project_id
+		inviteId = req.params.invite_id
+		logger.log {projectId, inviteId}, "resending invite"
+		sendingUser = AuthenticationController.getSessionUser(req)
+		CollaboratorsInviteHandler.resendInvite projectId, sendingUser, inviteId, (err) ->
+			if err?
+				logger.err {projectId, inviteId}, "error resending invite"
+				return next(err)
+			res.sendStatus(201)
+
+	viewInvite: (req, res, next) ->
+		projectId = req.params.Project_id
+		token = req.params.token
+		_renderInvalidPage = () ->
+			logger.log {projectId, token}, "invite not valid, rendering not-valid page"
+			res.render "project/invite/not-valid", {title: "Invalid Invite"}
+		# check if the user is already a member of the project
+		currentUser = AuthenticationController.getSessionUser(req)
+		CollaboratorsHandler.isUserMemberOfProject currentUser._id, projectId, (err, isMember, _privilegeLevel) ->
+			if err?
+				logger.err {err, projectId}, "error checking if user is member of project"
+				return next(err)
+			if isMember
+				logger.log {projectId, userId: currentUser._id}, "user is already a member of this project, redirecting"
+				return res.redirect "/project/#{projectId}"
+			# get the invite
+			CollaboratorsInviteHandler.getInviteByToken projectId, token, (err, invite) ->
+				if err?
+					logger.err {projectId, token}, "error getting invite by token"
+					return next(err)
+				# check if invite is gone, or otherwise non-existent
+				if !invite?
+					logger.log {projectId, token}, "no invite found for this token"
+					return _renderInvalidPage()
+				# check the user who sent the invite exists
+				UserGetter.getUser {_id: invite.sendingUserId}, {email: 1, first_name: 1, last_name: 1}, (err, owner) ->
+					if err?
+						logger.err {err, projectId}, "error getting project owner"
+						return next(err)
+					if !owner?
+						logger.log {projectId}, "no project owner found"
+						return _renderInvalidPage()
+					# fetch the project name
+					ProjectGetter.getProject projectId, {}, (err, project) ->
+						if err?
+							logger.err {err, projectId}, "error getting project"
+							return next(err)
+						if !project?
+							logger.log {projectId}, "no project found"
+							return _renderInvalidPage()
+						# finally render the invite
+						res.render "project/invite/show", {invite, project, owner, title: "Project Invite"}
+
+	acceptInvite: (req, res, next) ->
+		projectId = req.params.Project_id
+		token = req.params.token
+		currentUser = AuthenticationController.getSessionUser(req)
+		logger.log {projectId, userId: currentUser._id, token}, "got request to accept invite"
+		CollaboratorsInviteHandler.acceptInvite projectId, token, currentUser, (err) ->
+			if err?
+				logger.err {projectId, token}, "error accepting invite by token"
+				return next(err)
+			EditorRealTimeController.emitToRoom projectId, 'project:membership:changed', {invites: true, members: true}
+			AnalyticsManger.recordEvent(currentUser._id, "project-invite-accept", {projectId:projectId, userId:currentUser._id})
+			if req.xhr
+				res.sendStatus 204 #  Done async via project page notification
+			else
+				res.redirect "/project/#{projectId}"

+ 144 - 0
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee

@@ -0,0 +1,144 @@
+ProjectInvite = require("../../models/ProjectInvite").ProjectInvite
+logger = require('logger-sharelatex')
+CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler"
+CollaboratorsHandler = require "./CollaboratorsHandler"
+UserGetter = require "../User/UserGetter"
+ProjectGetter = require "../Project/ProjectGetter"
+Async = require "async"
+PrivilegeLevels = require "../Authorization/PrivilegeLevels"
+Errors = require "../Errors/Errors"
+Crypto = require 'crypto'
+NotificationsBuilder = require("../Notifications/NotificationsBuilder")
+
+
+module.exports = CollaboratorsInviteHandler =
+
+	getAllInvites: (projectId, callback=(err, invites)->) ->
+		logger.log {projectId}, "fetching invites for project"
+		ProjectInvite.find {projectId: projectId}, (err, invites) ->
+			if err?
+				logger.err {err, projectId}, "error getting invites from mongo"
+				return callback(err)
+			logger.log {projectId, count: invites.length}, "found invites for project"
+			callback(null, invites)
+
+	getInviteCount: (projectId, callback=(err, count)->) ->
+		logger.log {projectId}, "counting invites for project"
+		ProjectInvite.count {projectId: projectId}, (err, count) ->
+			if err?
+				logger.err {err, projectId}, "error getting invites from mongo"
+				return callback(err)
+			callback(null, count)
+
+	_trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) ->
+		email = invite.email
+		UserGetter.getUser {email: email}, {_id: 1}, (err, existingUser) ->
+			if err?
+				logger.err {projectId, email}, "error checking if user exists"
+				return callback(err)
+			if !existingUser?
+				logger.log {projectId, email}, "no existing user found, returning"
+				return callback(null)
+			ProjectGetter.getProject projectId, {_id: 1, name: 1}, (err, project) ->
+				if err?
+					logger.err {projectId, email}, "error getting project"
+					return callback(err)
+				if !project?
+					logger.log {projectId}, "no project found while sending notification, returning"
+					return callback(null)
+				NotificationsBuilder.projectInvite(invite, project, sendingUser, existingUser).create(callback)
+
+	_tryCancelInviteNotification: (inviteId, callback=()->) ->
+			NotificationsBuilder.projectInvite({_id: inviteId}, null, null, null).read(callback)
+
+	_sendMessages: (projectId, sendingUser, invite, callback=(err)->) ->
+		logger.log {projectId, inviteId: invite._id}, "sending notification and email for invite"
+		CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, invite.email, invite, (err)->
+			return callback(err) if err?
+			CollaboratorsInviteHandler._trySendInviteNotification projectId, sendingUser, invite, (err)->
+				return callback(err) if err?
+				callback()
+
+	inviteToProject: (projectId, sendingUser, email, privileges, callback=(err,invite)->) ->
+		logger.log {projectId, sendingUserId: sendingUser._id, email, privileges}, "adding invite"
+		Crypto.randomBytes 24, (err, buffer) ->
+			if err?
+				logger.err {err, projectId, sendingUserId: sendingUser._id, email}, "error generating random token"
+				return callback(err)
+			token = buffer.toString('hex')
+			invite = new ProjectInvite {
+				email: email
+				token: token
+				sendingUserId: sendingUser._id
+				projectId: projectId
+				privileges: privileges
+			}
+			invite.save (err, invite) ->
+				if err?
+					logger.err {err, projectId, sendingUserId: sendingUser._id, email}, "error saving token"
+					return callback(err)
+				# Send email and notification in background
+				CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
+					if err?
+						logger.err {projectId, email}, "error sending messages for invite"
+				callback(null, invite)
+
+
+	revokeInvite: (projectId, inviteId, callback=(err)->) ->
+		logger.log {projectId, inviteId}, "removing invite"
+		ProjectInvite.remove {projectId: projectId, _id: inviteId}, (err) ->
+			if err?
+				logger.err {err, projectId, inviteId}, "error removing invite"
+				return callback(err)
+			CollaboratorsInviteHandler._tryCancelInviteNotification(inviteId, ()->)
+			callback(null)
+
+	resendInvite: (projectId, sendingUser, inviteId, callback=(err)->) ->
+		logger.log {projectId, inviteId}, "resending invite email"
+		ProjectInvite.findOne {_id: inviteId, projectId: projectId}, (err, invite) ->
+			if err?
+				logger.err {err, projectId, inviteId}, "error finding invite"
+				return callback(err)
+			if !invite?
+				logger.err {err, projectId, inviteId}, "no invite found, nothing to resend"
+				return callback(null)
+			CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
+				if err?
+					logger.err {projectId, inviteId}, "error resending invite messages"
+					return callback(err)
+				callback(null)
+
+	getInviteByToken: (projectId, tokenString, callback=(err,invite)->) ->
+		logger.log {projectId, tokenString}, "fetching invite by token"
+		ProjectInvite.findOne {projectId: projectId, token: tokenString}, (err, invite) ->
+			if err?
+				logger.err {err, projectId}, "error fetching invite"
+				return callback(err)
+			if !invite?
+				logger.err {err, projectId, token: tokenString}, "no invite found"
+				return callback(null, null)
+			callback(null, invite)
+
+	acceptInvite: (projectId, tokenString, user, callback=(err)->) ->
+		logger.log {projectId, userId: user._id, tokenString}, "accepting invite"
+		CollaboratorsInviteHandler.getInviteByToken projectId, tokenString, (err, invite) ->
+			if err?
+				logger.err {err, projectId, tokenString}, "error finding invite"
+				return callback(err)
+			if !invite
+				err = new Errors.NotFoundError("no matching invite found")
+				logger.log {err, projectId, tokenString}, "no matching invite found"
+				return callback(err)
+			inviteId = invite._id
+			CollaboratorsHandler.addUserIdToProject projectId, invite.sendingUserId, user._id, invite.privileges, (err) ->
+				if err?
+					logger.err {err, projectId, inviteId, userId: user._id}, "error adding user to project"
+					return callback(err)
+				# Remove invite
+				logger.log {projectId, inviteId}, "removing invite"
+				ProjectInvite.remove {_id: inviteId}, (err) ->
+					if err?
+						logger.err {err, projectId, inviteId}, "error removing invite"
+						return callback(err)
+					CollaboratorsInviteHandler._tryCancelInviteNotification inviteId, ()->
+					callback()

+ 62 - 0
services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee

@@ -1,6 +1,8 @@
 CollaboratorsController = require('./CollaboratorsController')
 AuthenticationController = require('../Authentication/AuthenticationController')
 AuthorizationMiddlewear = require('../Authorization/AuthorizationMiddlewear')
+CollaboratorsInviteController = require('./CollaboratorsInviteController')
+RateLimiterMiddlewear = require('../Security/RateLimiterMiddlewear')
 
 module.exports =
 	apply: (webRouter, apiRouter) ->
@@ -8,3 +10,63 @@ module.exports =
 
 		webRouter.post   '/project/:Project_id/users', AuthorizationMiddlewear.ensureUserCanAdminProject, CollaboratorsController.addUserToProject
 		webRouter.delete '/project/:Project_id/users/:user_id', AuthorizationMiddlewear.ensureUserCanAdminProject, CollaboratorsController.removeUserFromProject
+
+		webRouter.get(
+			'/project/:Project_id/members',
+			AuthenticationController.requireLogin(),
+			AuthorizationMiddlewear.ensureUserCanAdminProject,
+			CollaboratorsController.getAllMembers
+		)
+
+		# invites
+		webRouter.post(
+			'/project/:Project_id/invite',
+			RateLimiterMiddlewear.rateLimit({
+				endpointName: "invite-to-project"
+				params: ["Project_id"]
+				maxRequests: 200
+				timeInterval: 60 * 10
+			}),
+			AuthenticationController.requireLogin(),
+			AuthorizationMiddlewear.ensureUserCanAdminProject,
+			CollaboratorsInviteController.inviteToProject
+		)
+
+		webRouter.get(
+			'/project/:Project_id/invites',
+			AuthenticationController.requireLogin(),
+			AuthorizationMiddlewear.ensureUserCanAdminProject,
+			CollaboratorsInviteController.getAllInvites
+		)
+
+		webRouter.delete(
+			'/project/:Project_id/invite/:invite_id',
+			AuthenticationController.requireLogin(),
+			AuthorizationMiddlewear.ensureUserCanAdminProject,
+			CollaboratorsInviteController.revokeInvite
+		)
+
+		webRouter.post(
+			'/project/:Project_id/invite/:invite_id/resend',
+			RateLimiterMiddlewear.rateLimit({
+				endpointName: "resend-invite"
+				params: ["Project_id"]
+				maxRequests: 200
+				timeInterval: 60 * 10
+			}),
+			AuthenticationController.requireLogin(),
+			AuthorizationMiddlewear.ensureUserCanAdminProject,
+			CollaboratorsInviteController.resendInvite
+		)
+
+		webRouter.get(
+			'/project/:Project_id/invite/token/:token',
+			AuthenticationController.requireLogin(),
+			CollaboratorsInviteController.viewInvite
+		)
+
+		webRouter.post(
+			'/project/:Project_id/invite/token/:token/accept',
+			AuthenticationController.requireLogin(),
+			CollaboratorsInviteController.acceptInvite
+		)

+ 4 - 0
services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee

@@ -48,6 +48,10 @@ module.exports = ClsiCookieManager =
 		multi.exec (err)->
 			callback(err, serverId)
 
+	clearServerId: (project_id, callback = (err)->)->
+		if !clsiCookiesEnabled
+			return callback()
+		rclient.del buildKey(project_id), callback
 
 	getCookieJar: (project_id, callback = (err, jar)->)->
 		if !clsiCookiesEnabled

+ 1 - 0
services/web/app/coffee/Features/Compile/ClsiManager.coffee

@@ -147,6 +147,7 @@ module.exports = ClsiManager =
 								timeout: options.timeout
 								imageName: project.imageName
 								draft: !!options.draft
+								check: options.check
 							rootResourcePath: rootResourcePath
 							resources: resources
 					}

+ 33 - 31
services/web/app/coffee/Features/Compile/CompileController.coffee

@@ -16,51 +16,53 @@ module.exports = CompileController =
 		res.setTimeout(5 * 60 * 1000)
 		project_id = req.params.Project_id
 		isAutoCompile = !!req.query?.auto_compile
-		AuthenticationController.getLoggedInUserId req, (error, user_id) ->
+		user_id = AuthenticationController.getLoggedInUserId req
+		options = {
+			isAutoCompile: isAutoCompile
+		}
+		if req.body?.rootDoc_id?
+			options.rootDoc_id = req.body.rootDoc_id
+		else if req.body?.settingsOverride?.rootDoc_id? # Can be removed after deploy
+			options.rootDoc_id = req.body.settingsOverride.rootDoc_id
+		if req.body?.compiler
+			options.compiler = req.body.compiler
+		if req.body?.draft
+			options.draft = req.body.draft
+		if req.body?.check in ['validate', 'error', 'silent']
+			options.check = req.body.check
+		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?
-			options = {
-				isAutoCompile: isAutoCompile
+			res.contentType("application/json")
+			res.status(200).send JSON.stringify {
+				status: status
+				outputFiles: outputFiles
+				compileGroup: limits?.compileGroup
+				clsiServerId:clsiServerId
+				validationProblems:validationProblems
 			}
-			if req.body?.rootDoc_id?
-				options.rootDoc_id = req.body.rootDoc_id
-			else if req.body?.settingsOverride?.rootDoc_id? # Can be removed after deploy
-				options.rootDoc_id = req.body.settingsOverride.rootDoc_id
-			if req.body?.compiler
-				options.compiler = req.body.compiler
-			if req.body?.draft
-				options.draft = req.body.draft
-			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?
-				res.contentType("application/json")
-				res.status(200).send JSON.stringify {
-					status: status
-					outputFiles: outputFiles
-					compileGroup: limits?.compileGroup
-					clsiServerId:clsiServerId
-					validationProblems:validationProblems
-				}
 
 	stopCompile: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_id
-		AuthenticationController.getLoggedInUserId req, (error, user_id) ->
+		user_id = AuthenticationController.getLoggedInUserId req
+		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?
-			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()
+			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)
+			user_id = AuthenticationController.getLoggedInUserId req
+			return callback(null, 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)
+			user_id = AuthenticationController.getLoggedInUserId req
+			return callback(null, user_id)
 		else
 			callback() # do a per-project compile, not per-user
 
@@ -149,9 +151,9 @@ module.exports = CompileController =
 		{page, h, v} = req.query
 		if not page?.match(/^\d+$/)
 			return next(new Error("invalid page parameter"))
-		if not h?.match(/^\d+\.\d+$/)
+		if not h?.match(/^-?\d+\.\d+$/)
 			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"))
 		# whether this request is going to a per-user container
 		CompileController._compileAsUser req, (error, user_id) ->

+ 22 - 23
services/web/app/coffee/Features/Contacts/ContactController.coffee

@@ -6,33 +6,32 @@ Modules = require "../../infrastructure/Modules"
 
 module.exports = ContactsController =
 	getContacts: (req, res, next) ->
-		AuthenticationController.getLoggedInUserId req, (error, user_id) ->
+		user_id = AuthenticationController.getLoggedInUserId req
+		ContactManager.getContactIds user_id, {limit: 50}, (error, contact_ids) ->
 			return next(error) if error?
-			ContactManager.getContactIds user_id, {limit: 50}, (error, contact_ids) ->
+			UserGetter.getUsers contact_ids, {
+				email: 1, first_name: 1, last_name: 1, holdingAccount: 1
+			}, (error, contacts) ->
 				return next(error) if error?
-				UserGetter.getUsers contact_ids, {
-					email: 1, first_name: 1, last_name: 1, holdingAccount: 1
-				}, (error, contacts) ->
+
+				# UserGetter.getUsers may not preserve order so put them back in order
+				positions = {}
+				for contact_id, i in contact_ids
+					positions[contact_id] = i
+				contacts.sort (a,b) -> positions[a._id?.toString()] - positions[b._id?.toString()]
+
+				# Don't count holding accounts to discourage users from repeating mistakes (mistyped or wrong emails, etc)
+				contacts = contacts.filter (c) -> !c.holdingAccount
+
+				contacts = contacts.map(ContactsController._formatContact)
+
+				Modules.hooks.fire "getContacts", user_id, contacts, (error, additional_contacts) ->
 					return next(error) if error?
-					
-					# UserGetter.getUsers may not preserve order so put them back in order
-					positions = {}
-					for contact_id, i in contact_ids
-						positions[contact_id] = i
-					contacts.sort (a,b) -> positions[a._id?.toString()] - positions[b._id?.toString()]
+					contacts = contacts.concat(additional_contacts...)
+					res.send({
+						contacts: contacts
+					})
 
-					# Don't count holding accounts to discourage users from repeating mistakes (mistyped or wrong emails, etc)
-					contacts = contacts.filter (c) -> !c.holdingAccount
-					
-					contacts = contacts.map(ContactsController._formatContact)
-					
-					Modules.hooks.fire "getContacts", user_id, contacts, (error, additional_contacts) ->
-						return next(error) if error?
-						contacts = contacts.concat(additional_contacts...)
-						res.send({
-							contacts: contacts
-						})
-	
 	_formatContact: (contact) ->
 		return  {
 			id: contact._id?.toString()

+ 8 - 5
services/web/app/coffee/Features/Editor/EditorHttpController.coffee

@@ -9,6 +9,7 @@ AuthorizationManager = require("../Authorization/AuthorizationManager")
 ProjectEditorHandler = require('../Project/ProjectEditorHandler')
 Metrics = require('../../infrastructure/Metrics')
 CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
+CollaboratorsInviteHandler = require("../Collaborators/CollaboratorsInviteHandler")
 PrivilegeLevels = require "../Authorization/PrivilegeLevels"
 
 module.exports = EditorHttpController =
@@ -30,6 +31,7 @@ module.exports = EditorHttpController =
 				ProjectDeleter.unmarkAsDeletedByExternalSource project_id
 
 	_buildJoinProjectView: (project_id, user_id, callback = (error, project, privilegeLevel) ->) ->
+		logger.log {project_id, user_id}, "building the joinProject view"
 		ProjectGetter.getProjectWithoutDocLines project_id, (error, project) ->
 			return callback(error) if error?
 			return callback(new Error("not found")) if !project?
@@ -40,10 +42,13 @@ module.exports = EditorHttpController =
 					AuthorizationManager.getPrivilegeLevelForProject user_id, project_id, (error, privilegeLevel) ->
 						return callback(error) if error?
 						if !privilegeLevel? or privilegeLevel == PrivilegeLevels.NONE
-							callback null, null, false
-						else
+							logger.log {project_id, user_id, privilegeLevel}, "not an acceptable privilege level, returning null"
+							return callback null, null, false
+						CollaboratorsInviteHandler.getAllInvites project_id, (error, invites) ->
+							return callback(error) if error?
+							logger.log {project_id, user_id, memberCount: members.length, inviteCount: invites.length, privilegeLevel}, "returning project model view"
 							callback(null,
-								ProjectEditorHandler.buildProjectModelView(project, members),
+								ProjectEditorHandler.buildProjectModelView(project, members, invites),
 								privilegeLevel
 							)
 
@@ -135,5 +140,3 @@ module.exports = EditorHttpController =
 		EditorController.deleteEntity project_id, entity_id, entity_type, "editor", (error) ->
 			return next(error) if error?
 			res.sendStatus 204
-
-

+ 32 - 7
services/web/app/coffee/Features/Email/EmailBuilder.coffee

@@ -7,7 +7,7 @@ settings = require("settings-sharelatex")
 
 templates = {}
 
-templates.registered =	
+templates.registered =
 	subject:  _.template "Activate your #{settings.appName} Account"
 	layout: PersonalEmailLayout
 	type: "notification"
@@ -19,14 +19,14 @@ templates.registered =
 <p>If you have any questions or problems, please contact <a href="mailto:#{settings.adminEmail}">#{settings.adminEmail}</a>.</p>
 """
 
-templates.canceledSubscription = 
+templates.canceledSubscription =
 	subject:  _.template "ShareLaTeX thoughts"
 	layout: PersonalEmailLayout
 	type:"lifecycle"
 	compiledTemplate: _.template '''
 <p>Hi <%= first_name %>,</p>
 
-<p>I'm sorry to see you cancelled your ShareLaTeX premium account. Would you mind giving me some advice on what the site is lacking at the moment via <a href="https://sharelatex.typeform.com/to/F7OzIY">this survey</a>?</p>
+<p>I'm sorry to see you cancelled your ShareLaTeX premium account. Would you mind giving me some advice on what the site is lacking at the moment via <a href="https://sharelatex.typeform.com/to/f5lBiZ">this survey</a>?</p>
 
 <p>Thank you in advance.</p>
 
@@ -36,7 +36,7 @@ ShareLaTeX Co-founder
 </p>
 '''
 
-templates.passwordResetRequested =	
+templates.passwordResetRequested =
 	subject:  _.template "Password Reset - #{settings.appName}"
 	layout: NotificationEmailLayout
 	type:"notification"
@@ -66,7 +66,7 @@ If you didn't request a password reset, let us know.
 <p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
 """
 
-templates.projectSharedWithYou = 
+templates.projectSharedWithYou =
 	subject: _.template "<%= owner.email %> wants to share <%= project.name %> with you"
 	layout: NotificationEmailLayout
 	type:"notification"
@@ -87,8 +87,33 @@ templates.projectSharedWithYou =
 <p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
 """
 
+templates.projectInvite =
+	subject: _.template "<%= project.name %> - shared by <%= owner.email %>"
+	layout: NotificationEmailLayout
+	type:"notification"
+	plainTextTemplate: _.template """
+Hi, <%= owner.email %> wants to share '<%= project.name %>' with you.
+
+Follow this link to view the project: <%= inviteUrl %>
+
+Thank you
+
+#{settings.appName} - <%= siteUrl %>
+"""
+	compiledTemplate: _.template """
+<p>Hi, <%= owner.email %> wants to share <a href="<%= inviteUrl %>">'<%= project.name %>'</a> with you</p>
+<center>
+			<a style="text-decoration: none; width: 200px; background-color: #a93629; border: 1px solid #e24b3b; border-radius: 3px; padding: 15px; margin: 24px; display: block;" href="<%= inviteUrl %>" style="text-decoration:none" target="_blank">
+				<span style= "font-size:16px;font-family:Helvetica,Arial;font-weight:400;color:#fff;white-space:nowrap;display:block; text-align:center">
+					View Project
+				</span>
+			</a>
+</center>
+<p> Thank you</p>
+<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
+"""
 
-templates.completeJoinGroupAccount = 
+templates.completeJoinGroupAccount =
 	subject: _.template "Verify Email to join <%= group_name %> group"
 	layout: NotificationEmailLayout
 	type:"notification"
@@ -121,6 +146,6 @@ module.exports =
 		return {
 			subject : template.subject(opts)
 			html: template.layout(opts)
+			text: template?.plainTextTemplate?(opts)
 			type:template.type
 		}
-

+ 3 - 2
services/web/app/coffee/Features/Email/EmailHandler.coffee

@@ -3,7 +3,7 @@ EmailBuilder = require "./EmailBuilder"
 EmailSender = require "./EmailSender"
 
 if !settings.email?
-	settings.email = 
+	settings.email =
 		lifecycleEnabled:false
 
 
@@ -14,6 +14,7 @@ module.exports =
 		if email.type == "lifecycle" and !settings.email.lifecycle
 			return callback()
 		opts.html = email.html
+		opts.text = email.text
 		opts.subject = email.subject
 		EmailSender.sendEmail opts, (err)->
-			callback(err)
+			callback(err)

+ 3 - 0
services/web/app/coffee/Features/Email/EmailSender.coffee

@@ -49,8 +49,11 @@ module.exports =
 			from: defaultFromAddress
 			subject: options.subject
 			html: options.html
+			text: options.text
 			replyTo: options.replyTo || Settings.email.replyToAddress
 			socketTimeout: 30 * 1000
+		if Settings.email.textEncoding?
+			opts.textEncoding = textEncoding
 		client.sendMail options, (err, res)->
 			if err?
 				logger.err err:err, "error sending message"

+ 6 - 4
services/web/app/coffee/Features/Errors/ErrorController.coffee

@@ -1,5 +1,6 @@
 Errors = require "./Errors"
 logger = require "logger-sharelatex"
+AuthenticationController = require '../Authentication/AuthenticationController'
 
 module.exports = ErrorController =
 	notFound: (req, res)->
@@ -11,15 +12,16 @@ module.exports = ErrorController =
 		res.status(500)
 		res.render 'general/500',
 			title: "Server Error"
-	
+
 	handleError: (error, req, res, next) ->
+		user = AuthenticationController.getSessionUser(req)
 		if error?.code is 'EBADCSRFTOKEN'
-			logger.warn err: error,url:req.url, method:req.method, user:req?.sesson?.user, "invalid csrf"
+			logger.warn err: error,url:req.url, method:req.method, user:user, "invalid csrf"
 			res.sendStatus(403)
 			return
 		if error instanceof Errors.NotFoundError
 			logger.warn {err: error, url: req.url}, "not found error"
 			ErrorController.notFound req, res
 		else
-			logger.error err: error, url:req.url, method:req.method, user:req?.sesson?.user, "error passed to top level next middlewear"
-			ErrorController.serverError req, res
+			logger.error err: error, url:req.url, method:req.method, user:user, "error passed to top level next middlewear"
+			ErrorController.serverError req, res

+ 11 - 0
services/web/app/coffee/Features/Helpers/EmailHelper.coffee

@@ -0,0 +1,11 @@
+mimelib = require("mimelib")
+
+
+module.exports = EmailHelper =
+
+	parseEmail: (email) ->
+			email = mimelib.parseAddresses(email or "")[0]?.address?.toLowerCase()
+			if !email? or email == ""
+				return null
+			else
+				return email

+ 19 - 4
services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee

@@ -1,16 +1,31 @@
 logger = require("logger-sharelatex")
 NotificationsHandler = require("./NotificationsHandler")
 
-module.exports = 
+module.exports =
+
+	# Note: notification keys should be url-safe
 
 	groupPlan: (user, licence)->
 		key : "join-sub-#{licence.subscription_id}"
 		create: (callback = ->)->
-			messageOpts = 
+			messageOpts =
 				groupName: licence.name
 				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
+			logger.log user_id:user._id, key:@key, "creating notification key for user"
+			NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, null, callback
 
 		read: (callback = ->)->
 			NotificationsHandler.markAsReadWithKey user._id, @key, callback
+
+	projectInvite: (invite, project, sendingUser, user) ->
+		key: "project-invite-#{invite._id}"
+		create: (callback=()->) ->
+			messageOpts =
+				userName: sendingUser.first_name
+				projectName: project.name
+				projectId: project._id.toString()
+				token: invite.token
+			logger.log {user_id: user._id, project_id: project._id, invite_id: invite._id, key: @key}, "creating project invite notification for user"
+			NotificationsHandler.createNotification user._id, @key, "notification_project_invite", messageOpts, invite.expires, callback
+		read:  (callback=()->) ->
+			NotificationsHandler.markAsReadByKeyOnly @key, callback

+ 5 - 3
services/web/app/coffee/Features/Notifications/NotificationsController.coffee

@@ -1,18 +1,20 @@
 NotificationsHandler = require("./NotificationsHandler")
+AuthenticationController = require("../Authentication/AuthenticationController")
 logger = require("logger-sharelatex")
 _ = require("underscore")
 
 module.exports =
 
 	getAllUnreadNotifications: (req, res)->
-		NotificationsHandler.getUserNotifications req.session.user._id, (err, unreadNotifications)->
-			unreadNotifications = _.map unreadNotifications, (notification)-> 
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		NotificationsHandler.getUserNotifications user_id, (err, unreadNotifications)->
+			unreadNotifications = _.map unreadNotifications, (notification)->
 				notification.html = req.i18n.translate(notification.templateKey, notification.messageOpts)
 				return notification
 			res.send(unreadNotifications)
 
 	markNotificationAsRead: (req, res)->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		notification_id = req.params.notification_id
 		NotificationsHandler.markAsRead user_id, notification_id, ->
 			res.send()

+ 25 - 11
services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee

@@ -10,10 +10,10 @@ makeRequest = (opts, callback)->
 	else
 		request(opts, callback)
 
-module.exports = 
+module.exports =
 
 	getUserNotifications: (user_id, callback)->
-		opts = 
+		opts =
 			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
 			json: true
 			timeout: oneSecond
@@ -29,21 +29,25 @@ module.exports =
 					unreadNotifications = []
 				callback(null, unreadNotifications)
 
-	createNotification: (user_id, key, templateKey, messageOpts, callback)->
-		opts = 
-			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
-			timeout: oneSecond
-			method:"POST"
-			json: {
+	createNotification: (user_id, key, templateKey, messageOpts, expiryDateTime, callback)->
+		payload = {
 				key:key
 				messageOpts:messageOpts
 				templateKey:templateKey
-			}
+				forceCreate: true
+		}
+		if expiryDateTime?
+			payload.expires = expiryDateTime
+		opts =
+			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
+			timeout: oneSecond
+			method:"POST"
+			json: payload
 		logger.log opts:opts, "creating notification for user"
 		makeRequest opts, callback
 
 	markAsReadWithKey: (user_id, key, callback)->
-		opts = 
+		opts =
 			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
 			method: "DELETE"
 			timeout: oneSecond
@@ -52,7 +56,7 @@ module.exports =
 			}
 		logger.log user_id:user_id, key:key, "sending mark notification as read with key to notifications api"
 		makeRequest opts, callback
-	
+
 
 	markAsRead: (user_id, notification_id, callback)->
 		opts =
@@ -61,3 +65,13 @@ module.exports =
 			timeout:oneSecond
 		logger.log user_id:user_id, notification_id:notification_id, "sending mark notification as read to notifications api"
 		makeRequest opts, callback
+
+	# removes notification by key, without regard for user_id,
+	# should not be exposed to user via ui/router
+	markAsReadByKeyOnly: (key, callback)->
+		opts =
+			uri: "#{settings.apis.notifications?.url}/key/#{key}"
+			method: "DELETE"
+			timeout: oneSecond
+		logger.log {key:key}, "sending mark notification as read with key-only to notifications api"
+		makeRequest opts, callback

+ 24 - 18
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -18,6 +18,8 @@ InactiveProjectManager = require("../InactiveData/InactiveProjectManager")
 ProjectUpdateHandler = require("./ProjectUpdateHandler")
 ProjectGetter = require("./ProjectGetter")
 PrivilegeLevels = require("../Authorization/PrivilegeLevels")
+AuthenticationController = require("../Authentication/AuthenticationController")
+PackageVersions = require("../../infrastructure/PackageVersions")
 
 module.exports = ProjectController =
 
@@ -45,10 +47,10 @@ module.exports = ProjectController =
 		async.series jobs, (error) ->
 			return next(error) if error?
 			res.sendStatus(204)
-			
+
 	updateProjectAdminSettings: (req, res, next) ->
 		project_id = req.params.Project_id
-		
+
 		jobs = []
 		if req.body.publicAccessLevel?
 			jobs.push (callback) ->
@@ -88,32 +90,33 @@ module.exports = ProjectController =
 		project_id = req.params.Project_id
 		projectName = req.body.projectName
 		logger.log project_id:project_id, projectName:projectName, "cloning project"
-		if !req.session.user?
+		if !AuthenticationController.isUserLoggedIn(req)
 			return res.send redir:"/register"
-		projectDuplicator.duplicate req.session.user, project_id, projectName, (err, project)->
+		currentUser = AuthenticationController.getSessionUser(req)
+		projectDuplicator.duplicate currentUser, project_id, projectName, (err, project)->
 			if err?
-				logger.error err:err, project_id: project_id, user_id: req.session.user._id, "error cloning project"
+				logger.error err:err, project_id: project_id, user_id: currentUser._id, "error cloning project"
 				return next(err)
 			res.send(project_id:project._id)
 
 
 	newProject: (req, res)->
-		user = req.session.user
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		projectName = req.body.projectName?.trim()
 		template = req.body.template
-		logger.log user: user, projectType: template, name: projectName, "creating project"
+		logger.log user: user_id, projectType: template, name: projectName, "creating project"
 		async.waterfall [
 			(cb)->
 				if template == 'example'
-					projectCreationHandler.createExampleProject user._id, projectName, cb
+					projectCreationHandler.createExampleProject user_id, projectName, cb
 				else
-					projectCreationHandler.createBasicProject user._id, projectName, cb
+					projectCreationHandler.createBasicProject user_id, projectName, cb
 		], (err, project)->
 			if err?
-				logger.error err: err, project: project, user: user, name: projectName, templateType: template, "error creating project"
+				logger.error err: err, project: project, user: user_id, name: projectName, templateType: template, "error creating project"
 				res.sendStatus 500
 			else
-				logger.log project: project, user: user, name: projectName, templateType: template, "created project"
+				logger.log project: project, user: user_id, name: projectName, templateType: template, "created project"
 				res.send {project_id:project._id}
 
 
@@ -131,7 +134,8 @@ module.exports = ProjectController =
 
 	projectListPage: (req, res, next)->
 		timer = new metrics.Timer("project-list")
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		currentUser = AuthenticationController.getSessionUser(req)
 		async.parallel {
 			tags: (cb)->
 				TagsHandler.getAllTags user_id, cb
@@ -140,7 +144,7 @@ module.exports = ProjectController =
 			projects: (cb)->
 				ProjectGetter.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref', cb
 			hasSubscription: (cb)->
-				LimitationsManager.userHasSubscriptionOrIsGroupMember req.session.user, cb
+				LimitationsManager.userHasSubscriptionOrIsGroupMember currentUser, cb
 			user: (cb) ->
 				User.findById user_id, "featureSwitches", cb
 			}, (err, results)->
@@ -149,7 +153,7 @@ module.exports = ProjectController =
 					return next(err)
 				logger.log results:results, user_id:user_id, "rendering project list"
 				tags = results.tags[0]
-				notifications = require("underscore").map results.notifications, (notification)-> 
+				notifications = require("underscore").map results.notifications, (notification)->
 					notification.html = req.i18n.translate(notification.templateKey, notification.messageOpts)
 					return notification
 				projects = ProjectController._buildProjectList results.projects[0], results.projects[1], results.projects[2]
@@ -183,8 +187,8 @@ module.exports = ProjectController =
 		if !Settings.editorIsOpen
 			return res.render("general/closed", {title:"updating_site"})
 
-		if req.session.user?
-			user_id = req.session.user._id
+		if AuthenticationController.isUserLoggedIn(req)
+			user_id = AuthenticationController.getLoggedInUserId(req)
 			anonymous = false
 		else
 			anonymous = true
@@ -259,6 +263,7 @@ module.exports = ProjectController =
 						fontSize : user.ace.fontSize
 						autoComplete: user.ace.autoComplete
 						pdfViewer : user.ace.pdfViewer
+						syntaxValidation: user.ace.syntaxValidation
 					}
 					privilegeLevel: privilegeLevel
 					chatUrl: Settings.apis.chat.url
@@ -320,6 +325,7 @@ defaultSettingsForAnonymousUser = (user_id)->
 		autoComplete: true
 		spellCheckLanguage: ""
 		pdfViewer: ""
+		syntaxValidation: true
 	subscription:
 		freeTrial:
 			allowed: true
@@ -328,8 +334,8 @@ defaultSettingsForAnonymousUser = (user_id)->
 
 THEME_LIST = []
 do generateThemeList = () ->
-	files = fs.readdirSync __dirname + '/../../../../public/js/ace'
+	files = fs.readdirSync __dirname + '/../../../../public/js/' + PackageVersions.lib('ace')
 	for file in files
 		if file.slice(-2) == "js" and file.match(/^theme-/)
 			cleanName = file.slice(0,-3).slice(6)
-			THEME_LIST.push cleanName
+			THEME_LIST.push cleanName

+ 22 - 11
services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee

@@ -1,7 +1,7 @@
 _ = require("underscore")
 
 module.exports = ProjectEditorHandler =
-	buildProjectModelView: (project, members) ->
+	buildProjectModelView: (project, members, invites) ->
 		result =
 			_id        : project._id
 			name       : project.name
@@ -15,17 +15,16 @@ module.exports = ProjectEditorHandler =
 			deletedByExternalDataSource : project.deletedByExternalDataSource || false
 			deletedDocs: project.deletedDocs
 			members:     []
-		
-		owner = null
-		for member in members
-			if member.privilegeLevel == "owner"
-				owner = member.user
-			else
-				result.members.push @buildUserModelView member.user, member.privilegeLevel
-		if owner?
-			result.owner = @buildUserModelView owner, "owner"
+			invites:     invites
+
+		if !result.invites?
+			result.invites = []
+
+		{owner, ownerFeatures, members} = @buildOwnerAndMembersViews(members)
+		result.owner = owner
+		result.members = members
 
-		result.features = _.defaults(owner?.features or {}, {
+		result.features = _.defaults(ownerFeatures or {}, {
 			collaborators: -1 # Infinite
 			versioning: false
 			dropbox:false
@@ -37,6 +36,18 @@ module.exports = ProjectEditorHandler =
 
 		return result
 
+	buildOwnerAndMembersViews: (members) ->
+		owner = null
+		ownerFeatures = null
+		filteredMembers = []
+		for member in members
+			if member.privilegeLevel == "owner"
+				ownerFeatures = member.user.features
+				owner = @buildUserModelView member.user, "owner"
+			else
+				filteredMembers.push @buildUserModelView member.user, member.privilegeLevel
+		{owner: owner, ownerFeatures: ownerFeatures, members: filteredMembers}
+
 	buildUserModelView: (user, privileges) ->
 		_id        : user._id
 		first_name : user.first_name

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

@@ -258,6 +258,7 @@ module.exports = ProjectEntityHandler =
 					if !foundFolder?
 						logger.log path:path, project_id:project._id, folderName:folderName, "making folder from mkdirp"
 						@addFolder project_id, parentFolder_id, folderName, (err, newFolder, parentFolder_id)->
+							return callback(err) if err?
 							newFolder.parentFolder_id = parentFolder_id
 							previousFolders.push newFolder
 							callback null, previousFolders
@@ -268,6 +269,7 @@ module.exports = ProjectEntityHandler =
 
 
 			async.reduce folders, [], procesFolder, (err, folders)->
+				return callback(err) if err?
 				lastFolder = folders[folders.length-1]
 				folders = _.select folders, (folder)->
 					!folder.filterOut

+ 17 - 7
services/web/app/coffee/Features/Project/ProjectLocator.coffee

@@ -81,7 +81,7 @@ module.exports = ProjectLocator =
 				else
 					getRootDoc project
 
-	findElementByPath: (project_or_id, needlePath, callback = (err, foundEntity)->)->
+	findElementByPath: (project_or_id, needlePath, callback = (err, foundEntity, type)->)->
 
 		getParentFolder = (haystackFolder, foldersList, level, cb)->
 			if foldersList.length == 0
@@ -100,12 +100,22 @@ module.exports = ProjectLocator =
 
 		getEntity = (folder, entityName, cb)->
 			if !entityName?
-				return cb null, folder
-			enteties = _.union folder.fileRefs, folder.docs, folder.folders
-			result = _.find enteties, (entity)->
-				entity?.name.toLowerCase() == entityName.toLowerCase()
+				return cb null, folder, "folder"
+			for file in folder.fileRefs or []
+				if file?.name.toLowerCase() == entityName.toLowerCase()
+					result = file
+					type = "file"
+			for doc in folder.docs or []
+				if doc?.name.toLowerCase() == entityName.toLowerCase()
+					result = doc
+					type = "doc"
+			for childFolder in folder.folders or []
+				if childFolder?.name.toLowerCase() == entityName.toLowerCase()
+					result = childFolder
+					type = "folder"
+
 			if result?
-				cb null, result
+				cb null, result, type
 			else
 				cb("not found project_or_id: #{project_or_id} search path: #{needlePath}, entity #{entityName} could not be found")
 
@@ -117,7 +127,7 @@ module.exports = ProjectLocator =
 			if !project?
 				return callback("project could not be found for finding a element #{project_or_id}")
 			if needlePath == '' || needlePath == '/'
-				return callback(null, project.rootFolder[0])
+				return callback(null, project.rootFolder[0], "folder")
 
 			if needlePath.indexOf('/') == 0
 				needlePath = needlePath.substring(1)

+ 4 - 2
services/web/app/coffee/Features/Referal/ReferalController.coffee

@@ -1,9 +1,11 @@
 logger = require('logger-sharelatex')
 ReferalHandler = require('./ReferalHandler')
+AuthenticationController = require('../Authentication/AuthenticationController')
 
-module.exports = 
+module.exports =
 	bonus: (req, res)->
-		ReferalHandler.getReferedUserIds req.session.user._id, (err, refered_users)->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		ReferalHandler.getReferedUserIds user_id, (err, refered_users)->
 			res.render "referal/bonus",
 				title: "bonus_please_recommend_us"
 				refered_users: refered_users

+ 0 - 11
services/web/app/coffee/Features/Referal/ReferalMiddleware.coffee

@@ -1,11 +0,0 @@
-User = require("../../models/User").User
-
-module.exports = RefererMiddleware =
-	getUserReferalId: (req, res, next) ->
-		if req.session? and req.session.user?
-			User.findById req.session.user._id, (error, user) ->
-				return next(error) if error?
-				req.session.user.referal_id = user.referal_id
-				next()
-		else
-			next()

+ 7 - 7
services/web/app/coffee/Features/References/ReferencesHandler.coffee

@@ -4,7 +4,7 @@ settings = require("settings-sharelatex")
 ProjectGetter = require "../Project/ProjectGetter"
 UserGetter = require "../User/UserGetter"
 DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
-U = require('underscore')
+_ = require('underscore')
 Async = require('async')
 
 oneMinInMs = 60 * 1000
@@ -22,24 +22,24 @@ module.exports = ReferencesHandler =
 	_findBibFileIds: (project) ->
 		ids = []
 		_process = (folder) ->
-			(folder.fileRefs or []).forEach (file) ->
+			_.each (folder.fileRefs or []), (file) ->
 				if file?.name?.match(/^.*\.bib$/)
 					ids.push(file._id)
-			(folder.folders or []).forEach (folder) ->
+			_.each (folder.folders or []), (folder) ->
 				_process(folder)
-		(project.rootFolder or []).forEach (rootFolder) ->
+		_.each (project.rootFolder or []), (rootFolder) ->
 			_process(rootFolder)
 		return ids
 
 	_findBibDocIds: (project) ->
 		ids = []
 		_process = (folder) ->
-			(folder.docs or []).forEach (doc) ->
+			_.each (folder.docs or []), (doc) ->
 				if doc?.name?.match(/^.*\.bib$/)
 					ids.push(doc._id)
-			(folder.folders or []).forEach (folder) ->
+			_.each (folder.folders or []), (folder) ->
 				_process(folder)
-		(project.rootFolder or []).forEach (rootFolder) ->
+		_.each (project.rootFolder or []), (rootFolder) ->
 			_process(rootFolder)
 		return ids
 

+ 1 - 1
services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee

@@ -30,5 +30,5 @@ module.exports =
 		multi.get buildKey(token)
 		multi.del buildKey(token)
 		multi.exec (err, results)->
-			callback err, results[0]
+			callback err, results?[0]
 

+ 6 - 8
services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee

@@ -1,24 +1,22 @@
 RateLimiter = require "../../infrastructure/RateLimiter"
 logger = require "logger-sharelatex"
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 module.exports = RateLimiterMiddlewear =
 	###
 	Do not allow more than opts.maxRequests from a single client in
 	opts.timeInterval. Pass an array of opts.params to segment this based on
 	parameters in the request URL, e.g.:
-	
+
 	    app.get "/project/:project_id", RateLimiterMiddlewear.rateLimit(endpointName: "open-editor", params: ["project_id"])
-	
+
 	will rate limit each project_id separately.
-	
+
 	Unique clients are identified by user_id if logged in, and IP address if not.
 	###
 	rateLimit: (opts) ->
 		return (req, res, next) ->
-			if req.session?.user?
-				user_id = req.session.user._id
-			else
-				user_id = req.ip
+			user_id = AuthenticationController.getLoggedInUserId(req) || req.ip
 			params = (opts.params or []).map (p) -> req.params[p]
 			params.push user_id
 			if !opts.endpointName?
@@ -37,4 +35,4 @@ module.exports = RateLimiterMiddlewear =
 					logger.warn options, "rate limit exceeded"
 					res.status(429) # Too many requests
 					res.write("Rate limit reached, please try again later")
-					res.end()
+					res.end()

+ 3 - 1
services/web/app/coffee/Features/Spelling/SpellingController.coffee

@@ -1,13 +1,15 @@
 request = require 'request'
 Settings = require 'settings-sharelatex'
 logger = require 'logger-sharelatex'
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 TEN_SECONDS = 1000 * 10
 
 module.exports = SpellingController =
 	proxyRequestToSpellingApi: (req, res, next) ->
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		url = req.url.slice("/spelling".length)
-		url = "/user/#{req.session.user._id}#{url}"
+		url = "/user/#{user_id}#{url}"
 		req.headers["Host"] = Settings.apis.spelling.host
 		request(url: Settings.apis.spelling.url + url, method: req.method, headers: req.headers, json: req.body, timeout:TEN_SECONDS)
 		.on "error", (error) ->

+ 3 - 2
services/web/app/coffee/Features/StaticPages/HomeController.coffee

@@ -5,12 +5,13 @@ Path = require "path"
 fs = require "fs"
 
 ErrorController = require "../Errors/ErrorController"
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 homepageExists = fs.existsSync Path.resolve(__dirname + "/../../../views/external/home.jade")
 
 module.exports = HomeController =
 	index : (req,res)->
-		if req.session.user
+		if AuthenticationController.isUserLoggedIn(req)
 			if req.query.scribtex_path?
 				res.redirect "/project?scribtex_path=#{req.query.scribtex_path}"
 			else
@@ -33,4 +34,4 @@ module.exports = HomeController =
 					res.render "external/#{page}.jade",
 						title: title
 				else
-					ErrorController.notFound(req, res, next)
+					ErrorController.notFound(req, res, next)

+ 5 - 59
services/web/app/coffee/Features/StaticPages/UniversityController.coffee

@@ -1,70 +1,16 @@
-request = require("request")
 settings = require("settings-sharelatex")
 logger = require("logger-sharelatex")
-_ = require("underscore")
-ErrorController = require "../Errors/ErrorController"
-StaticPageHelpers = require("./StaticPageHelpers")
-sanitize = require('sanitizer')
 Settings = require("settings-sharelatex")
-contentful = require('contentful')
-marked = require("marked")
+sixpack = require("../../infrastructure/Sixpack")
 
 
 
-
-module.exports = UniversityController = 
+module.exports = UniversityController =
 
 	getPage: (req, res, next)->
-		url = req.url?.toLowerCase()
-		universityUrl = "#{settings.apis.university.url}#{url}"
-		if StaticPageHelpers.shouldProxy(url) 
-			return UniversityController._directProxy universityUrl, res
-
-		logger.log url:url, "proxying request to university api"
-		request.get universityUrl, (err, r, data)->
-			if r?.statusCode == 404
-				return UniversityController.getContentfulPage(req, res, next)
-			if err?
-				return res.send 500
-			data = data.trim()
-			try
-				data = JSON.parse(data)
-				data.content = data.content.replace(/__ref__/g, sanitize.escape(req.query.ref))
-			catch err
-				logger.err err:err, data:data, "error parsing data from data"
-			res.render "university/university_holder", data
-
+		url = req.url?.toLowerCase().replace(".html","")
+		return res.redirect("/i#{url}")
 
 	getIndexPage: (req, res)->
-		req.url = "/university/index.html"
-		UniversityController.getPage req, res
-
-	_directProxy: (originUrl, res)->
-		upstream = request.get(originUrl)
-		upstream.on "error", (error) ->
-			logger.error err: error, "university proxy error"
-		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
-
-
+		return res.redirect("/i/university")
 

+ 8 - 6
services/web/app/coffee/Features/Subscription/LimitationsManager.coffee

@@ -4,6 +4,7 @@ User = require("../../models/User").User
 SubscriptionLocator = require("./SubscriptionLocator")
 Settings = require("settings-sharelatex")
 CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
+CollaboratorsInvitesHandler = require("../Collaborators/CollaboratorsInviteHandler")
 
 module.exports =
 
@@ -20,10 +21,12 @@ module.exports =
 			return callback(error) if error?
 			CollaboratorsHandler.getCollaboratorCount project_id, (error, current_number) =>
 				return callback(error) if error?
-				if current_number + x_collaborators <= allowed_number or allowed_number < 0
-					callback null, true
-				else
-					callback null, false
+				CollaboratorsInvitesHandler.getInviteCount project_id, (error, invite_count) =>
+					return callback(error) if error?
+					if current_number + invite_count + x_collaborators <= allowed_number or allowed_number < 0
+						callback null, true
+					else
+						callback null, false
 
 	userHasSubscriptionOrIsGroupMember: (user, callback = (err, hasSubscriptionOrIsMember)->) ->
 		@userHasSubscription user, (err, hasSubscription, subscription)=>
@@ -41,7 +44,7 @@ module.exports =
 			hasValidSubscription = subscription? and (subscription.recurlySubscription_id? or subscription?.customAccount == true)
 			logger.log user:user, hasValidSubscription:hasValidSubscription, subscription:subscription, "checking if user has subscription"
 			callback err, hasValidSubscription, subscription
-			
+
 	userIsMemberOfGroupSubscription: (user, callback = (error, isMember, subscriptions) ->) ->
 		logger.log user_id: user._id, "checking is user is member of subscription groups"
 		SubscriptionLocator.getMemberSubscriptions user._id, (err, subscriptions = []) ->
@@ -65,4 +68,3 @@ getOwnerOfProject = (project_id, callback)->
 		return callback(error) if error?
 		User.findById project.owner_ref, (error, owner) ->
 			callback(error, owner)
-

+ 10 - 4
services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee

@@ -29,13 +29,15 @@ module.exports = RecurlyWrapper =
 			RecurlyWrapper.apiRequest({
 				url:    "accounts/#{user._id}"
 				method: "GET"
+				expect404: true
 			}, (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)
+				if response.statusCode == 404  # actually not an error in this case, just no existing account
+					logger.log {user_id: user._id, recurly_token_id}, "user does not currently exist in recurly, proceed"
+					cache.userExists = false
+					return next(null, cache)
 				logger.log {user_id: user._id, recurly_token_id}, "user appears to exist in recurly"
 				RecurlyWrapper._parseAccountXml responseBody, (err, account) ->
 					if err
@@ -236,10 +238,14 @@ module.exports = RecurlyWrapper =
 			"Authorization" : "Basic " + new Buffer(Settings.apis.recurly.apiKey).toString("base64")
 			"Accept"        : "application/xml"
 			"Content-Type"  : "application/xml; charset=utf-8"
+		expect404 = options.expect404
+		delete options.expect404
 		request options, (error, response, body) ->
-			unless error? or response.statusCode == 200 or response.statusCode == 201 or response.statusCode == 204
+			unless error? or response.statusCode == 200 or response.statusCode == 201 or response.statusCode == 204 or (response.statusCode == 404 and expect404)
 				logger.err err:error, body:body, options:options, statusCode:response?.statusCode, "error returned from recurly"
 				error = "Recurly API returned with status code: #{response.statusCode}"
+			if response.statusCode == 404 and expect404
+				logger.log {url: options.url, method: options.method}, "got 404 response from recurly, expected as valid response"
 			callback(error, response, body)
 
 	sign : (parameters, callback) ->

+ 184 - 173
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -13,15 +13,16 @@ module.exports = SubscriptionController =
 
 	plansPage: (req, res, next) ->
 		plans = SubscriptionViewModelBuilder.buildViewModel()
-		if !req.session.user?
-			baseUrl = "/register?redir="
-		else
+		if AuthenticationController.isUserLoggedIn(req)
 			baseUrl = ""
+		else
+			baseUrl = "/register?redir="
 		viewName = "subscriptions/plans"
 		if req.query.v?
 			viewName = "#{viewName}_#{req.query.v}"
 		logger.log viewName:viewName, "showing plans page"
 		GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency)->
+			return next(err) if err?
 			res.render viewName,
 				title: "plans_and_pricing"
 				plans: plans
@@ -31,200 +32,210 @@ module.exports = SubscriptionController =
 
 	#get to show the recurly.js page
 	paymentPage: (req, res, next) ->
-		AuthenticationController.getLoggedInUser req, (error, user) =>
-			return next(error) if error?
-			plan = PlansLocator.findLocalPlanInSettings(req.query.planCode)
-			LimitationsManager.userHasSubscription user, (err, hasSubscription)->
-				return next(err) if err?
-				if hasSubscription or !plan?
-					res.redirect "/user/subscription"
-				else
-					currency = req.query.currency?.toUpperCase()
-					GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency, countryCode)->
-						return next(err) if err?
-						if recomendedCurrency? and !currency?
-							currency = recomendedCurrency
-						RecurlyWrapper.sign {
-							subscription:
-								plan_code : req.query.planCode
-								currency: currency
-							account_code: user._id
-						}, (error, signature) ->
-							return next(error) if error?
-							res.render "subscriptions/new",
-								title      : "subscribe"
-								plan_code: req.query.planCode
+		user = AuthenticationController.getSessionUser(req)
+		plan = PlansLocator.findLocalPlanInSettings(req.query.planCode)
+		LimitationsManager.userHasSubscription user, (err, hasSubscription)->
+			return next(err) if err?
+			if hasSubscription or !plan?
+				res.redirect "/user/subscription"
+			else
+				currency = req.query.currency?.toUpperCase()
+				GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency, countryCode)->
+					return next(err) if err?
+					if recomendedCurrency? and !currency?
+						currency = recomendedCurrency
+					RecurlyWrapper.sign {
+						subscription:
+							plan_code : req.query.planCode
+							currency: currency
+						account_code: user._id
+					}, (error, signature) ->
+						return next(error) if error?
+						res.render "subscriptions/new",
+							title      : "subscribe"
+							plan_code: req.query.planCode
+							currency: currency
+							countryCode:countryCode
+							plan:plan
+							showStudentPlan: req.query.ssp
+							recurlyConfig: JSON.stringify
 								currency: currency
-								countryCode:countryCode
-								plan:plan
-								showStudentPlan: req.query.ssp
-								recurlyConfig: JSON.stringify
-									currency: currency
-									subdomain: Settings.apis.recurly.subdomain
-								showCouponField: req.query.scf
-								showVatField: req.query.svf
-								couponCode:      req.query.cc or ""
+								subdomain: Settings.apis.recurly.subdomain
+							showCouponField: req.query.scf
+							showVatField: req.query.svf
+							couponCode:      req.query.cc or ""
 
 
 
 	userSubscriptionPage: (req, res, next) ->
-		AuthenticationController.getLoggedInUser req, (error, user) =>
-			return next(error) if error?
-			LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)->
-				groupLicenceInviteUrl = SubscriptionDomainHandler.getDomainLicencePage(user)
-				if subscription?.customAccount
-					logger.log user: user, "redirecting to custom account page"
-					res.redirect "/user/subscription/custom_account"
-				else if groupLicenceInviteUrl? and !hasSubOrIsGroupMember
-					logger.log user:user, "redirecting to group subscription invite page"		
-					res.redirect groupLicenceInviteUrl
-				else if !hasSubOrIsGroupMember
-					logger.log user: user, "redirecting to plans"
-					res.redirect "/user/subscription/plans"
-				else
-					SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groupSubscriptions) ->
-						return next(error) if error?
-						logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, groupSubscriptions:groupSubscriptions, "showing subscription dashboard"
-						plans = SubscriptionViewModelBuilder.buildViewModel()
-						res.render "subscriptions/dashboard",
-							title: "your_subscription"
-							recomendedCurrency: subscription?.currency
-							taxRate:subscription?.taxRate
-							plans: plans
-							subscription: subscription || {}
-							groupSubscriptions: groupSubscriptions
-							subscriptionTabActive: true
-							user:user
-
+		user = AuthenticationController.getSessionUser(req)
+		LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)->
+			return next(err) if err?
+			groupLicenceInviteUrl = SubscriptionDomainHandler.getDomainLicencePage(user)
+			if subscription?.customAccount
+				logger.log user: user, "redirecting to custom account page"
+				res.redirect "/user/subscription/custom_account"
+			else if groupLicenceInviteUrl? and !hasSubOrIsGroupMember
+				logger.log user:user, "redirecting to group subscription invite page"
+				res.redirect groupLicenceInviteUrl
+			else if !hasSubOrIsGroupMember
+				logger.log user: user, "redirecting to plans"
+				res.redirect "/user/subscription/plans"
+			else
+				SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groupSubscriptions) ->
+					return next(error) if error?
+					logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, groupSubscriptions:groupSubscriptions, "showing subscription dashboard"
+					plans = SubscriptionViewModelBuilder.buildViewModel()
+					res.render "subscriptions/dashboard",
+						title: "your_subscription"
+						recomendedCurrency: subscription?.currency
+						taxRate:subscription?.taxRate
+						plans: plans
+						subscription: subscription || {}
+						groupSubscriptions: groupSubscriptions
+						subscriptionTabActive: true
+						user:user
+						saved_billing_details: req.query.saved_billing_details?
 
 	userCustomSubscriptionPage: (req, res, next)->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)->
-				if !subscription?
-					err = new Error("subscription null for custom account, user:#{user?._id}")
-					logger.warn err:err, "subscription is null for custom accounts page"
-					return next(err)
-				res.render "subscriptions/custom_account",
-					title: "your_subscription"
-					subscription: subscription
+		user = AuthenticationController.getSessionUser(req)
+		LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)->
+			return next(err) if err?
+			if !subscription?
+				err = new Error("subscription null for custom account, user:#{user?._id}")
+				logger.warn err:err, "subscription is null for custom accounts page"
+				return next(err)
+			res.render "subscriptions/custom_account",
+				title: "your_subscription"
+				subscription: subscription
 
 
 	editBillingDetailsPage: (req, res, next) ->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			return next(error) if error?
-			LimitationsManager.userHasSubscription user, (err, hasSubscription)->
-				if !hasSubscription
-					res.redirect "/user/subscription"
-				else
-					RecurlyWrapper.sign {
-						account_code: user._id
-					}, (error, signature) ->
-						return next(error) if error?
-						res.render "subscriptions/edit-billing-details",
-							title      : "update_billing_details"
-							recurlyConfig: JSON.stringify
-								currency: "USD"
-								subdomain: Settings.apis.recurly.subdomain
-							signature  : signature
-							successURL : "#{Settings.siteUrl}/user/subscription/update"
-							user       :
-								id : user._id
-
-	createSubscription: (req, res, next)->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			return callback(error) if error?
-			recurly_token_id = req.body.recurly_token_id
-			subscriptionDetails = req.body.subscriptionDetails
-			logger.log recurly_token_id: recurly_token_id, user_id:user._id, subscriptionDetails:subscriptionDetails, "creating subscription"
-			SubscriptionHandler.createSubscription user, subscriptionDetails, recurly_token_id, (err)->
-				if err?
-					logger.err err:err, user_id:user._id, "something went wrong creating subscription"
-					return res.sendStatus 500
-				res.sendStatus 201
-
-	successful_subscription: (req, res)->
-		AuthenticationController.getLoggedInUser req, (error, user) =>
-			SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription) ->
-				res.render "subscriptions/successful_subscription",
-					title: "thank_you"
-					subscription:subscription
-
-	cancelSubscription: (req, res, next) ->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			logger.log user_id:user._id, "canceling subscription"
-			return next(error) if error?
-			SubscriptionHandler.cancelSubscription user, (err)->
-				if err?
-					logger.err err:err, user_id:user._id, "something went wrong canceling subscription"
-				res.redirect "/user/subscription"
- 
-	updateSubscription: (req, res)->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			return next(error) if error?
-			planCode = req.body.plan_code
-			logger.log planCode: planCode, user_id:user._id, "updating subscription"
-			SubscriptionHandler.updateSubscription user, planCode, null, (err)->
-				if err?
-					logger.err err:err, user_id:user._id, "something went wrong updating subscription"
+		user = AuthenticationController.getSessionUser(req)
+		LimitationsManager.userHasSubscription user, (err, hasSubscription)->
+			return next(err) if err?
+			if !hasSubscription
 				res.redirect "/user/subscription"
+			else
+				RecurlyWrapper.sign {
+					account_code: user._id
+				}, (error, signature) ->
+					return next(error) if error?
+					res.render "subscriptions/edit-billing-details",
+						title      : "update_billing_details"
+						recurlyConfig: JSON.stringify
+							currency: "USD"
+							subdomain: Settings.apis.recurly.subdomain
+						signature  : signature
+						successURL : "#{Settings.siteUrl}/user/subscription/billing-details/update"
+						user       :
+							id : user._id
+
+	updateBillingDetails: (req, res, next) ->
+		res.redirect "/user/subscription?saved_billing_details=true"
 
-	reactivateSubscription: (req, res)->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			logger.log user_id:user._id, "reactivating subscription"
+	createSubscription: (req, res, next)->
+		user = AuthenticationController.getSessionUser(req)
+		recurly_token_id = req.body.recurly_token_id
+		subscriptionDetails = req.body.subscriptionDetails
+		logger.log recurly_token_id: recurly_token_id, user_id:user._id, subscriptionDetails:subscriptionDetails, "creating subscription"
+		SubscriptionHandler.createSubscription user, subscriptionDetails, recurly_token_id, (err)->
+			if err?
+				logger.err err:err, user_id:user._id, "something went wrong creating subscription"
+				return res.sendStatus 500
+			res.sendStatus 201
+
+	successful_subscription: (req, res, next)->
+		user = AuthenticationController.getSessionUser(req)
+		SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription) ->
 			return next(error) if error?
-			SubscriptionHandler.reactivateSubscription user, (err)->
-				if err?
-					logger.err err:err, user_id:user._id, "something went wrong reactivating subscription"
-				res.redirect "/user/subscription"
+			res.render "subscriptions/successful_subscription",
+				title: "thank_you"
+				subscription:subscription
 
-	recurlyCallback: (req, res)->
+	cancelSubscription: (req, res, next) ->
+		user = AuthenticationController.getSessionUser(req)
+		logger.log user_id:user._id, "canceling subscription"
+		SubscriptionHandler.cancelSubscription user, (err)->
+			if err?
+				logger.err err:err, user_id:user._id, "something went wrong canceling subscription"
+				return next(err)
+			res.redirect "/user/subscription"
+
+	updateSubscription: (req, res, next)->
+		_origin = req?.query?.origin || null
+		user = AuthenticationController.getSessionUser(req)
+		planCode = req.body.plan_code
+		if !planCode?
+			err = new Error('plan_code is not defined')
+			logger.err {user_id: user._id, err, planCode, origin: _origin, body: req.body}, "[Subscription] error in updateSubscription form"
+			return next(err)
+		logger.log planCode: planCode, user_id:user._id, "updating subscription"
+		SubscriptionHandler.updateSubscription user, planCode, null, (err)->
+			if err?
+				logger.err err:err, user_id:user._id, "something went wrong updating subscription"
+				return next(err)
+			res.redirect "/user/subscription"
+
+	reactivateSubscription: (req, res, next)->
+		user = AuthenticationController.getSessionUser(req)
+		logger.log user_id:user._id, "reactivating subscription"
+		SubscriptionHandler.reactivateSubscription user, (err)->
+			if err?
+				logger.err err:err, user_id:user._id, "something went wrong reactivating subscription"
+				return next(err)
+			res.redirect "/user/subscription"
+
+	recurlyCallback: (req, res, next)->
 		logger.log data: req.body, "received recurly callback"
 		# we only care if a subscription has exipired
 		if req.body? and req.body["expired_subscription_notification"]?
 			recurlySubscription = req.body["expired_subscription_notification"].subscription
-			SubscriptionHandler.recurlyCallback recurlySubscription, ->
+			SubscriptionHandler.recurlyCallback recurlySubscription, (err)->
+				return next(err) if err?
 				res.sendStatus 200
 		else
 			res.sendStatus 200
 
-	renderUpgradeToAnnualPlanPage: (req, res)->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)->
-				planCode = subscription?.planCode.toLowerCase()
-				if planCode?.indexOf("annual") != -1
-					planName = "annual"
-				else if planCode?.indexOf("student") != -1
-					planName = "student"
-				else if planCode?.indexOf("collaborator") != -1
-					planName = "collaborator"
-				if !hasSubscription
-					return res.redirect("/user/subscription/plans")
-				logger.log planName:planName, user_id:user._id, "rendering upgrade to annual page"
-				res.render "subscriptions/upgradeToAnnual",
-					title: "Upgrade to annual"
-					planName: planName
-
-	processUpgradeToAnnualPlan: (req, res)->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			{planName} = req.body
-			coupon_code = Settings.coupon_codes.upgradeToAnnualPromo[planName]
-			annualPlanName = "#{planName}-annual"
-			logger.log user_id:user._id, planName:annualPlanName, "user is upgrading to annual billing with discount"
-			SubscriptionHandler.updateSubscription user, annualPlanName, coupon_code, (err)->
+	renderUpgradeToAnnualPlanPage: (req, res, next)->
+		user = AuthenticationController.getSessionUser(req)
+		LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)->
+			return next(err) if err?
+			planCode = subscription?.planCode.toLowerCase()
+			if planCode?.indexOf("annual") != -1
+				planName = "annual"
+			else if planCode?.indexOf("student") != -1
+				planName = "student"
+			else if planCode?.indexOf("collaborator") != -1
+				planName = "collaborator"
+			if !hasSubscription
+				return res.redirect("/user/subscription/plans")
+			logger.log planName:planName, user_id:user._id, "rendering upgrade to annual page"
+			res.render "subscriptions/upgradeToAnnual",
+				title: "Upgrade to annual"
+				planName: planName
+
+	processUpgradeToAnnualPlan: (req, res, next)->
+		user = AuthenticationController.getSessionUser(req)
+		{planName} = req.body
+		coupon_code = Settings.coupon_codes.upgradeToAnnualPromo[planName]
+		annualPlanName = "#{planName}-annual"
+		logger.log user_id:user._id, planName:annualPlanName, "user is upgrading to annual billing with discount"
+		SubscriptionHandler.updateSubscription user, annualPlanName, coupon_code, (err)->
+			if err?
+				logger.err err:err, user_id:user._id, "error updating subscription"
+				return next(err)
+			res.sendStatus 200
+
+	extendTrial: (req, res, next)->
+		user = AuthenticationController.getSessionUser(req)
+		LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)->
+			return next(err) if err?
+			SubscriptionHandler.extendTrial subscription, 14, (err)->
 				if err?
-					logger.err err:err, user_id:user._id, "error updating subscription"
-					res.sendStatus 500
+					res.send 500
 				else
-					res.sendStatus 200
-
-	extendTrial: (req, res)->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)->
-				SubscriptionHandler.extendTrial subscription, 14, (err)->
-					if err?
-						res.send 500
-					else
-						res.send 200
+					res.send 200
 
 	recurlyNotificationParser: (req, res, next) ->
 		xml = ""

+ 19 - 14
services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee

@@ -3,27 +3,28 @@ logger = require("logger-sharelatex")
 SubscriptionLocator = require("./SubscriptionLocator")
 ErrorsController = require("../Errors/ErrorController")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
+AuthenticationController = require('../Authentication/AuthenticationController')
 _ = require("underscore")
 async = require("async")
 
 module.exports =
 
 	addUserToGroup: (req, res)->
-		adminUserId = req.session.user._id
+		adminUserId = AuthenticationController.getLoggedInUserId(req)
 		newEmail = req.body?.email?.toLowerCase()?.trim()
 		logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group subscription"
 		SubscriptionGroupHandler.addUserToGroup adminUserId, newEmail, (err, user)->
 			if err?
 				logger.err err:err, newEmail:newEmail, adminUserId:adminUserId, "error adding user from group"
 				return res.sendStatus 500
-			result = 
+			result =
 				user:user
 			if err and err.limitReached
 				result.limitReached = true
 			res.json(result)
 
 	removeUserFromGroup: (req, res)->
-		adminUserId = req.session.user._id
+		adminUserId = AuthenticationController.getLoggedInUserId(req)
 		userToRemove_id = req.params.user_id
 		logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription"
 		SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)->
@@ -31,10 +32,10 @@ module.exports =
 				logger.err err:err, adminUserId:adminUserId, userToRemove_id:userToRemove_id, "error removing user from group"
 				return res.sendStatus 500
 			res.send()
-			
+
 	removeSelfFromGroup: (req, res)->
 		adminUserId = req.query.admin_user_id
-		userToRemove_id = req.session.user._id
+		userToRemove_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription after self request"
 		SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)->
 			if err?
@@ -43,7 +44,7 @@ module.exports =
 			res.send()
 
 	renderSubscriptionGroupAdminPage: (req, res)->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
 			if !subscription.groupPlan
 				return res.redirect("/")
@@ -55,11 +56,11 @@ module.exports =
 
 	renderGroupInvitePage: (req, res)->
 		group_subscription_id = req.params.subscription_id
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(group_subscription_id)
 		if !licence?
 			return ErrorsController.notFound(req, res)
-		jobs = 
+		jobs =
 			partOfGroup: (cb)->
 				SubscriptionGroupHandler.isUserPartOfGroup user_id, licence.group_subscription_id, cb
 			subscription: (cb)->
@@ -77,22 +78,26 @@ module.exports =
 
 	beginJoinGroup: (req, res)->
 		subscription_id = req.params.subscription_id
-		user_id = req.session.user._id
+		currentUser = AuthenticationController.getSessionUser(req)
+		if !currentUser?
+			logger.err {subscription_id}, "error getting current user"
+			return res.sendStatus 500
 		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)
 		if !licence?
 			return ErrorsController.notFound(req, res)
-		SubscriptionGroupHandler.sendVerificationEmail subscription_id, licence.name, req.session.user.email, (err)->
+		SubscriptionGroupHandler.sendVerificationEmail subscription_id, licence.name, currentUser.email, (err)->
 			if err?
 				res.sendStatus 500
 			else
 				res.sendStatus 200
 
 	completeJoin: (req, res)->
+		currentUser = AuthenticationController.getSessionUser(req)
 		subscription_id = req.params.subscription_id
 		if !SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)?
 			return ErrorsController.notFound(req, res)
-		email = req?.session?.user?.email
-		logger.log subscription_id:subscription_id, user_id:req?.session?.user?._id, email:email, "starting the completion of joining group"
+		email = currentUser?.email
+		logger.log subscription_id:subscription_id, user_id:currentUser?._id, email:email, "starting the completion of joining group"
 		SubscriptionGroupHandler.processGroupVerification email, subscription_id, req.query?.token, (err)->
 			if err? and err == "token_not_found"
 				return res.redirect "/user/subscription/#{subscription_id}/group/invited?expired=true"
@@ -109,10 +114,10 @@ module.exports =
 			return ErrorsController.notFound(req, res)
 		res.render "subscriptions/group/successful_join",
 			title: "Sucessfully joined group"
-			licenceName:licence.name	
+			licenceName:licence.name
 
 	exportGroupCsv: (req, res)->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log user_id: user_id, "exporting group csv"
 		SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
 			if !subscription.groupPlan

+ 3 - 0
services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee

@@ -7,6 +7,8 @@ SubscriptionUpdater = require("./SubscriptionUpdater")
 LimitationsManager = require('./LimitationsManager')
 EmailHandler = require("../Email/EmailHandler")
 Events = require "../../infrastructure/Events"
+Analytics = require("../Analytics/AnalyticsManager")
+
 
 module.exports =
 
@@ -52,6 +54,7 @@ module.exports =
 					setTimeout (-> EmailHandler.sendEmail "canceledSubscription", emailOpts
 					), ONE_HOUR_IN_MS
 					Events.emit "cancelSubscription", user._id
+					Analytics.recordEvent user._id, "subscription-canceled"
 					callback()
 			else
 				callback()

+ 6 - 2
services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee

@@ -14,7 +14,11 @@ module.exports =
 			logger.log user_id:user_id, "got users subscription"
 			callback(err, subscription)
 
-	getMemberSubscriptions: (user_id, callback) ->
+	getMemberSubscriptions: (user_or_id, callback) ->
+		if user_or_id? and user_or_id._id?
+			user_id = user_or_id._id
+		else if user_or_id?
+			user_id = user_or_id
 		logger.log user_id: user_id, "getting users group subscriptions"
 		Subscription.find(member_ids: user_id).populate("admin_id").exec callback
 
@@ -25,4 +29,4 @@ module.exports =
 		Subscription.findOne {member_ids: user_id, _id:subscription_id}, {_id:1}, callback
 
 	getGroupSubscriptionMemberOf: (user_id, callback)->
-		Subscription.findOne {member_ids: user_id}, {_id:1, planCode:1}, callback
+		Subscription.findOne {member_ids: user_id}, {_id:1, planCode:1}, callback

+ 1 - 0
services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee

@@ -16,6 +16,7 @@ module.exports =
 		
 		webRouter.get  '/user/subscription/new',        AuthenticationController.requireLogin(), SubscriptionController.paymentPage 
 		webRouter.get  '/user/subscription/billing-details/edit', AuthenticationController.requireLogin(), SubscriptionController.editBillingDetailsPage
+		webRouter.post '/user/subscription/billing-details/update', AuthenticationController.requireLogin(), SubscriptionController.updateBillingDetails
 
 		webRouter.get  '/user/subscription/thank-you', AuthenticationController.requireLogin(), SubscriptionController.successful_subscription
 

+ 5 - 0
services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee

@@ -4,6 +4,7 @@ PlansLocator = require("./PlansLocator")
 SubscriptionFormatters = require("./SubscriptionFormatters")
 LimitationsManager = require("./LimitationsManager")
 SubscriptionLocator = require("./SubscriptionLocator")
+logger = require('logger-sharelatex')
 _ = require("underscore")
 
 module.exports =
@@ -16,6 +17,10 @@ module.exports =
 				if subscription?
 					return callback(error) if error?
 					plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
+					if !plan?
+						err = new Error("No plan found for planCode '#{subscription.planCode}'")
+						logger.error {user_id: user._id, err}, "error getting subscription plan for user"
+						return callback(err)
 					RecurlyWrapper.getSubscription subscription.recurlySubscription_id, (err, recurlySubscription)->
 						tax = recurlySubscription?.tax_in_cents || 0
 						callback null, {

+ 12 - 11
services/web/app/coffee/Features/Tags/TagsController.coffee

@@ -1,48 +1,49 @@
 TagsHandler = require("./TagsHandler")
 logger = require("logger-sharelatex")
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 module.exports =
 	getAllTags: (req, res, next)->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {user_id}, "getting tags"
 		TagsHandler.getAllTags user_id, (error, allTags)->
 			return next(error) if error?
 			res.json(allTags)
-	
+
 	createTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		name = req.body.name
 		logger.log {user_id, name}, "creating tag"
 		TagsHandler.createTag user_id, name, (error, tag) ->
 			return next(error) if error?
 			res.json(tag)
-	
+
 	addProjectToTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		{tag_id, project_id} = req.params
 		logger.log {user_id, tag_id, project_id}, "adding tag to project"
 		TagsHandler.addProjectToTag user_id, tag_id, project_id, (error) ->
 			return next(error) if error?
 			res.status(204).end()
-	
+
 	removeProjectFromTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		{tag_id, project_id} = req.params
 		logger.log {user_id, tag_id, project_id}, "removing tag from project"
 		TagsHandler.removeProjectFromTag user_id, tag_id, project_id, (error) ->
 			return next(error) if error?
 			res.status(204).end()
-	
+
 	deleteTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		tag_id = req.params.tag_id
 		logger.log {user_id, tag_id}, "deleting tag"
 		TagsHandler.deleteTag user_id, tag_id, (error) ->
 			return next(error) if error?
 			res.status(204).end()
-	
+
 	renameTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		tag_id = req.params.tag_id
 		name = req.body?.name
 		if !name?

+ 12 - 13
services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee

@@ -33,16 +33,11 @@ module.exports =
 						self.p.processDoc project_id, elementId, user_id, fsPath, path, source, callback
 
 	deleteUpdate: (project_id, path, source, callback)->
-		projectLocator.findElementByPath project_id, path, (err, element)->
-			type = 'file'
+		projectLocator.findElementByPath project_id, path, (err, element, type)->
 			if  err? || !element?
 				logger.log element:element, project_id:project_id, path:path, "could not find entity for deleting, assuming it was already deleted"
 				return callback()
-			if element.lines?
-				type = 'doc'
-			else if element.folders?
-				type = 'folder'
-			logger.log project_id:project_id, updateType:path, updateType:type, element:element, "processing update to delete entity from tpds"
+			logger.log project_id:project_id, path:path, type:type, element:element, "processing update to delete entity from tpds"
 			editorController.deleteEntity project_id, element._id, type, source, (err)->
 				logger.log project_id:project_id, path:path, "finished processing update to delete entity from tpds"
 				callback()
@@ -56,12 +51,13 @@ module.exports =
 					return callback(err)
 				logger.log docLines:docLines, doc_id:doc_id, project_id:project_id, "processing doc update from tpds"
 				if doc_id?
-					editorController.setDoc project_id, doc_id, user_id, docLines, source, (err)->
-						callback()
+					editorController.setDoc project_id, doc_id, user_id, docLines, source, callback
 				else
 					setupNewEntity project_id, path, (err, folder, fileName)->
-						editorController.addDoc project_id, folder._id, fileName, docLines, source, (err)->
-							callback()
+						if err?
+							logger.err err:err, project_id:project_id, doc_id:doc_id, path:path, "error processing file"
+							return callback(err)
+						editorController.addDoc project_id, folder._id, fileName, docLines, source, callback
 
 		processFile: (project_id, file_id, fsPath, path, source, callback)->
 			finish = (err)->
@@ -69,10 +65,13 @@ module.exports =
 				callback(err)
 			logger.log project_id:project_id, file_id:file_id, path:path, "processing file update from tpds"
 			setupNewEntity project_id, path, (err, folder, fileName) =>
-				if file_id?
+				if err?
+					logger.err err:err, project_id:project_id, file_id:file_id, path:path, "error processing file"
+					return callback(err)
+				else if file_id?
 					editorController.replaceFile project_id, file_id, fsPath, source, finish
 				else
-					editorController.addFile project_id, folder._id, fileName, fsPath, source, finish
+					editorController.addFile project_id, folder?._id, fileName, fsPath, source, finish
 
 		writeStreamToDisk: (project_id, file_id, stream, callback = (err, fsPath)->)->
 			if !file_id?

+ 13 - 14
services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee

@@ -5,17 +5,16 @@ AuthenticationController = require "../Authentication/AuthenticationController"
 
 module.exports = TrackChangesController =
 	proxyToTrackChangesApi: (req, res, next = (error) ->) ->
-		AuthenticationController.getLoggedInUserId req, (error, user_id) ->
-			return next(error) if error?
-			url = settings.apis.trackchanges.url + req.url
-			logger.log url: url, "proxying to track-changes api"
-			getReq = request(
-				url: url
-				method: req.method
-				headers:
-					"X-User-Id": user_id
-			)
-			getReq.pipe(res)
-			getReq.on "error", (error) ->
-				logger.error err: error, "track-changes API error"
-				next(error)
+		user_id = AuthenticationController.getLoggedInUserId req
+		url = settings.apis.trackchanges.url + req.url
+		logger.log url: url, "proxying to track-changes api"
+		getReq = request(
+			url: url
+			method: req.method
+			headers:
+				"X-User-Id": user_id
+		)
+		getReq.pipe(res)
+		getReq.on "error", (error) ->
+			logger.error err: error, "track-changes API error"
+			next(error)

+ 6 - 8
services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee

@@ -4,11 +4,12 @@ fs      = require "fs"
 Path    = require "path"
 FileSystemImportManager = require "./FileSystemImportManager"
 ProjectUploadManager    = require "./ProjectUploadManager"
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 module.exports = ProjectUploadController =
 	uploadProject: (req, res, next) ->
 		timer = new metrics.Timer("project-upload")
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		{originalname, path} = req.files.qqfile
 		name = Path.basename(originalname, ".zip")
 		ProjectUploadManager.createProjectFromZipArchive user_id, name, path, (error, project) ->
@@ -24,18 +25,18 @@ module.exports = ProjectUploadController =
 					project: project._id, file_path: path, file_name: name,
 					"uploaded project"
 				res.send success: true, project_id: project._id
-	
+
 	uploadFile: (req, res, next) ->
 		timer = new metrics.Timer("file-upload")
-		name = req.files.qqfile.originalname
-		path = req.files.qqfile.path
+		name = req.files.qqfile?.originalname
+		path = req.files.qqfile?.path
 		project_id   = req.params.Project_id
 		folder_id    = req.query.folder_id
 		if !name? or name.length == 0 or name.length > 150
 			logger.err project_id:project_id, name:name, "bad name when trying to upload file"
 			return res.send success: false
 		logger.log folder_id:folder_id, project_id:project_id, "getting upload file request"
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		FileSystemImportManager.addEntity user_id, project_id, folder_id, name, path, true, (error, entity) ->
 			fs.unlink path, ->
 			timer.done()
@@ -50,6 +51,3 @@ module.exports = ProjectUploadController =
 					project_id: project_id, file_path: path, file_name: name, folder_id: folder_id
 					"uploaded file"
 				res.send success: true, entity_id: entity?._id
-
-
-

+ 24 - 9
services/web/app/coffee/Features/User/UserController.coffee

@@ -8,6 +8,7 @@ logger = require("logger-sharelatex")
 metrics = require("../../infrastructure/Metrics")
 Url = require("url")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
+AuthenticationController = require('../Authentication/AuthenticationController')
 UserSessionsManager = require("./UserSessionsManager")
 UserUpdater = require("./UserUpdater")
 settings = require "settings-sharelatex"
@@ -15,20 +16,21 @@ settings = require "settings-sharelatex"
 module.exports = UserController =
 
 	deleteUser: (req, res)->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		UserDeleter.deleteUser user_id, (err)->
 			if !err?
 				req.session?.destroy()
 			res.sendStatus(200)
 
 	unsubscribe: (req, res)->
-		UserLocator.findById req.session.user._id, (err, user)->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		UserLocator.findById user_id, (err, user)->
 			newsLetterManager.unsubscribe user, ->
 				res.send()
 
 	updateUserSettings : (req, res)->
-		logger.log user: req.session.user, "updating account settings"
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		logger.log user_id: user_id, "updating account settings"
 		User.findById user_id, (err, user)->
 			if err? or !user?
 				logger.err err:err, user_id:user_id, "problem updaing user settings"
@@ -54,9 +56,12 @@ module.exports = UserController =
 				user.ace.spellCheckLanguage = req.body.spellCheckLanguage
 			if req.body.pdfViewer?
 				user.ace.pdfViewer = req.body.pdfViewer
+			if req.body.syntaxValidation?
+				user.ace.syntaxValidation = req.body.syntaxValidation
 			user.save (err)->
 				newEmail = req.body.email?.trim().toLowerCase()
 				if !newEmail? or newEmail == user.email
+					AuthenticationController.setInSessionUser(req, {first_name: user.first_name, last_name: user.last_name})
 					return res.sendStatus 200
 				else if newEmail.indexOf("@") == -1
 					return res.sendStatus(400)
@@ -73,7 +78,7 @@ module.exports = UserController =
 							if err?
 								logger.err err:err, user_id:user_id, "error getting user for email update"
 								return res.send 500
-							req.session.user.email = user.email
+							AuthenticationController.setInSessionUser(req, {email: user.email, first_name: user.first_name, last_name: user.last_name})
 							UserHandler.populateGroupLicenceInvite user, (err)-> #need to refresh this in the background
 								if err?
 									logger.err err:err, "error populateGroupLicenceInvite"
@@ -81,9 +86,10 @@ module.exports = UserController =
 
 	logout : (req, res)->
 		metrics.inc "user.logout"
-		logger.log user: req?.session?.user, "logging out"
+		user = AuthenticationController.getSessionUser(req)
+		logger.log user: user, "logging out"
 		sessionId = req.sessionID
-		user = req?.session?.user
+		req.logout?()  # passport logout
 		req.session.destroy (err)->
 			if err
 				logger.err err: err, 'error destorying session'
@@ -102,13 +108,22 @@ module.exports = UserController =
 				setNewPasswordUrl: setNewPasswordUrl
 			}
 
+	clearSessions: (req, res, next = (error) ->) ->
+		metrics.inc "user.clear-sessions"
+		user = AuthenticationController.getSessionUser(req)
+		logger.log {user_id: user._id}, "clearing sessions for user"
+		UserSessionsManager.revokeAllUserSessions user, [req.sessionID], (err) ->
+			return next(err) if err?
+			res.sendStatus 201
+
 	changePassword : (req, res, next = (error) ->)->
 		metrics.inc "user.password-change"
 		oldPass = req.body.currentPassword
-		AuthenticationManager.authenticate {_id:req.session.user._id}, oldPass, (err, user)->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		AuthenticationManager.authenticate {_id:user_id}, oldPass, (err, user)->
 			return next(err) if err?
 			if(user)
-				logger.log user: req.session.user, "changing password"
+				logger.log user: user._id, "changing password"
 				newPassword1 = req.body.newPassword1
 				newPassword2 = req.body.newPassword2
 				if newPassword1 != newPassword2

+ 5 - 6
services/web/app/coffee/Features/User/UserInfoController.coffee

@@ -3,12 +3,14 @@ logger = require("logger-sharelatex")
 UserDeleter = require("./UserDeleter")
 UserUpdater = require("./UserUpdater")
 sanitize = require('sanitizer')
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 module.exports = UserController =
 	getLoggedInUsersPersonalInfo: (req, res, next = (error) ->) ->
-		logger.log user: req.user, "reciving request for getting logged in users personal info"
-		return next(new Error("User is not logged in")) if !req.user?
-		UserGetter.getUser req.user._id, {
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		logger.log user_id: user_id, "reciving request for getting logged in users personal info"
+		return next(new Error("User is not logged in")) if !user_id?
+		UserGetter.getUser user_id, {
 			first_name: true, last_name: true,
 			role:true, institution:true,
 			email: true, signUpDate: true
@@ -38,6 +40,3 @@ module.exports = UserController =
 			role: user.role
 			institution: user.institution
 		}
-		
-
-	

+ 18 - 4
services/web/app/coffee/Features/User/UserPagesController.coffee

@@ -1,9 +1,11 @@
 UserLocator = require("./UserLocator")
 UserGetter = require("./UserGetter")
+UserSessionsManager = require("./UserSessionsManager")
 ErrorController = require("../Errors/ErrorController")
 logger = require("logger-sharelatex")
 Settings = require("settings-sharelatex")
 fs = require('fs')
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 module.exports =
 
@@ -22,14 +24,14 @@ module.exports =
 			sharedProjectData: sharedProjectData
 			newTemplateData: newTemplateData
 			new_email:req.query.new_email || ""
-	
+
 	activateAccountPage: (req, res) ->
 		# An 'activation' is actually just a password reset on an account that
 		# 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?
 			return ErrorController.notFound(req, res)
-			
+
 		UserGetter.getUser req.query.user_id, {email: 1, loginCount: 1}, (error, user) ->
 			return next(error) if error?
 			if !user
@@ -53,11 +55,23 @@ module.exports =
 			email: req.query.email
 
 	settingsPage : (req, res, next)->
-		logger.log user: req.session.user, "loading settings page"
-		UserLocator.findById req.session.user._id, (err, user)->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		logger.log user: user_id, "loading settings page"
+		UserLocator.findById user_id, (err, user)->
 			return next(err) if err?
 			res.render 'user/settings',
 				title:'account_settings'
 				user: user,
 				languages: Settings.languages,
 				accountSettingsTabActive: true
+
+	sessionsPage: (req, res, next) ->
+		user = AuthenticationController.getSessionUser(req)
+		logger.log user_id: user._id, "loading sessions page"
+		UserSessionsManager.getAllUserSessions user, [req.sessionID], (err, sessions) ->
+			if err?
+				logger.err {user_id: user._id}, "error getting all user sessions"
+				return next(err)
+			res.render 'user/sessions',
+				title: "sessions"
+				sessions: sessions

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

@@ -8,6 +8,7 @@ logger = require("logger-sharelatex")
 crypto = require("crypto")
 EmailHandler = require("../Email/EmailHandler")
 OneTimeTokenHandler = require "../Security/OneTimeTokenHandler"
+Analytics = require "../Analytics/AnalyticsManager"
 settings = require "settings-sharelatex"
 
 module.exports = UserRegistrationHandler =
@@ -62,6 +63,7 @@ module.exports = UserRegistrationHandler =
 						cb() #this can be slow, just fire it off
 				], (err)->
 					logger.log user: user, "registered"
+					Analytics.recordEvent user._id, "user-registered"
 					callback(err, user)
 	
 	registerNewUserAndSendActivationEmail: (email, callback = (error, user, setNewPasswordUrl) ->) ->

+ 31 - 2
services/web/app/coffee/Features/User/UserSessionsManager.coffee

@@ -55,11 +55,40 @@ module.exports = UserSessionsManager =
 				UserSessionsManager._checkSessions(user, () ->)
 				callback()
 
+	getAllUserSessions: (user, exclude, callback=(err, sessionKeys)->) ->
+		exclude = _.map(exclude, UserSessionsManager._sessionKey)
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		rclient.smembers sessionSetKey, (err, sessionKeys) ->
+			if err?
+				logger.err user_id: user._id, "error getting all session keys for user from redis"
+				return callback(err)
+			sessionKeys = _.filter sessionKeys, (k) -> !(_.contains(exclude, k))
+			if sessionKeys.length == 0
+				logger.log {user_id: user._id}, "no other sessions found, returning"
+				return callback(null, [])
+			rclient.mget sessionKeys, (err, sessions) ->
+				if err?
+					logger.err {user_id: user._id}, "error getting all sessions for user from redis"
+					return callback(err)
+
+				result = []
+				for session in sessions
+					if session is null
+						continue
+					session = JSON.parse(session)
+					session_user = session?.user or session?.passport?.user
+					result.push {
+						ip_address: session_user.ip_address,
+						session_created: session_user.session_created
+					}
+
+				return callback(null, result)
+
 	revokeAllUserSessions: (user, retain, callback=(err)->) ->
-		if !retain
+		if !retain?
 			retain = []
 		retain = retain.map((i) -> UserSessionsManager._sessionKey(i))
-		if !user
+		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"

+ 70 - 44
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -5,42 +5,54 @@ Settings = require('settings-sharelatex')
 SubscriptionFormatters = require('../Features/Subscription/SubscriptionFormatters')
 querystring = require('querystring')
 SystemMessageManager = require("../Features/SystemMessages/SystemMessageManager")
+AuthenticationController = require("../Features/Authentication/AuthenticationController")
 _ = require("underscore")
+async = require("async")
 Modules = require "./Modules"
 Url = require "url"
-
+PackageVersions = require "./PackageVersions"
 fingerprints = {}
 Path = require 'path'
 
-
 jsPath =
 	if Settings.useMinifiedJs
 		"/minjs/"
 	else
 		"/js/"
 
+ace = PackageVersions.lib('ace')
+pdfjs = PackageVersions.lib('pdfjs')
 
-logger.log "Generating file fingerprints..."
-for path in [
-	"#{jsPath}libs/require.js",
-	"#{jsPath}ide.js",
-	"#{jsPath}main.js",
-	"#{jsPath}libs.js",
-	"#{jsPath}ace/ace.js",
-	"#{jsPath}libs/pdfjs-1.3.91/pdf.js",
-	"#{jsPath}libs/pdfjs-1.3.91/pdf.worker.js",
-	"#{jsPath}libs/pdfjs-1.3.91/compatibility.js",
-	"/stylesheets/style.css"
-]
-	filePath = Path.join __dirname, "../../../", "public#{path}"
+getFileContent = (filePath)->
+	filePath = Path.join __dirname, "../../../", "public#{filePath}"
 	exists = fs.existsSync filePath
 	if exists
 		content = fs.readFileSync filePath
-		hash = crypto.createHash("md5").update(content).digest("hex")
-		logger.log "#{filePath}: #{hash}"
-		fingerprints[path] = hash
+		return content
 	else
 		logger.log filePath:filePath, "file does not exist for fingerprints"
+		return ""
+
+logger.log "Generating file fingerprints..."
+pathList = [
+	["#{jsPath}libs/require.js"]
+	["#{jsPath}ide.js"]
+	["#{jsPath}main.js"]
+	["#{jsPath}libs.js"]
+	["#{jsPath}#{ace}/ace.js","#{jsPath}#{ace}/mode-latex.js", "#{jsPath}#{ace}/snippets/latex.js"]
+	["#{jsPath}libs/#{pdfjs}/pdf.js"]
+	["#{jsPath}libs/#{pdfjs}/pdf.worker.js"]
+	["#{jsPath}libs/#{pdfjs}/compatibility.js"]
+	["/stylesheets/style.css"]
+]
+
+for paths in pathList
+	contentList = _.map(paths, getFileContent)
+	content = contentList.join("")
+	hash = crypto.createHash("md5").update(content).digest("hex")
+	_.each paths, (filePath)-> 
+		logger.log "#{filePath}: #{hash}"
+		fingerprints[filePath] = hash
 
 getFingerprint = (path) ->
 	if fingerprints[path]?
@@ -59,28 +71,35 @@ module.exports = (app, webRouter, apiRouter)->
 		res.locals.session = req.session
 		next()
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
+
+		cdnBlocked = req.query.nocdn == 'true' or req.session.cdnBlocked
+		user_id = AuthenticationController.getLoggedInUserId(req)
+
+		if cdnBlocked and !req.session.cdnBlocked?
+			logger.log user_id:user_id, ip:req?.ip, "cdnBlocked for user, not using it and turning it off for future requets"
+			req.session.cdnBlocked = true
 
 		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
+		if cdnAvailable and isLive and !cdnBlocked
 			staticFilesBase = Settings.cdn?.web?.host
 		else if darkCdnAvailable and isDark
 			staticFilesBase = Settings.cdn?.web?.darkHost
 		else
 			staticFilesBase = ""
-		
+
 		res.locals.jsPath = jsPath
 		res.locals.fullJsPath = Url.resolve(staticFilesBase, jsPath)
-
+		res.locals.lib = PackageVersions.lib
 
 		res.locals.buildJsPath = (jsFile, opts = {})->
 			path = Path.join(jsPath, jsFile)
 
 			doFingerPrint = opts.fingerprint != false
-			
+
 			if !opts.qs?
 				opts.qs = {}
 
@@ -89,14 +108,13 @@ module.exports = (app, webRouter, apiRouter)->
 
 			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)
@@ -109,7 +127,7 @@ module.exports = (app, webRouter, apiRouter)->
 
 
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
 		res.locals.settings = Settings
 		next()
 
@@ -117,7 +135,9 @@ module.exports = (app, webRouter, apiRouter)->
 		res.locals.translate = (key, vars = {}) ->
 			vars.appName = Settings.appName
 			req.i18n.translate(key, vars)
-		res.locals.currentUrl = req.originalUrl
+		# Don't include the query string parameters, otherwise Google
+		# treats ?nocdn=true as the canonical version
+		res.locals.currentUrl = Url.parse(req.originalUrl).pathname
 		next()
 
 	webRouter.use (req, res, next)->
@@ -125,9 +145,10 @@ module.exports = (app, webRouter, apiRouter)->
 			Settings.siteUrl.substring(Settings.siteUrl.indexOf("//")+2)
 		next()
 
-	webRouter.use (req, res, next)->
+	webRouter.use (req, res, next) ->
 		res.locals.getUserEmail = ->
-			email = req?.session?.user?.email or ""
+			user = AuthenticationController.getSessionUser(req)
+			email = user?.email or ""
 			return email
 		next()
 
@@ -137,15 +158,17 @@ module.exports = (app, webRouter, apiRouter)->
 			return formatedPrivileges[privilegeLevel] || "Private"
 		next()
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
 		res.locals.buildReferalUrl = (referal_medium) ->
 			url = Settings.siteUrl
-			if req.session? and req.session.user? and req.session.user.referal_id?
-				url+="?r=#{req.session.user.referal_id}&rm=#{referal_medium}&rs=b" # Referal source = bonus
+			currentUser = AuthenticationController.getSessionUser(req)
+			if currentUser? and currentUser?.referal_id?
+				url+="?r=#{currentUser.referal_id}&rm=#{referal_medium}&rs=b" # Referal source = bonus
 			return url
 		res.locals.getReferalId = ->
-			if req.session? and req.session.user? and req.session.user.referal_id
-				return req.session.user.referal_id
+			currentUser = AuthenticationController.getSessionUser(req)
+			if currentUser? and currentUser?.referal_id?
+				return currentUser.referal_id
 		res.locals.getReferalTagLine = ->
 			tagLines = [
 				"Roar!"
@@ -161,7 +184,11 @@ module.exports = (app, webRouter, apiRouter)->
 			return ""
 
 		res.locals.getLoggedInUserId = ->
-			return req.session.user?._id
+			return AuthenticationController.getLoggedInUserId(req)
+		res.locals.isUserLoggedIn = ->
+			return AuthenticationController.isUserLoggedIn(req)
+		res.locals.getSessionUser = ->
+			return AuthenticationController.getSessionUser(req)
 		next()
 
 	webRouter.use (req, res, next) ->
@@ -173,11 +200,11 @@ module.exports = (app, webRouter, apiRouter)->
 			return req.query?[field]
 		next()
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
 		res.locals.fingerprint = getFingerprint
 		next()
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
 		res.locals.formatPrice = SubscriptionFormatters.formatPrice
 		next()
 
@@ -187,11 +214,12 @@ module.exports = (app, webRouter, apiRouter)->
 		next()
 
 	webRouter.use (req, res, next)->
-		if req.session.user?
+		currentUser = AuthenticationController.getSessionUser(req)
+		if currentUser?
 			res.locals.user =
-				email: req.session.user.email
-				first_name: req.session.user.first_name
-				last_name: req.session.user.last_name
+				email: currentUser.email
+				first_name: currentUser.first_name
+				last_name: currentUser.last_name
 			if req.session.justRegistered
 				res.locals.justRegistered = true
 				delete req.session.justRegistered
@@ -217,7 +245,7 @@ module.exports = (app, webRouter, apiRouter)->
 			res.locals.nav[key] = _.clone(Settings.nav[key])
 		res.locals.templates = Settings.templateLinks
 		next()
-		
+
 	webRouter.use (req, res, next) ->
 		SystemMessageManager.getMessages (error, messages = []) ->
 			res.locals.systemMessages = messages
@@ -240,5 +268,3 @@ module.exports = (app, webRouter, apiRouter)->
 		res.locals.moduleIncludes = Modules.moduleIncludes
 		res.locals.moduleIncludesAvailable = Modules.moduleIncludesAvailable
 		next()
-
-

+ 2 - 1
services/web/app/coffee/infrastructure/GeoIpLookup.coffee

@@ -2,6 +2,7 @@ request = require("request")
 settings = require("settings-sharelatex")
 _ = require("underscore")
 logger = require("logger-sharelatex")
+URL = require("url")
 
 currencyMappings = {
 	"GB":"GBP"
@@ -31,7 +32,7 @@ module.exports = GeoIpLookup =
 			return callback(e)
 		ip = ip.trim().split(" ")[0]
 		opts = 
-			url: "#{settings.apis.geoIpLookup.url}/#{ip}"
+			url: URL.resolve(settings.apis.geoIpLookup.url,ip)
 			timeout: 1000
 			json:true
 		logger.log ip:ip, opts:opts, "getting geo ip details"

+ 5 - 5
services/web/app/coffee/infrastructure/Modules.coffee

@@ -25,14 +25,14 @@ module.exports = Modules =
 		for module in @modules
 			for view, partial of module.viewIncludes or {}
 				@viewIncludes[view] ||= []
-				@viewIncludes[view].push fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".jade"))
+				@viewIncludes[view].push jade.compile(fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".jade")), doctype: "html")
 			
 	moduleIncludes: (view, locals) ->
-		partials = Modules.viewIncludes[view] or []
+		compiledPartials = Modules.viewIncludes[view] or []
 		html = ""
-		for partial in partials
-			compiler = jade.compile(partial, doctype: "html")
-			html += compiler(locals)
+		for compiledPartial in compiledPartials
+			d = new Date()
+			html += compiledPartial(locals)
 		return html
 
 	moduleIncludesAvailable: (view) ->

+ 15 - 0
services/web/app/coffee/infrastructure/PackageVersions.coffee

@@ -0,0 +1,15 @@
+version = {
+	"pdfjs": "1.6.210p1"
+	"moment": "2.9.0"
+	"ace": "1.2.5"
+}
+
+module.exports = {
+	version: version
+
+	lib: (name) ->
+		if version[name]?
+			return "#{name}-#{version[name]}"
+		else
+			return "#{name}"
+}

+ 21 - 2
services/web/app/coffee/infrastructure/Server.coffee

@@ -21,6 +21,9 @@ cookieParser = require('cookie-parser')
 
 sessionStore = new RedisStore(client:rclient)
 
+passport = require('passport')
+LocalStrategy = require('passport-local').Strategy
+
 Mongoose = require("./Mongoose")
 
 oneDayInMilliseconds = 86400000
@@ -32,6 +35,7 @@ Modules = require "./Modules"
 
 ErrorController = require "../Features/Errors/ErrorController"
 UserSessionsManager = require "../Features/User/UserSessionsManager"
+AuthenticationController = require "../Features/Authentication/AuthenticationController"
 
 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)
@@ -87,11 +91,26 @@ webRouter.use csrfProtection
 webRouter.use translations.expressMiddlewear
 webRouter.use translations.setLangBasedOnDomainMiddlewear
 
+# passport
+webRouter.use passport.initialize()
+webRouter.use passport.session()
+
+passport.use(new LocalStrategy(
+	{
+		passReqToCallback: true,
+		usernameField: 'email',
+		passwordField: 'password'
+	},
+	AuthenticationController.doPassportLogin
+))
+passport.serializeUser(AuthenticationController.serializeUser)
+passport.deserializeUser(AuthenticationController.deserializeUser)
+
 # Measure expiry from last request, not last login
 webRouter.use (req, res, next) ->
 	req.session.touch()
-	if req?.session?.user?
-		UserSessionsManager.touch(req.session.user, (err)->)
+	if AuthenticationController.isUserLoggedIn(req)
+		UserSessionsManager.touch(AuthenticationController.getSessionUser(req), (err)->)
 	next()
 
 webRouter.use ReferalConnect.use

+ 29 - 10
services/web/app/coffee/models/ProjectInvite.coffee

@@ -1,23 +1,42 @@
 mongoose = require 'mongoose'
 Settings = require 'settings-sharelatex'
 
+
 Schema = mongoose.Schema
 ObjectId = Schema.ObjectId
 
-ProjectInviteSchema = new Schema
-	project_id:     ObjectId
-	from_user_id:   ObjectId
-	privilegeLevel: String
-	# For existing users
-	to_user_id:     ObjectId
-	# For non-existant users
-	hashed_token:   String
-	email:          String
+
+EXPIRY_IN_SECONDS = 60 * 60 * 24 * 30
+
+ExpiryDate = () ->
+	timestamp = new Date()
+	timestamp.setSeconds(timestamp.getSeconds() + EXPIRY_IN_SECONDS)
+	return timestamp
+
+
+
+ProjectInviteSchema = new Schema(
+	{
+		email:          String
+		token:          String
+		sendingUserId:  ObjectId
+		projectId:      ObjectId
+		privileges:     String
+		createdAt:      {type: Date, default: Date.now}
+		expires:        {type: Date, default: ExpiryDate, index: {expireAfterSeconds: 10}}
+	},
+	{
+		collection: 'projectInvites'
+	}
+)
+
 
 conn = mongoose.createConnection(Settings.mongo.url, server: poolSize: Settings.mongo.poolSize || 10)
 
+
 ProjectInvite = conn.model('ProjectInvite', ProjectInviteSchema)
 
 mongoose.model 'ProjectInvite', ProjectInviteSchema
 exports.ProjectInvite = ProjectInvite
-exports.ProjectInviteSchema = ProjectInviteSchema
+exports.ProjectInviteSchema = ProjectInviteSchema
+exports.EXPIRY_IN_SECONDS = EXPIRY_IN_SECONDS

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

@@ -26,6 +26,7 @@ UserSchema = new Schema
 							autoComplete:   {type : Boolean, default: true}
 							spellCheckLanguage :   {type : String, default: "en"}
 							pdfViewer   :   {type : String, default: "pdfjs"}
+							syntaxValidation   :   {type : Boolean, default: true}
 						}
 	features		  : {
 							collaborators: { type:Number,  default: Settings.defaultFeatures.collaborators }

+ 29 - 10
services/web/app/coffee/router.coffee

@@ -11,7 +11,6 @@ SubscriptionRouter = require './Features/Subscription/SubscriptionRouter'
 UploadsRouter = require './Features/Uploads/UploadsRouter'
 metrics = require('./infrastructure/Metrics')
 ReferalController = require('./Features/Referal/ReferalController')
-ReferalMiddleware = require('./Features/Referal/ReferalMiddleware')
 AuthenticationController = require('./Features/Authentication/AuthenticationController')
 TagsController = require("./Features/Tags/TagsController")
 NotificationsController = require("./Features/Notifications/NotificationsController")
@@ -22,6 +21,7 @@ UserPagesController = require('./Features/User/UserPagesController')
 DocumentController = require('./Features/Documents/DocumentController')
 CompileManager = require("./Features/Compile/CompileManager")
 CompileController = require("./Features/Compile/CompileController")
+ClsiCookieManager = require("./Features/Compile/ClsiCookieManager")
 HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 FileStoreController = require("./Features/FileStore/FileStoreController")
@@ -38,6 +38,7 @@ ContactRouter = require("./Features/Contacts/ContactRouter")
 ReferencesController = require('./Features/References/ReferencesController')
 AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear')
 BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
+AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
 
 logger = require("logger-sharelatex")
 _ = require("underscore")
@@ -51,7 +52,8 @@ module.exports = class Router
 		webRouter.get  '/login', UserPagesController.loginPage
 		AuthenticationController.addEndpointToLoginWhitelist '/login'
 
-		webRouter.post '/login', AuthenticationController.login
+		webRouter.post '/login', AuthenticationController.passportLogin
+
 		webRouter.get  '/logout', UserController.logout
 		webRouter.get  '/restricted', AuthorizationMiddlewear.restricted
 
@@ -68,12 +70,13 @@ module.exports = class Router
 		StaticPagesRouter.apply(webRouter, apiRouter)
 		RealTimeProxyRouter.apply(webRouter, apiRouter)
 		ContactRouter.apply(webRouter, apiRouter)
+		AnalyticsRouter.apply(webRouter, apiRouter)
 
 		Modules.applyRouter(webRouter, apiRouter)
 
 
 		if Settings.enableSubscriptions
-			webRouter.get  '/user/bonus', AuthenticationController.requireLogin(), ReferalMiddleware.getUserReferalId, ReferalController.bonus
+			webRouter.get  '/user/bonus', AuthenticationController.requireLogin(), ReferalController.bonus
 
 		webRouter.get '/blog', BlogController.getIndexPage
 		webRouter.get '/blog/*', BlogController.getPage
@@ -85,6 +88,9 @@ module.exports = class Router
 		webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
 		webRouter.post '/user/password/update', AuthenticationController.requireLogin(), UserController.changePassword
 
+		webRouter.get  '/user/sessions', AuthenticationController.requireLogin(), UserPagesController.sessionsPage
+		webRouter.post '/user/sessions/clear', AuthenticationController.requireLogin(), UserController.clearSessions
+
 		webRouter.delete '/user/newsletter/unsubscribe', AuthenticationController.requireLogin(), UserController.unsubscribe
 		webRouter.delete '/user', AuthenticationController.requireLogin(), UserController.deleteUser
 
@@ -179,7 +185,7 @@ module.exports = class Router
 		webRouter.delete '/tag/:tag_id/project/:project_id', AuthenticationController.requireLogin(), TagsController.removeProjectFromTag
 
 		webRouter.get '/notifications', AuthenticationController.requireLogin(), NotificationsController.getAllUnreadNotifications
-		webRouter.delete '/notifications/:notification_id', AuthenticationController.requireLogin(), NotificationsController.markNotificationAsRead	
+		webRouter.delete '/notifications/:notification_id', AuthenticationController.requireLogin(), NotificationsController.markNotificationAsRead
 
 		# Deprecated in favour of /internal/project/:project_id but still used by versioning
 		apiRouter.get  '/project/:project_id/details', AuthenticationController.httpAuth, ProjectApiController.getProjectDetails
@@ -249,14 +255,27 @@ module.exports = class Router
 		apiRouter.get '/health_check/redis', HealthCheckController.checkRedis
 
 		apiRouter.get "/status/compiler/:Project_id", AuthorizationMiddlewear.ensureUserCanReadProject, (req, res) ->
+			project_id = req.params.Project_id
 			sendRes = _.once (statusCode, message)->
-				res.writeHead statusCode
-				res.end message
-			CompileManager.compile req.params.Project_id, "test-compile", {}, () ->
-				sendRes 200, "Compiler returned in less than 10 seconds"
-			setTimeout (() ->
+				res.status statusCode
+				res.send message
+				ClsiCookieManager.clearServerId project_id # force every compile to a new server
+			# set a timeout
+			handler = setTimeout (() ->
 				sendRes 500, "Compiler timed out"
+				handler = null
 			), 10000
+			# use a valid user id for testing
+			test_user_id = "123456789012345678901234"
+			# run the compile
+			CompileManager.compile project_id, test_user_id, {}, (error, status) ->
+				clearTimeout handler if handler?
+				if error?
+					sendRes 500, "Compiler returned error #{error.message}"
+				else if status is "success"
+					sendRes 200, "Compiler returned in less than 10 seconds"
+				else
+					sendRes 500, "Compiler returned failure #{status}"
 
 		apiRouter.get "/ip", (req, res, next) ->
 			res.send({
@@ -280,4 +299,4 @@ module.exports = class Router
 			metrics.inc("client-side-error")
 			res.sendStatus(204)
 
-		webRouter.get '*', ErrorController.notFound
+		webRouter.get '*', ErrorController.notFound

+ 1 - 1
services/web/app/views/beta_program/opt_in.jade

@@ -22,7 +22,7 @@ block content
 											ul.list-unstyled.text-center
 												li 
 													i.fa.fa-fw.fa-book
-													| &nbsp;#{translate("mendeley_integration")}
+													| &nbsp;#{translate("syntax_checking")}
 								.row.text-centered
 									.col-md-12
 										if user.betaProgram

+ 20 - 77
services/web/app/views/layout.jade

@@ -8,6 +8,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 			window.similarproducts = true
 		style [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak  {display: none !important; display: none; }
 
+
 		-if (typeof(gaExperiments) != "undefined")
 			|!{gaExperiments}
 		
@@ -46,83 +47,22 @@ html(itemscope, itemtype='http://schema.org/Product')
 		- else
 			script(type='text/javascript').
 				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").
 			window.csrfToken = "#{csrfToken}";
 			
 		block scripts
+
 		script(src=buildJsPath("libs/jquery-1.11.1.min.js", {fingerprint:false}))
+		script(type="text/javascript").
+			var noCdnKey = "nocdn=true"
+			var cdnBlocked = typeof jQuery === 'undefined'
+			var noCdnAlreadyInUrl = window.location.href.indexOf(noCdnKey) != -1 //prevent loops
+			if (cdnBlocked && !noCdnAlreadyInUrl && navigator.userAgent.indexOf("Googlebot") == -1) {
+				window.location.search += '&'+noCdnKey;
+			}
 		script(src=buildJsPath("libs/angular-1.3.15.min.js", {fingerprint:false}))
+
 		script.
 			window.sharelatex = {
 				siteUrl: '#{settings.siteUrl}',
@@ -171,23 +111,27 @@ html(itemscope, itemtype='http://schema.org/Product')
 			include layout/navbar
 
 		block content
+
 		div(ng-controller="AbTestController")
 		- if(typeof(suppressFooter) == "undefined")
 			include layout/footer
 
-
-
 		- if (typeof(lookingForScribtex) != "undefined" && lookingForScribtex)
 			span(ng-controller="ScribtexPopupController")
 			include scribtex-modal
 
-
-		- if(typeof(suppressFooter) == "undefined")
+		block requirejs
 			script(type='text/javascript').
+				// minimal requirejs configuration (can be extended/overridden)
 				window.requirejs = {
-					"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}",
 					"paths" : {
-						"moment": "libs/moment-2.7.0"
+						"moment": "libs/#{lib('moment')}"
+					},
+					"urlArgs": "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}",
+					"config":{
+						"moment":{
+							"noGlobal": true
+						}
 					}
 				};
 			script(
@@ -196,7 +140,6 @@ html(itemscope, itemtype='http://schema.org/Product')
 				src=buildJsPath('libs/require.js')
 			)
 
-
 		include contact-us-modal
 		include sentry
 		

+ 2 - 2
services/web/app/views/layout/navbar.jade

@@ -13,7 +13,7 @@ nav.navbar.navbar-default
 		.navbar-collapse.collapse(collapse="navCollapsed")
 
 			ul.nav.navbar-nav.navbar-right
-				if (session && session.user && session.user.isAdmin)
+				if (getSessionUser() && getSessionUser().isAdmin)
 					li.dropdown(class="subdued", dropdown)
 						a.dropdown-toggle(href, dropdown-toggle)
 							| Admin
@@ -25,7 +25,7 @@ nav.navbar.navbar-default
 								a(href="/admin/user") Manage Users
 
 				each item in nav.header
-					if ((item.only_when_logged_in && session && session.user) || (item.only_when_logged_out && (!session || !session.user)) || (!item.only_when_logged_out && !item.only_when_logged_in))
+					if ((item.only_when_logged_in && getSessionUser()) || (item.only_when_logged_out && (!getSessionUser())) || (!item.only_when_logged_out && !item.only_when_logged_in))
 						if item.dropdown
 							li.dropdown(class=item.class, dropdown)
 								a.dropdown-toggle(href, dropdown-toggle)

+ 23 - 24
services/web/app/views/project/editor.jade

@@ -3,7 +3,6 @@ extends ../layout
 block vars
 	- var suppressNavbar = true
 	- var suppressFooter = true
-	- var suppressDefaultJs = true
 	- var suppressSystemMessages = true
 
 block content
@@ -66,7 +65,7 @@ block content
 					.ui-layout-center
 						include ./editor/editor
 						include ./editor/binary-file
-						include ./editor/track-changes
+						include ./editor/history
 						include ./editor/publish-template
 
 			.ui-layout-east
@@ -86,7 +85,11 @@ block content
 		.modal-footer
 			button.btn.btn-info(ng-click="done()") #{translate("ok")}
 
-	script(src='/socket.io/socket.io.js')
+block requirejs
+	script(type="text/javascript" src='/socket.io/socket.io.js')
+
+	//- don't use cdn for worker
+	- var pdfWorkerPath = buildJsPath('/libs/' + lib('pdfjs') + '/pdf.worker', {cdn:false,fingerprint:false})
 
 	//- We need to do .replace(/\//g, '\\/') do that '</script>' -> '<\/script>'
 	//- and doesn't prematurely end the script tag.
@@ -100,47 +103,43 @@ block content
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
 		window.requirejs = {
 			"paths" : {
-				"mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {qs:{config:'TeX-AMS_HTML', fingerprint:false}})}",
-				"moment": "libs/moment-2.7.0",
-				"libs/pdf": "libs/pdfjs-1.3.91/pdf"
+				"mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {cdn:false, fingerprint:false, qs:{config:'TeX-AMS_HTML'}})}",
+				"moment": "libs/#{lib('moment')}",
+				"pdfjs-dist/build/pdf": "libs/#{lib('pdfjs')}/pdf",
+				"pdfjs-dist/build/pdf.worker": "#{pdfWorkerPath}",
+				"ace": "#{lib('ace')}"
 			},
 			"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}-#{fingerprint(jsPath + 'libs.js')}",
 			"waitSeconds": 0,
 			"shim": {
-				"libs/pdf": {
-					deps: ["libs/pdfjs-1.3.91/compatibility"]
+				"pdfjs-dist/build/pdf": {
+					"deps": ["libs/#{lib('pdfjs')}/compatibility"]
 				},
 				"ace/ext-searchbox": {
-					deps: ["ace/ace"]
+					"deps": ["ace/ace"]
 				},
 				"ace/ext-language_tools": {
-					deps: ["ace/ace"]
+					"deps": ["ace/ace"]
 				}
 			},
-			config:{
-				moment:{
-					noGlobal: true
+			"config":{
+				"moment":{
+					"noGlobal": true
 				}
 			}
 		};
-		window.aceFingerprint = "#{fingerprint(jsPath + 'ace/ace.js')}"
-
-	- locals.suppressDefaultJs = true
-
-	- var pdfPath = 'libs/pdfjs-1.3.91/pdf.worker.js'
-	- var fingerprintedPath = fingerprint(jsPath+pdfPath)
-	- var pdfJsWorkerPath = buildJsPath(pdfPath, {cdn:false,qs:{fingerprint:fingerprintedPath}}) // don't use worker for cdn
+		window.aceFingerprint = "#{fingerprint(jsPath + lib('ace') + '/ace.js')}"
 
+	- var aceWorkerPath = user.betaProgram ? buildJsPath(lib('ace'), {cdn:false,fingerprint:false}) : "" // don't use cdn for worker
 
 	script(type='text/javascript').
-		window.pdfJsWorkerPath = "#{pdfJsWorkerPath}";
+		window.aceWorkerPath = "#{aceWorkerPath}";
 
 	script(
 		data-main=buildJsPath("ide.js", {fingerprint:false}),
 		baseurl=fullJsPath,
-		data-ace-base=buildJsPath('ace', {fingerprint:false}),
+		data-ace-base=buildJsPath(lib('ace'), {fingerprint:false}),
 		src=buildJsPath('libs/require.js')
 	)
 
-		
-
+	

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

@@ -41,11 +41,12 @@ aside.chat(
 							}"
 						)
 							.arrow(ng-style="{'border-color': 'hsl({{ hue(message.user) }}, 70%, 70%)'}")
-							p(
-								mathjax,
-								ng-repeat="content in message.contents track by $index"
-							)
-								span(ng-bind-html="content | linky:'_blank'")
+							.message-content
+								p(
+									mathjax,
+									ng-repeat="content in message.contents track by $index"
+								)
+									span(ng-bind-html="content | linky:'_blank'")
 
 	.new-message
 		textarea(

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

@@ -36,6 +36,7 @@ div.full-size(
 			annotations="pdf.logEntryAnnotations[editor.open_doc_id]",
 			read-only="!permissions.write",
 			on-ctrl-enter="recompileViaKey"
+			syntax-validation="settings.syntaxValidation"
 		)
 
 	.ui-layout-east

+ 22 - 4
services/web/app/views/project/editor/file-tree.jade

@@ -70,13 +70,13 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected'
 				ng-repeat="entity in rootFolder.children | orderBy:[orderByFoldersFirst, 'name']"
 			)
 
-			li(ng-show="deletedDocs.length > 0 && ui.view == 'track-changes'")
+			li(ng-show="deletedDocs.length > 0 && ui.view == 'history'")
 				h3 #{translate("deleted_files")}
 			li(
 				ng-class="{ 'selected': entity.selected }",
 				ng-repeat="entity in deletedDocs | orderBy:'name'",
 				ng-controller="FileTreeEntityController",
-				ng-show="ui.view == 'track-changes'"
+				ng-show="ui.view == 'history'"
 			)
 				.entity
 					.entity-name(
@@ -315,8 +315,12 @@ script(type='text/ng-template', id='newDocModalTemplate')
 				required,
 				ng-model="inputs.name",
 				on-enter="create()",
-				select-name-on="open"
+				select-name-on="open",
+				ng-pattern="validFileRegex",
+				name="name"
 			)
+			.text-danger.row-spaced-small(ng-show="newDocForm.name.$error.pattern")
+				| #{translate('files_cannot_include_invalid_characters')}
 	.modal-footer
 		button.btn.btn-default(
 			ng-disabled="state.inflight"
@@ -341,8 +345,12 @@ script(type='text/ng-template', id='newFolderModalTemplate')
 				required,
 				ng-model="inputs.name",
 				on-enter="create()",
-				select-name-on="open"
+				select-name-on="open",
+				ng-pattern="validFileRegex",
+				name="name"
 			)
+			.text-danger.row-spaced-small(ng-show="newFolderForm.name.$error.pattern")
+				| #{translate('files_cannot_include_invalid_characters')}
 	.modal-footer
 		button.btn.btn-default(
 			ng-disabled="state.inflight"
@@ -414,3 +422,13 @@ script(type='text/ng-template', id='deleteEntityModalTemplate')
 		)
 			span(ng-hide="state.inflight") #{translate("delete")}
 			span(ng-show="state.inflight") #{translate("deleting")}...
+
+script(type='text/ng-template', id='invalidFileNameModalTemplate')
+	.modal-header
+		h3 #{translate('invalid_file_name')}
+	.modal-body
+		p #{translate('files_cannot_include_invalid_characters')}
+	.modal-footer
+		button.btn.btn-default(
+			ng-click="$close()"
+		) #{translate('ok')}

+ 369 - 109
services/web/app/views/project/editor/header.jade

@@ -1,120 +1,380 @@
-header.toolbar.toolbar-header(ng-cloak, ng-hide="state.loading")
-	a.btn.btn-full-height(
-		href,
-		ng-click="ui.leftMenuShown = true"
-		tooltip='#{translate("menu")}',
-		tooltip-placement="bottom",
-		tooltip-append-to-body="true"
+div(ng-if="!shouldABTestHeaderLabels")
+	header.toolbar.toolbar-header(
+		ng-cloak,
+		ng-hide="state.loading"
 	)
-		i.fa.fa-fw.fa-bars
-	a(
-		href="/project"
-		tooltip="#{translate('back_to_projects')}",
-		tooltip-placement="bottom",
-		tooltip-append-to-body="true"
-	)
-		i.fa.fa-fw.fa-level-up
-	span(ng-controller="PdfViewToggleController")
-		a(
+		a.btn.btn-full-height(
 			href,
-			ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
-			tooltip="PDF",
+			ng-click="ui.leftMenuShown = true"
+			tooltip='#{translate("menu")}',
 			tooltip-placement="bottom",
 			tooltip-append-to-body="true",
-			ng-click="togglePdfView()",
-			ng-class="{ 'active': ui.view == 'pdf' }"
-		)
-			i.fa.fa-file-pdf-o
-
-	.toolbar-center.project-name(ng-controller="ProjectNameController")
-		span.name(
-			ng-dblclick="!permissions.admin || startRenaming()",
-			ng-show="!state.renaming"
-		) {{ project.name }}
-
-		input.form-control(
-			type="text"
-			ng-model="inputs.name",
-			ng-show="state.renaming",
-			on-enter="finishRenaming()",
-			ng-blur="finishRenaming()",
-			select-name-when="state.renaming"
 		)
-
-		a.rename(
-			ng-if="permissions.admin",
-			href='#',
+			i.fa.fa-fw.fa-bars
+		a(
+			href="/project"
+			tooltip="#{translate('back_to_projects')}",
 			tooltip-placement="bottom",
-			tooltip="#{translate('rename')}",
-			tooltip-append-to-body="true",
-			ng-click="startRenaming()",
-			ng-show="!state.renaming"
+			tooltip-append-to-body="true"
 		)
-			i.fa.fa-pencil
+			i.fa.fa-fw.fa-level-up
+		
+		span(ng-controller="PdfViewToggleController")
+			a(
+				href,
+				ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
+				tooltip="PDF",
+				tooltip-placement="bottom",
+				tooltip-append-to-body="true",
+				ng-click="togglePdfView()",
+				ng-class="{ 'active': ui.view == 'pdf' }"
+			)
+				i.fa.fa-file-pdf-o
+
+		.toolbar-center.project-name(ng-controller="ProjectNameController")
+			span.name(
+				ng-dblclick="!permissions.admin || startRenaming()",
+				ng-show="!state.renaming"
+			) {{ project.name }}
+
+			input.form-control(
+				type="text"
+				ng-model="inputs.name",
+				ng-show="state.renaming",
+				on-enter="finishRenaming()",
+				ng-blur="finishRenaming()",
+				select-name-when="state.renaming"
+			)
+
+			a.rename(
+				ng-if="permissions.admin",
+				href='#',
+				tooltip-placement="bottom",
+				tooltip="#{translate('rename')}",
+				tooltip-append-to-body="true",
+				ng-click="startRenaming()",
+				ng-show="!state.renaming"
+			)
+				i.fa.fa-pencil
+
+		.toolbar-right
+			span.online-users(
+				ng-show="onlineUsersArray.length > 0"
+				ng-controller="OnlineUsersController"
+			)
+				span(ng-if="onlineUsersArray.length < 4")
+					span.online-user(
+						ng-repeat="user in onlineUsersArray",
+						ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }",
+						popover="{{ user.name }}"
+						popover-placement="bottom"
+						popover-append-to-body="true"
+						popover-trigger="mouseenter"
+						ng-click="gotoUser(user)"
+					) {{ user.name.slice(0,1) }}
+				
+				span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4")
+					span.online-user.online-user-multi(
+						dropdown-toggle,
+						tooltip="#{translate('connected_users')}",
+						tooltip-placement="left"
+					)
+						strong {{ onlineUsersArray.length }}
+						i.fa.fa-fw.fa-user
+					ul.dropdown-menu.pull-right
+						li.dropdown-header #{translate('connected_users')}
+						li(ng-repeat="user in onlineUsersArray")
+							a(href, ng-click="gotoUser(user)")
+								span.online-user(
+									ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }"
+								) {{ user.name.slice(0,1) }}
+								| {{ user.name }}
+
+		
+			a.btn.btn-full-height(
+				href,
+				ng-if="permissions.admin",
+				tooltip="#{translate('share')}",
+				tooltip-placement="bottom",
+				ng-click="openShareProjectModal()",
+				ng-controller="ShareController",
+			)
+				i.fa.fa-fw.fa-group
+			a.btn.btn-full-height(
+				href,
+				ng-click="toggleHistory()",
+				ng-class="{ active: (ui.view == 'history') }"
+				tooltip="#{translate('recent_changes')}",
+				tooltip-placement="bottom",
+			)
+				i.fa.fa-fw.fa-history
+			a.btn.btn-full-height(
+				href,
+				tooltip="#{translate('chat')}",
+				tooltip-placement="bottom",
+				ng-class="{ active: ui.chatOpen }",
+				ng-click="toggleChat()",
+				ng-controller="ChatButtonController",
+				ng-show="!anonymous",
+			)
+				i.fa.fa-fw.fa-comment(
+					ng-class="{ 'bounce': unreadMessages > 0 }"
+				)
+				span.label.label-info(
+					ng-show="unreadMessages > 0"
+				) {{ unreadMessages }}
 
-	.toolbar-right
-		span.online-users(
-			ng-show="onlineUsersArray.length > 0"
-			ng-controller="OnlineUsersController"
+div(ng-if="shouldABTestHeaderLabels")
+	div(sixpack-switch="editor-header")
+		header.toolbar.toolbar-header(
+			ng-cloak,
+			ng-hide="state.loading"
+			sixpack-default
 		)
-			span(ng-if="onlineUsersArray.length < 4")
-				span.online-user(
-					ng-repeat="user in onlineUsersArray",
-					ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }",
-					popover="{{ user.name }}"
-					popover-placement="bottom"
-					popover-append-to-body="true"
-					popover-trigger="mouseenter"
-					ng-click="gotoUser(user)"
-				) {{ user.name.slice(0,1) }}
+			a.btn.btn-full-height(
+				href,
+				ng-click="ui.leftMenuShown = true; trackABTestConversion('menu');"
+				tooltip='#{translate("menu")}',
+				tooltip-placement="bottom",
+				tooltip-append-to-body="true",
+				sixpack-convert="editor-header"
+			)
+				i.fa.fa-fw.fa-bars
+			a(
+				href="/project"
+				tooltip="#{translate('back_to_projects')}",
+				tooltip-placement="bottom",
+				tooltip-append-to-body="true"
+			)
+				i.fa.fa-fw.fa-level-up
 			
-			span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4")
-				span.online-user.online-user-multi(
-					dropdown-toggle,
-					tooltip="#{translate('connected_users')}",
-					tooltip-placement="left"
-				)
-					strong {{ onlineUsersArray.length }}
-					i.fa.fa-fw.fa-user
-				ul.dropdown-menu.pull-right
-					li.dropdown-header #{translate('connected_users')}
-					li(ng-repeat="user in onlineUsersArray")
-						a(href, ng-click="gotoUser(user)")
-							span.online-user(
-								ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }"
-							) {{ user.name.slice(0,1) }}
-							| {{ user.name }}
-
-	
-		a.btn.btn-full-height(
-			href,
-			ng-if="permissions.admin",
-			tooltip="#{translate('share')}",
-			tooltip-placement="bottom",
-			ng-click="openShareProjectModal()",
-			ng-controller="ShareController"
-		)
-			i.fa.fa-fw.fa-group
-		a.btn.btn-full-height(
-			href,
-			ng-click="toggleTrackChanges()",
-			ng-class="{ active: (ui.view == 'track-changes') }"
-			tooltip="#{translate('recent_changes')}",
-			tooltip-placement="bottom"
-		)
-			i.fa.fa-fw.fa-history
-		a.btn.btn-full-height(
-			href,
-			tooltip="#{translate('chat')}",
-			tooltip-placement="bottom",
-			ng-class="{ active: ui.chatOpen }",
-			ng-click="toggleChat()",
-			ng-controller="ChatButtonController",
-			ng-show="!anonymous"
+			span(ng-controller="PdfViewToggleController")
+				a(
+					href,
+					ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
+					tooltip="PDF",
+					tooltip-placement="bottom",
+					tooltip-append-to-body="true",
+					ng-click="togglePdfView()",
+					ng-class="{ 'active': ui.view == 'pdf' }"
+				)
+					i.fa.fa-file-pdf-o
+
+			.toolbar-center.project-name(ng-controller="ProjectNameController")
+				span.name(
+					ng-dblclick="!permissions.admin || startRenaming()",
+					ng-show="!state.renaming"
+				) {{ project.name }}
+
+				input.form-control(
+					type="text"
+					ng-model="inputs.name",
+					ng-show="state.renaming",
+					on-enter="finishRenaming()",
+					ng-blur="finishRenaming()",
+					select-name-when="state.renaming"
+				)
+
+				a.rename(
+					ng-if="permissions.admin",
+					href='#',
+					tooltip-placement="bottom",
+					tooltip="#{translate('rename')}",
+					tooltip-append-to-body="true",
+					ng-click="startRenaming()",
+					ng-show="!state.renaming"
+				)
+					i.fa.fa-pencil
+
+			.toolbar-right
+				span.online-users(
+					ng-show="onlineUsersArray.length > 0"
+					ng-controller="OnlineUsersController"
+				)
+					span(ng-if="onlineUsersArray.length < 4")
+						span.online-user(
+							ng-repeat="user in onlineUsersArray",
+							ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }",
+							popover="{{ user.name }}"
+							popover-placement="bottom"
+							popover-append-to-body="true"
+							popover-trigger="mouseenter"
+							ng-click="gotoUser(user)"
+						) {{ user.name.slice(0,1) }}
+					
+					span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4")
+						span.online-user.online-user-multi(
+							dropdown-toggle,
+							tooltip="#{translate('connected_users')}",
+							tooltip-placement="left"
+						)
+							strong {{ onlineUsersArray.length }}
+							i.fa.fa-fw.fa-user
+						ul.dropdown-menu.pull-right
+							li.dropdown-header #{translate('connected_users')}
+							li(ng-repeat="user in onlineUsersArray")
+								a(href, ng-click="gotoUser(user)")
+									span.online-user(
+										ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }"
+									) {{ user.name.slice(0,1) }}
+									| {{ user.name }}
+
+			
+				a.btn.btn-full-height(
+					href,
+					ng-if="permissions.admin",
+					tooltip="#{translate('share')}",
+					tooltip-placement="bottom",
+					ng-click="openShareProjectModal(); trackABTestConversion('share');",
+					ng-controller="ShareController",
+					sixpack-convert="editor-header"
+				)
+					i.fa.fa-fw.fa-group
+				a.btn.btn-full-height(
+					href,
+					ng-click="toggleHistory(); trackABTestConversion('history');",
+					ng-class="{ active: (ui.view == 'history') }"
+					tooltip="#{translate('recent_changes')}",
+					tooltip-placement="bottom",
+					sixpack-convert="editor-header"
+				)
+					i.fa.fa-fw.fa-history
+				a.btn.btn-full-height(
+					href,
+					tooltip="#{translate('chat')}",
+					tooltip-placement="bottom",
+					ng-class="{ active: ui.chatOpen }",
+					ng-click="toggleChat(); trackABTestConversion('chat');",
+					ng-controller="ChatButtonController",
+					ng-show="!anonymous",
+					sixpack-convert="editor-header"
+				)
+					i.fa.fa-fw.fa-comment(
+						ng-class="{ 'bounce': unreadMessages > 0 }"
+					)
+					span.label.label-info(
+						ng-show="unreadMessages > 0"
+					) {{ unreadMessages }}
+
+		header.toolbar.toolbar-header.toolbar-with-labels(
+			ng-cloak,
+			ng-hide="state.loading"
+			sixpack-when="labels"
 		)
-			i.fa.fa-fw.fa-comment(
-				ng-class="{ 'bounce': unreadMessages > 0 }"
-			)
-			span.label.label-info(
-				ng-show="unreadMessages > 0"
-			) {{ unreadMessages }}
+			.toolbar-left
+				a.btn.btn-full-height(
+					href,
+					ng-click="ui.leftMenuShown = true; trackABTestConversion('menu');",
+					sixpack-convert="editor-header"
+				)
+					i.fa.fa-fw.fa-bars
+					p.toolbar-label #{translate("menu")}
+				a(
+					href="/project"
+				)
+					i.fa.fa-fw.fa-level-up
+
+			span(ng-controller="PdfViewToggleController")
+				a(
+					href,
+					ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
+					tooltip="PDF",
+					tooltip-placement="bottom",
+					tooltip-append-to-body="true",
+					ng-click="togglePdfView()",
+					ng-class="{ 'active': ui.view == 'pdf' }"
+				)
+					i.fa.fa-file-pdf-o
+
+			.toolbar-center.project-name(ng-controller="ProjectNameController")
+				span.name(
+					ng-dblclick="!permissions.admin || startRenaming()",
+					ng-show="!state.renaming"
+				) {{ project.name }}
+
+				input.form-control(
+					type="text"
+					ng-model="inputs.name",
+					ng-show="state.renaming",
+					on-enter="finishRenaming()",
+					ng-blur="finishRenaming()",
+					select-name-when="state.renaming"
+				)
+
+				a.rename(
+					ng-if="permissions.admin",
+					href='#',
+					tooltip-placement="bottom",
+					tooltip="#{translate('rename')}",
+					tooltip-append-to-body="true",
+					ng-click="startRenaming()",
+					ng-show="!state.renaming"
+				)
+					i.fa.fa-pencil
+
+			.toolbar-right
+				span.online-users(
+					ng-show="onlineUsersArray.length > 0"
+					ng-controller="OnlineUsersController"
+				)
+					span(ng-if="onlineUsersArray.length < 4")
+						span.online-user(
+							ng-repeat="user in onlineUsersArray",
+							ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }",
+							popover="{{ user.name }}"
+							popover-placement="bottom"
+							popover-append-to-body="true"
+							popover-trigger="mouseenter"
+							ng-click="gotoUser(user)"
+						) {{ user.name.slice(0,1) }}
+					
+					span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4")
+						span.online-user.online-user-multi(
+							dropdown-toggle,
+							tooltip="#{translate('connected_users')}",
+							tooltip-placement="left"
+						)
+							strong {{ onlineUsersArray.length }}
+							i.fa.fa-fw.fa-user
+						ul.dropdown-menu.pull-right
+							li.dropdown-header #{translate('connected_users')}
+							li(ng-repeat="user in onlineUsersArray")
+								a(href, ng-click="gotoUser(user)")
+									span.online-user(
+										ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }"
+									) {{ user.name.slice(0,1) }}
+									| {{ user.name }}
+
+			
+				a.btn.btn-full-height(
+					href,
+					ng-if="permissions.admin",
+					ng-click="openShareProjectModal(); trackABTestConversion('share');",
+					ng-controller="ShareController",
+					sixpack-convert="editor-header"
+				)
+					i.fa.fa-fw.fa-group
+					p.toolbar-label #{translate("share")}
+				a.btn.btn-full-height(
+					href,
+					ng-click="toggleHistory(); trackABTestConversion('history');",
+					ng-class="{ active: (ui.view == 'history') }",
+					sixpack-convert="editor-header"
+				)
+					i.fa.fa-fw.fa-history
+					p.toolbar-label #{translate("history")}
+				a.btn.btn-full-height(
+					href,
+					ng-class="{ active: ui.chatOpen }",
+					ng-click="toggleChat(); trackABTestConversion('chat');",
+					ng-controller="ChatButtonController",
+					ng-show="!anonymous",
+					sixpack-convert="editor-header"
+				)
+					i.fa.fa-fw.fa-comment(
+						ng-class="{ 'bounce': unreadMessages > 0 }"
+					)
+					span.label.label-info(
+						ng-show="unreadMessages > 0"
+					) {{ unreadMessages }}
+					p.toolbar-label #{translate("chat")}

+ 38 - 26
services/web/app/views/project/editor/track-changes.jade → services/web/app/views/project/editor/history.jade

@@ -1,5 +1,5 @@
-div#trackChanges(ng-show="ui.view == 'track-changes'")
-	span(ng-controller="TrackChangesPremiumPopup")
+div#history(ng-show="ui.view == 'history'")
+	span(ng-controller="HistoryPremiumPopup")
 		.upgrade-prompt(ng-show="!project.features.versioning")
 			.message(ng-show="project.owner._id == user.id")
 				p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})}
@@ -33,29 +33,29 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
 					a.btn.btn-success(
 						href
 						ng-class="buttonClass"
-						ng-click="startFreeTrial('track-changes')"
+						ng-click="startFreeTrial('history')"
 					) #{translate("start_free_trial")}
 
 
 			.message(ng-show="project.owner._id != user.id")
 				p #{translate("ask_proj_owner_to_upgrade_for_history")}
 				p
-					a.small(href, ng-click="toggleTrackChanges()") #{translate("cancel")}
+					a.small(href, ng-click="toggleHistory()") #{translate("cancel")}
 
 	aside.change-list(
-		ng-controller="TrackChangesListController"
+		ng-controller="HistoryListController"
 		infinite-scroll="loadMore()"
-		infinite-scroll-disabled="trackChanges.loading || trackChanges.atEnd"
-		infinite-scroll-initialize="ui.view == 'track-changes'"
+		infinite-scroll-disabled="history.loading || history.atEnd"
+		infinite-scroll-initialize="ui.view == 'history'"
 	)
 		.infinite-scroll-inner
 			ul.list-unstyled(
 				ng-class="{\
-					'hover-state': trackChanges.hoveringOverListSelectors\
+					'hover-state': history.hoveringOverListSelectors\
 				}"
 			)
 				li.change(
-					ng-repeat="update in trackChanges.updates"
+					ng-repeat="update in history.updates"
 					ng-class="{\
 						'first-in-day': update.meta.first_in_day,\
 						'selected': update.inSelection,\
@@ -65,7 +65,7 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
 						'hover-selected-to': update.hoverSelectedTo,\
 						'hover-selected-from': update.hoverSelectedFrom,\
 					}"
-					ng-controller="TrackChangesListItemController"
+					ng-controller="HistoryListItemController"
 				)
 
 					div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }}
@@ -108,57 +108,69 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
 								.color-square(style="background-color: hsl(100, 100%, 50%)")
 								span #{translate("anonymous")}
 
-			.loading(ng-show="trackChanges.loading")
+			.loading(ng-show="history.loading")
 				i.fa.fa-spin.fa-refresh
 				| &nbsp;&nbsp; #{translate("loading")}...
 
-	.diff-panel.full-size(ng-controller="TrackChangesDiffController")
+	.diff-panel.full-size(ng-controller="HistoryDiffController")
 		.diff(
-			ng-show="!!trackChanges.diff && !trackChanges.diff.loading && !trackChanges.diff.deleted && !trackChanges.diff.error"
+			ng-show="!!history.diff && !history.diff.loading && !history.diff.deleted && !history.diff.error"
 		)
 			.toolbar.toolbar-alt
 				span.name
-					| <strong>{{trackChanges.diff.highlights.length}} </strong>
+					| <strong>{{history.diff.highlights.length}} </strong>
 					ng-pluralize(
-						count="trackChanges.diff.highlights.length",
+						count="history.diff.highlights.length",
 						when="{\
 							'one': 'change',\
 							'other': 'changes'\
 						}"
 					)
-					|  in <strong>{{trackChanges.diff.doc.name}}</strong>
+					|  in <strong>{{history.diff.doc.name}}</strong>
 				.toolbar-right
 					a.btn.btn-danger.btn-sm(
 						href,
 						ng-click="openRestoreDiffModal()"
 					) #{translate("restore_to_before_these_changes")}
 			.diff-editor.hide-ace-cursor(
-				ace-editor="track-changes",
+				ace-editor="history",
 				theme="settings.theme",
 				font-size="settings.fontSize",
-				text="trackChanges.diff.text",
-				highlights="trackChanges.diff.highlights",
+				text="history.diff.text",
+				highlights="history.diff.highlights",
 				read-only="true",
 				resize-on="layout:main:resize",
 				navigate-highlights="true"
 			)
 		.diff-deleted.text-centered(
-			ng-show="trackChanges.diff.deleted"
+			ng-show="history.diff.deleted && !history.diff.restoreDeletedSuccess"
 		)
-			p.text-serif #{translate("file_has_been_deleted", {filename:"{{ trackChanges.diff.doc.name }} "})}
-
+			p.text-serif #{translate("file_has_been_deleted", {filename:"{{ history.diff.doc.name }} "})}
 			p
 				a.btn.btn-primary.btn-lg(
 					href,
-					ng-click="restoreDeletedDoc()"
+					ng-click="restoreDeletedDoc()",
+					ng-disabled="history.diff.restoreInProgress"
 				) #{translate("restore")}
-		.loading-panel(ng-show="trackChanges.diff.loading")
+
+		.diff-deleted.text-centered(
+			ng-show="history.diff.deleted && history.diff.restoreDeletedSuccess"
+		)
+			p.text-serif  #{translate("file_restored", {filename:"{{ history.diff.doc.name }} "})}
+			p.text-serif  #{translate("file_restored_back_to_editor")}
+			p
+				a.btn.btn-default(
+					href,
+					ng-click="backToEditorAfterRestore()",
+				) #{translate("file_restored_back_to_editor_btn")}
+
+		.loading-panel(ng-show="history.diff.loading")
 			i.fa.fa-spin.fa-refresh
 			| &nbsp;&nbsp;#{translate("loading")}...
-		.error-panel(ng-show="trackChanges.diff.error")
+		.error-panel(ng-show="history.diff.error")
 			.alert.alert-danger #{translate("generic_something_went_wrong")}
 
-script(type="text/ng-template", id="trackChangesRestoreDiffModalTemplate")
+script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
 	.modal-header
 		button.close(
 			type="button"

+ 25 - 0
services/web/app/views/project/editor/hotkeys.jade

@@ -66,6 +66,31 @@ script(type="text/ng-template", id="hotkeysModalTemplate")
 				.hotkey
 					span.combination {{ctrl}} + I
 					span.description Italic Text
+	
+		h3 #{translate("autocomplete")}
+		.row
+			.col-xs-6
+				.hotkey
+					span.combination Ctrl + Space
+					span.description Autocomplete Menu
+
+			.col-xs-6
+				.hotkey
+					span.combination Tab / Up / Down
+					span.description Select Candidate
+
+				.hotkey
+					span.combination Enter
+					span.description Insert Candidate
+	
+		h3 !{translate("autocomplete_references")}
+		.row
+			.col-xs-6
+				.hotkey
+					span.combination Ctrl + Space 
+					span.description Search References
+		
+					
 	.modal-footer
 		button.btn.btn-default(
 			ng-click="cancel()"

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

@@ -105,6 +105,15 @@ aside#left-menu.full-size(
 					ng-options="o.v as o.n for o in [{ n: 'On', v: true }, { n: 'Off', v: false }]"
 				)
 
+			if (user.betaProgram)
+				.form-controls
+					label(for="syntaxValidation") #{translate("syntax_validation")}
+					select(
+						name="syntaxValidation"
+						ng-model="settings.syntaxValidation"
+						ng-options="o.v as o.n for o in [{ n: 'On', v: true }, { n: 'Off', v: false }]"
+					)
+
 			.form-controls
 				label(for="theme") #{translate("theme")}
 				select(
@@ -197,6 +206,10 @@ script(type='text/ng-template', id='wordCountModalTemplate')
 			)
 		div(ng-if="!status.loading")
 			.container-fluid
+				.row(ng-show='data.messages.length > 0')
+					.col-xs-12
+						.alert.alert-danger
+							p(style="white-space: pre-wrap") {{data.messages}}
 				.row
 					.col-xs-4
 						.pull-right #{translate("total_words")} :

+ 36 - 5
services/web/app/views/project/editor/pdf.jade

@@ -25,7 +25,7 @@ div.full-size.pdf(ng-controller="PdfController")
 				dropdown-toggle
 			)
 				span.caret
-			ul.dropdown-menu.dropdown-menu-right
+			ul.dropdown-menu.dropdown-menu-left
 				li.dropdown-header #{translate("compile_mode")}
 				li
 					a(href, ng-click="draft = false")
@@ -36,6 +36,19 @@ div.full-size.pdf(ng-controller="PdfController")
 						i.fa.fa-fw(ng-class="{'fa-check': draft}")
 						| &nbsp;#{translate("fast")}&nbsp;
 						span.subdued [draft]
+				li.dropdown-header #{translate("compile_time_checks")}
+				li
+					a(href, ng-click="stop_on_validation_error = true")
+						i.fa.fa-fw(ng-class="{'fa-check': stop_on_validation_error}")
+						| &nbsp;#{translate("stop_on_validation_error")}
+				li
+					a(href, ng-click="stop_on_validation_error = false")
+						i.fa.fa-fw(ng-class="{'fa-check': !stop_on_validation_error}")
+						| &nbsp;#{translate("ignore_validation_errors")}
+				li
+					a(href, ng-click="recompile({check:true})")
+						i.fa.fa-fw()
+						| &nbsp;#{translate("run_syntax_check_now")}
 		a(
 			href
 			ng-click="stop()"
@@ -100,6 +113,23 @@ div.full-size.pdf(ng-controller="PdfController")
 			strong #{translate("compile_error")}. 
 			span #{translate("generic_failed_compile_message")}.
 
+		.alert.alert-danger(ng-show="pdf.failedCheck")
+			strong #{translate("failed_compile_check")}.
+			p
+			p.text-center(ng-show="!check")
+				a.text-info(
+					href,
+					ng-disabled="pdf.compiling",
+					ng-click="recompile({try:true})"
+				) #{translate("failed_compile_check_try")}
+				| &#32;#{translate("failed_compile_option_or")}&#32;
+				a.text-info(
+					href,
+					ng-disabled="pdf.compiling",
+					ng-click="recompile({force:true})"
+				) #{translate("failed_compile_check_ignore")}
+				| .
+
 		div(ng-repeat="entry in pdf.logEntries.all", ng-controller="PdfLogEntryController")
 			.alert(
 				ng-class="{\
@@ -115,7 +145,8 @@ div.full-size.pdf(ng-controller="PdfController")
 					| &nbsp;
 					span(ng-show="entry.file") {{ entry.file }}
 					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.type }} {{ entry.message }}
 				.card.card-hint(
 					ng-if="entry.humanReadableHint"
 					stop-propagation="click"
@@ -126,7 +157,7 @@ div.full-size.pdf(ng-controller="PdfController")
 						ng-show="entry.humanReadableHint",
 						ng-bind-html="wikiEnabled ? entry.humanReadableHint : stripHTMLFromString(entry.humanReadableHint)")
 					.card-hint-actions.clearfix
-						.card-hint-ext-link(ng-if="wikiEnabled")
+						.card-hint-ext-link(ng-if="wikiEnabled && entry.extraInfoURL")
 							a(
 								ng-href="{{ entry.extraInfoURL }}",
 								ng-click="trackLogHintsLearnMore()"
@@ -198,7 +229,7 @@ div.full-size.pdf(ng-controller="PdfController")
 
 				p.entry-content(ng-show="entry.content") {{ entry.content.trim() }}
 
-		p
+		div
 			.files-dropdown-container
 				a.btn.btn-default.btn-sm(
 					href,
@@ -313,7 +344,7 @@ div.full-size.pdf(ng-controller="PdfController")
 					strong #{translate("ask_proj_owner_to_upgrade_for_faster_compiles")}
 				p #{translate("free_accounts_have_timeout_upgrade_to_increase")}
 				p Plus:
-				p
+				div
 					ul.list-unstyled
 						li
 							i.fa.fa-check &nbsp;

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

@@ -27,12 +27,12 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 					) #{translate("make_private")}
 			.row.project-member
 				.col-xs-8 {{ project.owner.email }}
-				.text-right(
+				.text-left(
 					ng-class="{'col-xs-3': project.members.length > 0, 'col-xs-4': project.members.length == 0}"
 				) #{translate("owner")}
 			.row.project-member(ng-repeat="member in project.members")
 				.col-xs-8 {{ member.email }}
-				.col-xs-3.text-right
+				.col-xs-3.text-left
 					span(ng-show="member.privileges == 'readAndWrite'") #{translate("can_edit")}
 					span(ng-show="member.privileges == 'readOnly'") #{translate("read_only")}
 				.col-xs-1
@@ -43,6 +43,23 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 						ng-click="removeMember(member)"
 					)
 						i.fa.fa-times
+			.row.project-invite(ng-repeat="invite in project.invites")
+				.col-xs-8 {{ invite.email }}&nbsp;
+					div.small
+						| #{translate("invite_not_accepted")}.&nbsp;
+						a(href="#", ng-click="resendInvite(invite, $event)") #{translate("resend")}
+				.col-xs-3.text-left
+					// todo: get invite privileges
+					span(ng-show="invite.privileges == 'readAndWrite'") #{translate("can_edit")}
+					span(ng-show="invite.privileges == 'readOnly'") #{translate("read_only")}
+				.col-xs-1
+					a(
+						href
+						tooltip="#{translate('revoke_invite')}"
+						tooltip-placement="bottom"
+						ng-click="revokeInvite(invite)"
+					)
+						i.fa.fa-times
 			.row.invite-controls
 				form(ng-show="canAddCollaborators")
 					.small #{translate("share_with_your_collabs")}
@@ -78,6 +95,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 							button.btn.btn-info(
 								type="submit"
 								ng-mousedown="addMembers()"
+								ng-keyup="$event.keyCode == 13 ? addMembers() : null"
 							) #{translate("share")}
 				div(ng-hide="canAddCollaborators")
 					p.text-center #{translate("need_to_upgrade_for_more_collabs")}. Also:
@@ -123,7 +141,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 		.modal-footer-left
 			i.fa.fa-refresh.fa-spin(ng-show="state.inflight")
 			span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")}
-		button.btn.btn-primary(
+		button.btn.btn-default(
 			ng-click="done()"
 		) #{translate("close")}
 

+ 18 - 0
services/web/app/views/project/invite/not-valid.jade

@@ -0,0 +1,18 @@
+extends ../../layout
+
+block content
+	.content.content-alt
+		.container
+			.row
+				.col-md-8.col-md-offset-2
+					.card.project-invite-invalid
+						.page-header.text-centered
+							h1  #{translate("invite_not_valid")}
+						.row.text-center
+							.col-md-12
+								p
+									| #{translate("invite_not_valid_description")}.
+						.row.text-center.actions
+							.col-md-12
+								a.btn.btn-info(href="/project") #{translate("back_to_your_projects")}
+							

+ 31 - 0
services/web/app/views/project/invite/show.jade

@@ -0,0 +1,31 @@
+extends ../../layout
+
+block content
+	.content.content-alt
+		.container
+			.row
+				.col-md-8.col-md-offset-2
+					.card.project-invite-accept
+						.page-header.text-centered
+							h1  #{translate("user_wants_you_to_see_project", {username:owner.first_name, projectname:""})}
+								em
+									span.project-name #{project.name} 
+						.row.text-center
+							.col-md-12
+								p
+									| #{translate("accepting_invite_as")}&nbsp;
+									em #{user.email}
+						.row
+							.col-md-12
+								form.form(
+									name="acceptForm",
+									method="POST",
+									action="/project/#{invite.projectId}/invite/token/#{invite.token}/accept"
+								)
+									input(name='_csrf', type='hidden', value=csrfToken)
+									input(name='token', type='hidden', value="#{invite.token}")
+									.form-group.text-center
+										button.btn.btn-lg.btn-primary(type="submit")
+											| #{translate("join_project")}
+									.form-group.text-center
+							

+ 3 - 3
services/web/app/views/project/list.jade

@@ -23,7 +23,7 @@ block content
 		.container
 				
 			.row(ng-cloak)
-				span(ng-show="first_sign_up == 'default' || projects.length > 0")
+				span(ng-if="projects.length > 0")
 					aside.col-md-2.col-xs-3
 						include ./list/side-bar
 
@@ -31,8 +31,8 @@ block content
 						include ./list/notifications
 						include ./list/project-list
 				
-				span(ng-if="first_sign_up == 'minimial' && projects.length == 0")
+				span(ng-if="projects.length === 0")
 					.col-md-offset-2.col-md-8.col-md-offset-2.col-xs-8
-						include ./list/project-list-minimal
+						include ./list/empty-project-list
 
 		include ./list/modals

+ 46 - 0
services/web/app/views/project/list/empty-project-list.jade

@@ -0,0 +1,46 @@
+.row.row-spaced
+	.col-xs-12
+		.card.card-thin.project-list-card
+			div.welcome.text-centered(ng-cloak)
+				h2 #{translate("welcome_to_sl")}
+				p #{translate("new_to_latex_look_at")} 
+					a(href="/templates") #{translate("templates").toLowerCase()}
+					|   #{translate("or")}
+					a(href="/learn")  #{translate("latex_help_guide")}
+
+
+					.row
+						.col-md-offset-4.col-md-4
+							.dropdown.minimal-create-proj-dropdown(dropdown)
+								a.btn.btn-success.dropdown-toggle(
+									href="#",
+									data-toggle="dropdown",
+									dropdown-toggle
+								)
+									| Create First Project
+
+								ul.dropdown-menu.minimal-create-proj-dropdown-menu(role="menu")
+									li
+										a(
+											href,
+											ng-click="openCreateProjectModal()"
+										) #{translate("blank_project")}
+									li
+										a(
+											href,
+											ng-click="openCreateProjectModal('example')"
+										) #{translate("example_project")}
+									li
+										a(
+											href,
+											ng-click="openUploadProjectModal()"
+										) #{translate("upload_project")}
+									!= moduleIncludes("newProjectMenu", locals)
+									if (templates)
+										li.divider
+										li.dropdown-header #{translate("templates")}
+										each item in templates
+											li
+												a.menu-indent(href=item.url) #{translate(item.name)}
+
+							

+ 26 - 7
services/web/app/views/project/list/notifications.jade

@@ -4,12 +4,31 @@ span(ng-controller="NotificationsController").userNotifications
 		ng-cloak
 	)
 		li.notification_entry(
-			ng-repeat="unreadNotification in notifications",
+			ng-repeat="notification in notifications",
 		)
-			.row(ng-hide="unreadNotification.hide")
+			.row(ng-hide="notification.hide")
 				.col-xs-12
-					.alert.alert-info
-						span(ng-bind-html="unreadNotification.html")
-						button(ng-click="dismiss(unreadNotification)").close.pull-right
-							span(aria-hidden="true") &times;
-							span.sr-only #{translate("close")}
+					.alert.alert-info(ng-if="notification.templateKey == 'notification_project_invite'", ng-controller="ProjectInviteNotificationController")
+						div.notification_inner
+							.notification_body(ng-show="!notification.accepted")
+								| !{translate("notification_project_invite_message")}
+								a.pull-right.btn.btn-sm.btn-info(href, ng-click="accept()", ng-disabled="notification.inflight")
+									span(ng-show="!notification.inflight") #{translate("join_project")}
+									span(ng-show="notification.inflight")
+										i.fa.fa-fw.fa-spinner.fa-spin
+										| &nbsp;
+										| #{translate("joining")}...
+							.notification_body(ng-show="notification.accepted")
+								| !{translate("notification_project_invite_accepted_message")}
+								a.pull-right.btn.btn-sm.btn-info(href="/project/{{ notification.messageOpts.projectId }}") #{translate("open_project")}
+							span().notification_close
+								button(ng-click="dismiss(notification)").close.pull-right
+									span(aria-hidden="true") &times;
+									span.sr-only #{translate("close")}
+					.alert.alert-info(ng-if="notification.templateKey != 'notification_project_invite'")
+						div.notification_inner
+							span(ng-bind-html="notification.html").notification_body
+							span().notification_close
+								button(ng-click="dismiss(notification)").close.pull-right
+									span(aria-hidden="true") &times;
+									span.sr-only #{translate("close")}

+ 0 - 202
services/web/app/views/project/list/project-list-minimal.jade

@@ -1,202 +0,0 @@
-.row
-	.col-xs-12(ng-cloak)
-
-		.project-tools(ng-cloak)
-			.btn-toolbar(ng-show="filter != 'archived'")
-				.btn-group(ng-hide="selectedProjects.length < 1")
-					a.btn.btn-default(
-						href='#',
-						tooltip="#{translate('download')}",
-						tooltip-placement="bottom",
-						tooltip-append-to-body="true",
-						ng-click="downloadSelectedProjects()"
-					)
-						i.fa.fa-cloud-download
-					a.btn.btn-default(
-						href='#',
-						tooltip="#{translate('delete')}",
-						tooltip-placement="bottom",
-						tooltip-append-to-body="true",
-						ng-click="openArchiveProjectsModal()"
-					)
-						i.fa.fa-trash-o
-
-				.btn-group.dropdown(ng-hide="selectedProjects.length < 1", dropdown)
-					a.btn.btn-default.dropdown-toggle(
-						href="#",
-						data-toggle="dropdown",
-						dropdown-toggle,
-						tooltip="#{translate('add_to_folders')}",
-						tooltip-append-to-body="true",
-						tooltip-placement="bottom"
-					)
-						i.fa.fa-folder-open-o
-						|  
-						span.caret
-					ul.dropdown-menu.dropdown-menu-right.js-tags-dropdown-menu(
-						role="menu"
-						ng-controller="TagListController"
-					)
-						li.dropdown-header #{translate("add_to_folder")}
-						li(
-							ng-repeat="tag in tags | filter:nonEmpty | orderBy:'name'",
-							ng-controller="TagDropdownItemController"
-						)
-							a(href="#", ng-click="addOrRemoveProjectsFromTag()", stop-propagation="click")
-								i.fa(
-									ng-class="{\
-										'fa-check-square-o': areSelectedProjectsInTag == true,\
-										'fa-square-o': areSelectedProjectsInTag == false,\
-										'fa-minus-square-o': areSelectedProjectsInTag == 'partial'\
-									}"
-								)
-								|  {{tag.name}}
-						li.divider
-						li
-							a(href="#", ng-click="openNewTagModal()", stop-propagation="click") #{translate("create_new_folder")}
-
-				.btn-group(ng-hide="selectedProjects.length != 1", dropdown).dropdown
-					a.btn.btn-default.dropdown-toggle(
-						href='#',
-						data-toggle="dropdown",
-						dropdown-toggle
-					) #{translate("more")}
-						span.caret
-					ul.dropdown-menu.dropdown-menu-right(role="menu")
-						li(ng-show="getFirstSelectedProject().accessLevel == 'owner'")
-							a(
-								href='#',
-								ng-click="openRenameProjectModal()"
-							) #{translate("rename")}
-						li
-							a(
-								href='#',
-								ng-click="openCloneProjectModal()"
-							) #{translate("make_copy")}
-
-			.btn-toolbar(ng-show="filter == 'archived'")
-				.btn-group(ng-hide="selectedProjects.length < 1")
-					a.btn.btn-default(
-						href='#',
-						data-original-title="Restore",
-						data-toggle="tooltip",
-						data-placement="bottom",
-						ng-click="restoreSelectedProjects()"
-					) #{translate("restore")}
-
-				.btn-group(ng-hide="selectedProjects.length < 1")
-					a.btn.btn-danger(
-						href='#',
-						data-original-title="Delete Forever",
-						data-toggle="tooltip",
-						data-placement="bottom",
-						ng-click="openDeleteProjectsModal()"
-					) #{translate("delete_forever")}
-
-.row.row-spaced
-	.col-xs-12
-		.card.card-thin.project-list-card
-			ul.list-unstyled.project-list.structured-list(
-				select-all-list,
-				ng-if="projects.length > 0",
-				max-height="projectListHeight - 25",
-				ng-cloak
-			)
-				li.container-fluid
-					.row
-						.col-xs-6
-							input.select-all(
-								select-all,
-								type="checkbox"
-							)
-							span.header.clickable(ng-click="changePredicate('name')") #{translate("title")}
-								i.tablesort.fa(ng-class="getSortIconClass('name')")
-						.col-xs-2
-							span.header.clickable(ng-click="changePredicate('accessLevel')") #{translate("owner")}
-								i.tablesort.fa(ng-class="getSortIconClass('accessLevel')")
-						.col-xs-4
-							span.header.clickable(ng-click="changePredicate('lastUpdated')") #{translate("last_modified")}
-								i.tablesort.fa(ng-class="getSortIconClass('lastUpdated')")
-				li.project_entry.container-fluid(
-					ng-repeat="project in visibleProjects | orderBy:predicate:reverse",
-					ng-controller="ProjectListItemController"
-				)
-					.row
-						.col-xs-6
-							input.select-item(
-								select-individual,
-								type="checkbox",
-								ng-model="project.selected"
-							)
-							span
-								a.projectName(href="/project/{{project.id}}") {{project.name}}
-								span(
-									ng-controller="TagListController"
-								)
-									a.label.label-default.tag-label(
-										href,
-										ng-repeat='tag in project.tags',
-										ng-click="selectTag(tag)"
-									) {{tag.name}}
-						.col-xs-2
-							span.owner {{ownerName()}}
-						.col-xs-4
-							span.last-modified {{project.lastUpdated | formatDate}}
-				li(
-					ng-if="visibleProjects.length == 0",
-					ng-cloak
-				)
-					.row
-						.col-xs-12.text-centered
-							small #{translate("no_projects")}
-
-			div.welcome.text-centered(ng-if="projects.length == 0", ng-cloak)
-				h2 #{translate("welcome_to_sl")}
-				p #{translate("new_to_latex_look_at")} 
-					a(href="/templates") #{translate("templates").toLowerCase()}
-					|   #{translate("or")}
-					a(href="/learn")  #{translate("latex_help_guide")}
-
-
-					.row
-						.col-md-offset-4.col-md-4
-							.dropdown(dropdown)
-								a.btn.btn-success.dropdown-toggle(
-									href="#",
-									data-toggle="dropdown",
-									dropdown-toggle
-								)
-									| Create First Project
-								style.
-									.dropdown{text-align:center;}
-									.button, .dropdown-menu{margin:2px auto}
-									.dropdown-menu{width:200px; left:50%; margin-left:-100px;}
-
-								ul.dropdown-menu(role="menu", style="text-align:center;")
-									li
-										a(
-											href,
-											ng-click="openCreateProjectModal()"
-											sixpack-convert="first_sign_up",
-										) #{translate("blank_project")}
-									li
-										a(
-											href,
-											sixpack-convert="first_sign_up",
-											ng-click="openCreateProjectModal('example')"
-										) #{translate("example_project")}
-									li
-										a(
-											href,
-											sixpack-convert="first_sign_up",
-											ng-click="openUploadProjectModal()"
-										) #{translate("upload_project")}
-									!= moduleIncludes("newProjectMenu", locals)
-									if (templates)
-										li.divider
-										li.dropdown-header #{translate("templates")}
-										each item in templates
-											li
-												a.menu-indent(href=item.url, sixpack-convert="first_sign_up") #{translate(item.name)}
-
-							

+ 2 - 2
services/web/app/views/project/list/project-list.jade

@@ -8,7 +8,7 @@
 				input.form-control.col-md-7.col-xs-12(
 					placeholder="#{translate('search_projects')}…",
 					autofocus='autofocus',
-					ng-model="searchText",
+					ng-model="searchText.value",
 					focus-on='search:clear',
 					ng-keyup="searchProjects()"
 				)
@@ -16,7 +16,7 @@
 				i.fa.fa-times.form-control-feedback(
 					ng-click="clearSearchText()",
 					style="cursor: pointer;",
-					ng-show="searchText.length > 0"
+					ng-show="searchText.value.length > 0"
 				)
 			//- i.fa.fa-remove
 

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

@@ -155,6 +155,6 @@
 							| #{translate("or_unlock_features_bonus")} 
 							a(href="/user/bonus") #{translate("sharing_sl")} .
 script.
-	window.userHasNoSubscription = #{settings.enableSubscriptions && !hasSubscription}
+	window.userHasNoSubscription = #{!!(settings.enableSubscriptions && !hasSubscription)}
 
 

+ 5 - 0
services/web/app/views/subscriptions/dashboard.jade

@@ -40,6 +40,11 @@ block content
 		.container(ng-controller="UserSubscriptionController")
 			.row
 				.col-md-8.col-md-offset-2
+					if saved_billing_details
+						.alert.alert-success
+							i.fa.fa-check
+							| &nbsp;
+							| #{translate("your_billing_details_were_saved")}
 					.card(ng-if="view == 'overview'")
 						.page-header
 							h1 #{translate("your_subscription")}

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

@@ -1,10 +1,9 @@
 extends ../layout
 
-block content
-	- locals.supressDefaultJs = true
-	script(data-main=jsPath+'main.js', src=jsPath+'libs/require.js', baseurl=jsPath)
-	script(src=buildJsPath('libs/recurly.min.js'))
+block scripts
+	script(src=buildJsPath('libs/recurly.min.js', {fingerprint:false}))
 
+block content
 	.content.content-alt
 		.container
 			.row
@@ -19,7 +18,7 @@ block content
 		Recurly.config(!{recurlyConfig})
 		Recurly.buildBillingInfoUpdateForm({
 			target      : "#billingDetailsForm",
-			successURL  : "#{successURL}?_csrf=#{csrfToken}",
+			successURL  : "#{successURL}?_csrf=#{csrfToken}&origin=editBillingDetails",
 			signature   : "!{signature}",
 			accountCode : "#{user.id}"
 		});

+ 1 - 1
services/web/app/views/subscriptions/group/invite.jade

@@ -3,7 +3,7 @@ extends ../../layout
 block scripts
 	script(type='text/javascript').
 		window.group_subscription_id = '#{group_subscription_id}'
-		window.has_personal_subscription = '#{has_personal_subscription}'
+		window.has_personal_subscription = #{has_personal_subscription}
 
 block content
 	.content.content-alt

+ 193 - 205
services/web/app/views/subscriptions/new.jade

@@ -1,6 +1,6 @@
 extends ../layout
-block scripts
 
+block scripts
 	script(src="https://js.recurly.com/v3/recurly.js")
 
 	script(type='text/javascript').
@@ -11,10 +11,6 @@ block scripts
 		window.couponCode = "#{couponCode}"
 
 block content
-	- locals.supressDefaultJs = true
-	script(data-main=jsPath+'main.js', src=jsPath+'libs/require.js', baseurl=jsPath)
-
-
 	.content.content-alt
 		.container(ng-controller="NewSubscriptionController" ng-cloak)
 			.row.card-group
@@ -51,226 +47,218 @@ block content
 									div(ng-if="normalPrice")
 										span.small Normally {{price.currency.symbol}}{{normalPrice}}
 						.row
-							.col-md-12
-								form(ng-show="planName")
-									
+							div()
+								.col-md-12()
+									form(
+										ng-if="planName"
+										name="simpleCCForm"
+										novalidate
+									)
+										div.payment-method-toggle
+											a.payment-method-toggle-switch(
+												href
+												ng-click="setPaymentMethod('credit_card');"
+												ng-class="paymentMethod.value === 'credit_card' ? 'payment-method-toggle-switch-selected' : ''"
+											)
+												i.fa.fa-cc-mastercard.fa-2x
+												span &nbsp;
+												i.fa.fa-cc-visa.fa-2x
+												span &nbsp;
+												i.fa.fa-cc-amex.fa-2x
+											a.payment-method-toggle-switch(
+												href
+												ng-click="setPaymentMethod('paypal');"
+												ng-class="paymentMethod.value === 'paypal' ? 'payment-method-toggle-switch-selected' : ''"
+											)
+												i.fa.fa-cc-paypal.fa-2x
+										
+										.alert.alert-warning.small(ng-show="genericError")
+											strong {{genericError}}
 
-									.row
-										.col-md-12
-											.form-group
-												.row
-													.col-md-6
-														label.radio-inline
-															input.paymentTypeOption(type="radio",value="credit_card", ng-model="paymentMethod")
-															i.fa.fa-cc-mastercard.fa-3x
-															span &nbsp;
-															i.fa.fa-cc-visa.fa-3x
-													.col-md-6
-														label.radio-inline
-															input.paymentTypeOption(type="radio", value="paypal", ng-model="paymentMethod")
-															i.fa.fa-cc-paypal.fa-3x
+										div(ng-if="paymentMethod.value === 'credit_card'")
+											.row
+												.col-xs-6
+													.form-group(ng-class="validation.errorFields.first_name || inputHasError(simpleCCForm.firstName) ? 'has-error' : ''")
+														label(for="first-name") #{translate('first_name')}
+														input#first-name.form-control(
+															type="text"
+															maxlength='255'
+															data-recurly="first_name"
+															name="firstName"
+															ng-model="data.first_name"
+															required
+														)
+														span.input-feedback-message  {{ simpleCCForm.firstName.$error.required ? 'This field is required' : '' }}
+												.col-xs-6
+													.form-group(for="last-name",ng-class="validation.errorFields.last_name || inputHasError(simpleCCForm.lastName)? 'has-error' : ''")
+														label(for="last-name") #{translate('last_name')}
+														input#last-name.form-control(
+															type="text"
+															maxlength='255'
+															data-recurly="last_name"
+															name="lastName"
+															ng-model="data.last_name"
+															required
+														)
+														span.input-feedback-message {{ simpleCCForm.lastName.$error.required ? 'This field is required' : '' }}
 
+											.form-group(ng-class="validation.correctCardNumber == false || validation.errorFields.number || inputHasError(simpleCCForm.ccNumber) ? 'has-error' : ''")
+												label(for="card-no") #{translate("credit_card_number")}
+												input#card-no.form-control(
+													type="text"
+													ng-model="data.number"
+													name="ccNumber"
+													ng-focus="validation.correctCardNumber = true; validation.errorFields.number = false;"
+													ng-blur="validateCardNumber();"
+													required
+													cc-format-card-number
+												)
+												span.input-feedback-message {{ simpleCCForm.ccNumber.$error.required ? 'This field is required' : 'Please re-check the card number' }}
 
+											.row
+												.col-xs-6
+													.form-group.has-feedback(ng-class="validation.correctExpiry == false || validation.errorFields.expiry || inputHasError(simpleCCForm.expiry) ? 'has-error' : ''")
+														label #{translate("expiry")}
+														input.form-control(
+															type="text"
+															ng-model="data.mmYY"
+															name="expiry"
+															placeholder="MM / YY"
+															ng-focus="validation.correctExpiry = true; validation.errorFields.expiry = false;"
+															ng-blur="updateExpiry(); validateExpiry()"
+															required
+															cc-format-expiry
+														)
+														span.input-feedback-message {{ simpleCCForm.expiry.$error.required ? 'This field is required' : 'Please re-check the expiry date' }}
 
-									.alert.alert-warning.small(ng-show="genericError")
-										strong {{genericError}}
+												.col-xs-6
+													.form-group.has-feedback(ng-class="validation.correctCvv == false || validation.errorFields.cvv || inputHasError(simpleCCForm.cvv) ? 'has-error' : ''")
+														label #{translate("security_code")}
+														input.form-control(
+															type="text"
+															ng-model="data.cvv"
+															ng-focus="validation.correctCvv = true; validation.errorFields.cvv = false;"
+															ng-blur="validateCvv()"
+															name="cvv"
+															required
+															cc-format-sec-code
+														)
+														.form-control-feedback
+															a.form-helper(
+																href
+																tabindex="-1"
+																tooltip-template="'cvv-tooltip-tpl.html'"
+																tooltip-trigger="mouseenter"
+																tooltip-append-to-body="true"
+															) ?
+														span.input-feedback-message {{ simpleCCForm.cvv.$error.required ? 'This field is required' : 'Please re-check the security code' }}
 
-									span(ng-hide="paymentMethod == 'paypal'")
-										.row
-											.col-md-12
-												.form-group
-													div.alert.alert-warning.small(ng-hide="validation.correctCvv") #{translate("invalid")} CVV
-													div.alert.alert-warning.small(ng-hide="validation.correctCardNumber") #{translate("invalid")} #{translate("credit_card_number")}
-										.row
-											.col-md-6
-												.form-group(ng-class="validation.number == false || validation.errorFields.number ? 'has-error' : ''")
-													input.form-control(ng-model='data.number', ng-blur="validateCardNumber()", placeholder="#{translate('credit_card_number')}")
-											.col-md-3
-												.form-group(ng-class="validation.correctCvv == false || validation.errorFields.cvv ? 'has-error' : ''")
-													input.form-control(ng-model='data.cvv', ng-blur="validateCvv()", placeholder="CVV")
-										.row
-											.col-md-12
-												div.alert.alert-warning.small(ng-hide="validation.correctExpiry") #{translate("invalid")} #{translate("expiry")}
-										.row
-											.col-md-3
-												.form-group(ng-class="validation.correctExpiry == false || validation.errorFields.month ? 'has-error' : ''")
-													select.form-control(data-recurly='month', ng-change="validateExpiry()", ng-model='data.month')
-														option(value="", disabled, selected) Month
-														option(value="01") 01
-														option(value="02") 02
-														option(value="03") 03
-														option(value="04") 04
-														option(value="05") 05
-														option(value="06") 06
-														option(value="07") 07
-														option(value="08") 08
-														option(value="09") 09
-														option(value="10") 10
-														option(value="11") 11
-														option(value="12") 12
-											.col-md-4
-												.form-group(ng-class="validation.correctExpiry == false || validation.errorFields.year ? 'has-error' : ''")
-													select.form-control(data-recurly='year', ng-change="validateExpiry()", ng-model='data.year')
-														option(value="", disabled, selected) Year
-														option(value="2016") 2016
-														option(value="2017") 2017
-														option(value="2018") 2018
-														option(value="2019") 2019
-														option(value="2020") 2020
-														option(value="2021") 2021
-														option(value="2022") 2022
-														option(value="2023") 2023
-														option(value="2024") 2024
-														option(value="2025") 2025
-														option(value="2026") 2026
-									.row
-										.col-md-6
-											.form-group(ng-class="validation.errorFields.first_name ? 'has-error' : ''")
-												input.form-control(type='text', value='', maxlength='255', , onkeyup='', data-recurly="first_name", ng-model="data.first_name", required, placeholder="#{translate('first_name')}")
-										.col-md-6
-											.form-group(ng-class="validation.errorFields.last_name ? 'has-error' : ''")
-												input.form-control(type='text', value='', maxlength='255',  onkeyup='', data-recurly="last_name", ng-model="data.last_name", required, placeholder="#{translate('last_name')}")
-									hr
-									.row
-										.col-md-12
-											.form-group(ng-class="validation.errorFields.address1 ? 'has-error' : ''")
-												label #{translate("billing_address")}
-												input.form-control(type='text', value='', maxlength='255',  onkeyup='', ng-model="data.address1", placeholder="#{translate('address')}")
-											.form-group(ng-class="validation.errorFields.address2 ? 'has-error' : ''")
-												input.form-control(type='text', value='', maxlength='255',  onkeyup='', ng-model="data.address2", placeholder="#{translate('address')}")
-											.form-group(ng-class="validation.errorFields.state ? 'has-error' : ''")
-												input.form-control(type='text', value='', maxlength='255',  onkeyup='', ng-model="data.state", placeholder="#{translate('state')}")
-									.row
-										.col-md-7
-											.form-group(ng-class="validation.errorFields.city ? 'has-error' : ''")
-												input.form-control(type='text', value='', maxlength='255',  onkeyup='', data-recurly="city", ng-model="data.city", placeholder="#{translate('city')}")
-										.col-md-5(ng-class="validation.errorFields.postal_code ? 'has-error' : ''")
-												input.form-control(type='text', value='', maxlength='255',  onkeyup='', data-recurly="postal_code", ng-model="data.postal_code", placeholder="#{translate('zip_post_code')}")
-									.row
-										.col-md-7
-											.form-group(ng-class="validation.errorFields.country ? 'has-error' : ''")
-												select.form-control(data-recurly="country", ng-model="data.country", ng-change="updateCountry()", required)
+										
+										div
+											.form-group(ng-class="validation.errorFields.country || inputHasError(simpleCCForm.country) ? 'has-error' : ''")
+												label(for="country") #{translate('country')}
+												select#country.form-control(
+													data-recurly="country"
+													ng-model="data.country"
+													name="country"
+													ng-change="updateCountry()"
+													required
+												)
 													mixin countries_options()
-									.row
-										.col-md-8
-											if (showCouponField)
-												.form-group
-													input.form-control(type='text', ng-blur="applyCoupon()", ng-model="data.coupon", placeholder="#{translate('coupon')}")
-									.row
-										.col-md-8
+												span.input-feedback-message {{ simpleCCForm.country.$error.required ? 'This field is required' : '' }}
+
 											if (showVatField)
 												.form-group
-													input.form-control(type='text', ng-blur="applyVatNumber()", ng-model="data.vat_number", placeholder="#{translate('vat_number')}")
-									.row
-										.col-xs-7
-											.form-group
-												button.btn.btn-success(ng-click="submit()", ng-disabled="processing", sixpack-convert="payment-left-menu-bottom") #{translate("upgrade_now")} 
-									
-										.col-xs-3.pricingBreakdown
-											div(ng-if="price.next.subtotal != price.next.total") Subtotal
-											div(ng-if="price.next.tax!='0.00'") Tax
-											div
-												strong Total
-										.col-xs-2
-											div(ng-if="price.next.subtotal != price.next.total").pull-right {{price.currency.symbol}}{{price.next.subtotal}}
-											div(ng-if="price.next.tax!='0.00'").pull-right {{price.currency.symbol}}{{price.next.tax}}
-											div.pull-right
-												strong {{price.currency.symbol}}{{price.next.total}}
+													label(for="vat-no") #{translate('vat_number')}
+													input#vat-no.form-control(
+														type="text"
+														ng-blur="applyVatNumber()"
+														ng-model="data.vat_number"
+													)
+											if (showCouponField)
+												.form-group
+													label(for="coupon-code") #{translate('coupon_code')}
+													input#coupon-code.form-control(
+														type="text"
+														ng-blur="applyCoupon()"
+														ng-model="data.coupon"
+													)
+											
+											p(ng-if="paymentMethod.value === 'paypal'") #{translate("paypal_upgrade")}
+											
+											div.price-breakdown(ng-if="price.next.tax !== '0.00'")
+												hr.thin
+												span Total: 
+												strong {{price.currency.symbol}}{{price.next.total}} 
+												span ({{price.currency.symbol}}{{price.next.subtotal}} + {{price.currency.symbol}}{{price.next.tax}} tax) 
+												span(ng-if="monthlyBilling") #{translate("every")} #{translate("month")}
+												span(ng-if="!monthlyBilling") #{translate("every")} #{translate("year")}
+												hr.thin
 
-				span(sixpack-switch="payment-left-menu-bottom")
+											div.payment-submit
+												button.btn.btn-success.btn-block(
+													ng-click="submit()"
+													ng-disabled="processing || !isFormValid(simpleCCForm);"
+												) 
+													span(ng-show="processing") 
+														i.fa.fa-spinner.fa-spin
+														| &nbsp;
+													| {{ paymentMethod.value === 'credit_card' ? '#{translate("upgrade_cc_btn")}' : '#{translate("upgrade_paypal_btn")}' }}
 
-					.col-md-3.col-md-pull-4(sixpack-default)
-						if showStudentPlan == 'true'
-							a.btn-primary.btn.plansPageStudentLink(
-								href,
-								ng-click="switchToStudent()"
-							) #{translate("half_price_student")} 
 
-						.card.card-first
-							.paymentPageFeatures
-								h3 #{translate("unlimited_projects")}
-								p #{translate("create_unlimited_projects")}
-							
-								h3
-									if plan.features.collaborators == -1
-										- var collaboratorCount = 'Unlimited'
-									else
-										- var collaboratorCount = plan.features.collaborators
-									| #{translate("collabs_per_proj", {collabcount:collaboratorCount})}
-								p #{translate("work_on_single_version")}. #{translate("view_collab_edits")} in real time.
-								
-								h3 #{translate("full_doc_history")}
-								p #{translate("see_what_has_been")} 
-									span.added #{translate("added")} 
-									| #{translate("and")} 
-									span.removed #{translate("removed")}. 
-									| #{translate("restore_to_any_older_version")}.
-								
-								h3 #{translate("sync_to_dropbox")}
-								p
-									| #{translate("acces_work_from_anywhere")}. 
-									| #{translate("work_offline_and_sync_with_dropbox")}.
-									
-								hr
-									
-								p.small.text-center We're confident that you'll love ShareLaTeX, but if not you can cancel anytime. We'll give you your money back, no questions asked, if you let us know within 30 days.
-								hr
-								span &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;
-								a(href="https://www.positivessl.com" style="font-family: arial; font-size: 10px; color: #212121; text-decoration: none;")
-									img(src="https://www.positivessl.com/images-new/PositiveSSL_tl_trans.png" alt="SSL Certificate" title="SSL Certificate" border="0")
-									div(style="font-family: arial;font-weight:bold;font-size:15px;color:#86BEE0;")
-										a(href="https://www.positivessl.com" style="color:#86BEE0; text-decoration: none;")
-					.col-md-3.col-md-pull-4(sixpack-when="bolder")
-						if showStudentPlan == 'true'
-							a.btn-primary.btn.plansPageStudentLink(
-								href,
-								ng-click="switchToStudent()"
-							) #{translate("half_price_student")} 
+				.col-md-3.col-md-pull-4
+					if showStudentPlan == 'true'
+						a.btn-primary.btn.plansPageStudentLink(
+							href,
+							ng-click="switchToStudent()"
+						) #{translate("half_price_student")} 
 
-						.card.card-first
-							.paymentPageFeatures
-								.page-header
-									h2 #{translate("features")}
-								h3 
-									i.fa.fa-check &nbsp;
-									| #{translate("unlimited_projects")}
+					.card.card-first
+						.paymentPageFeatures
+							h3 #{translate("unlimited_projects")}
+							p #{translate("create_unlimited_projects")}
+						
+							h3
+								if plan.features.collaborators == -1
+									- var collaboratorCount = 'Unlimited'
+								else
+									- var collaboratorCount = plan.features.collaborators
+								| #{translate("collabs_per_proj", {collabcount:collaboratorCount})}
+							p #{translate("work_on_single_version")}. #{translate("view_collab_edits")} in real time.
+							
+							h3 #{translate("full_doc_history")}
+							p #{translate("see_what_has_been")} 
+								span.added #{translate("added")} 
+								| #{translate("and")} 
+								span.removed #{translate("removed")}. 
+								| #{translate("restore_to_any_older_version")}.
 							
-								h3
-									i.fa.fa-check &nbsp;
-									if plan.features.collaborators == -1
-										- var collaboratorCount = 'Unlimited'
-									else
-										- var collaboratorCount = plan.features.collaborators
-									| #{translate("collabs_per_proj", {collabcount:collaboratorCount})}
+							h3 #{translate("sync_to_dropbox")}
+							p
+								| #{translate("acces_work_from_anywhere")}. 
+								| #{translate("work_offline_and_sync_with_dropbox")}.
 								
-								h3
-									i.fa.fa-check &nbsp;
-									|  #{translate("full_doc_history")}
+							hr
 								
-								h3 
-									i.fa.fa-check &nbsp;
-									| #{translate("sync_to_dropbox")}
-
-								h3
-									i.fa.fa-check &nbsp;
-									| #{translate("sync_to_github")}
-								h3
-									i.fa.fa-check &nbsp;
-									| #{translate("Compile Larger Projects")}								
-								hr
-									
-								h2.text-center 30 Day Guarantee
-								hr
-								span &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;
-								a(href="https://www.positivessl.com" style="font-family: arial; font-size: 10px; color: #212121; text-decoration: none;")
-									img(src="https://www.positivessl.com/images-new/PositiveSSL_tl_trans.png" alt="SSL Certificate" title="SSL Certificate" border="0")
-									div(style="font-family: arial;font-weight:bold;font-size:15px;color:#86BEE0;")
-										a(href="https://www.positivessl.com" style="color:#86BEE0; text-decoration: none;")
+							p.small.text-center We're confident that you'll love ShareLaTeX, but if not you can cancel anytime. We'll give you your money back, no questions asked, if you let us know within 30 days.
+							hr
+							span &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;
+							a(href="https://www.positivessl.com" style="font-family: arial; font-size: 10px; color: #212121; text-decoration: none;")
+								img(src="https://www.positivessl.com/images-new/PositiveSSL_tl_trans.png" alt="SSL Certificate" title="SSL Certificate" border="0")
+								div(style="font-family: arial;font-weight:bold;font-size:15px;color:#86BEE0;")
+									a(href="https://www.positivessl.com" style="color:#86BEE0; text-decoration: none;")
 
 
 	script(type="text/javascript").
 		ga('send', 'event', 'pageview', 'payment_form', "#{plan_code}")
 
+	script(
+		type="text/ng-template"
+		id="cvv-tooltip-tpl.html"
+	)
+		p For #[strong Visa, MasterCard and Discover], the #[strong 3 digits] on the #[strong back] of your card.
+		p For #[strong American Express], the #[strong 4 digits] on the #[strong front] of your card.
+
 mixin countries_options()
 	option(value='', disabled, selected) #{translate("country")}
 	option(value='-') --------------

+ 8 - 2
services/web/app/views/subscriptions/plans.jade

@@ -72,7 +72,10 @@ block content
 											li &nbsp;
 											li
 												br
-												a.btn.btn-info(href="/register") #{translate("sign_up_now")}
+												a.btn.btn-info(
+													href="/register"
+													style=(getLoggedInUserId() === undefined ? "" : "visibility: hidden")
+												) #{translate("sign_up_now")}
 								.col-md-4
 									.card.card-highlighted
 										.card-header
@@ -136,7 +139,10 @@ block content
 											li &nbsp;
 											li
 												br
-												a.btn.btn-info(href="/register") #{translate("sign_up_now")}
+												a.btn.btn-info(
+													href="/register"
+													style=(getLoggedInUserId() === undefined ? "" : "visibility: hidden")
+												) #{translate("sign_up_now")}
 								
 								.col-md-4
 									.card.card-highlighted

+ 7 - 3
services/web/app/views/user/register.jade

@@ -6,9 +6,13 @@ block content
 			.row
 				.registration_message
 					if sharedProjectData.user_first_name !== undefined
-						h1  #{translate("user_wants_you_to_see_project", {username:sharedProjectData.user_first_name, projectname:sharedProjectData.project_name})}
-						div #{translate("join_sl_to_view_project")}.
-							a(href="/login")  #{translate("login_here")}
+						h1  #{translate("user_wants_you_to_see_project", {username:sharedProjectData.user_first_name, projectname:""})}
+							em #{sharedProjectData.project_name}
+						div
+							| #{translate("join_sl_to_view_project")}.
+						div
+							| #{translate("if_you_are_registered")},
+							a(href="/login?redir=#{getReqQueryParam('redir')}")  #{translate("login_here")}
 					else if newTemplateData.templateName !== undefined
 						h1 #{translate("register_to_edit_template", {templateName:newTemplateData.templateName})}
 

+ 49 - 0
services/web/app/views/user/sessions.jade

@@ -0,0 +1,49 @@
+extends ../layout
+
+
+block scripts
+	script(type='text/javascript').
+		window.otherSessions = !{JSON.stringify(sessions)}
+
+
+block content
+	.content.content-alt
+		.container
+			.row
+				.col-md-10.col-md-offset-1.col-lg-8.col-lg-offset-2
+					.card.clear-user-sessions(ng-controller="ClearSessionsController", ng-cloak)
+						.page-header
+							h1  #{translate("your_sessions")}
+
+						div
+							p.small
+								| #{translate("clear_sessions_description")}
+
+						div
+							div(ng-if="state.otherSessions.length == 0")
+								p.text-center
+									| #{translate("no_other_sessions")}
+
+								div(ng-if="state.success == true")
+									p.text-success.text-center
+										| #{translate('clear_sessions_success')}
+
+							div(ng-if="state.otherSessions.length != 0")
+								table.table.table-striped
+									thead
+										tr
+											th #{translate("ip_address")}
+											th #{translate("session_created_at")}
+									tr(ng-repeat="session in state.otherSessions")
+										td {{session.ip_address}}
+										td {{session.session_created | formatDate}}
+
+								p.actions
+									.text-center
+										button.btn.btn-lg.btn-primary(
+											ng-click="clearSessions()"
+										) #{translate('clear_sessions')}
+
+										div(ng-if="state.error == true")
+											p.text-danger.error
+												| #{translate('generic_something_went_wrong')}

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

@@ -79,7 +79,7 @@ block content
 														required,
 														complex-password
 													)
-													span.small.text-primary(ng-show="changePasswordForm.newPassword1.$error.complexPassword && changePasswordForm.currentPassword.$dirty", ng-bind-html="complexPasswordErrorMessage") 
+													span.small.text-primary(ng-show="changePasswordForm.newPassword1.$error.complexPassword && changePasswordForm.newPassword1.$dirty", ng-bind-html="complexPasswordErrorMessage") 
 												.form-group
 													label(for='newPassword2') #{translate("confirm_new_password")}
 													input.form-control(
@@ -88,9 +88,11 @@ block content
 														placeholder='*********',
 														ng-model="newPassword2",
 														equals="passwordField"
-													)
-													span.small.text-primary(ng-show="changePasswordForm.newPassword2.$invalid && changePasswordForm.newPassword2.$dirty")
-														| #{translate("doesnt_match")}
+													) 
+													span.small.text-primary(ng-show="changePasswordForm.newPassword2.$error.areEqual && changePasswordForm.newPassword2.$dirty")
+														| #{translate("doesnt_match")} 
+													span.small.text-primary(ng-show="!changePasswordForm.newPassword2.$error.areEqual && changePasswordForm.newPassword2.$invalid && changePasswordForm.newPassword2.$dirty")
+														| #{translate("Invalid Password")} 
 												.actions
 													button.btn.btn-primary(
 														type='submit',
@@ -99,16 +101,25 @@ block content
 
 							| !{moduleIncludes("userSettings", locals)}
 
-							if (user.betaProgram)
-								hr
+							hr
 
-								h3
-									| #{translate("sharelatex_beta_program")}
+							h3
+								| #{translate("sharelatex_beta_program")}
 
+							if (user.betaProgram)
 								p.small
 									| #{translate("beta_program_already_participating")}
-								div
-									a(id="beta-program-participate-link" href="/beta/participate") #{translate("manage_beta_program_membership")}
+
+							div
+								a(id="beta-program-participate-link" href="/beta/participate") #{translate("manage_beta_program_membership")}
+
+							hr
+	
+							h3
+								| #{translate("sessions")}
+
+							div
+								a(id="sessions-link", href="/user/sessions") #{translate("manage_sessions")}
 
 							hr
 
@@ -140,7 +151,7 @@ block content
 		.modal-header
 			h3 #{translate("delete_account")}
 		.modal-body
-			p !{translate("delete_account_warning_message")}
+			p !{translate("delete_account_warning_message_2")}
 			form(novalidate, name="deleteAccountForm")
 				input.form-control(
 					type="text",

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

@@ -106,8 +106,8 @@ module.exports = settings =
 			url: ""
 		# references:
 		# 	url: "http://localhost:3040"
-		# notifications:
-		# 	url: "http://localhost:3042" 
+		notifications:
+			url: "http://localhost:3042" 
 			
 	templates:
 		user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"
@@ -116,7 +116,7 @@ module.exports = settings =
 
 	# cdn:
 	# 	web:
-	# 		host:"http://cdn.sharelatex.dev:3000"
+	# 		host:"http://nowhere.sharelatex.dev"
 	#		darkHost:"http://cdn.sharelatex.dev:3000"
 
 	# Where your instance of ShareLaTeX can be found publically. Used in emails
@@ -155,7 +155,7 @@ module.exports = settings =
 		collaborators: -1
 		dropbox: true
 		versioning: true
-		compileTimeout: 60
+		compileTimeout: 180
 		compileGroup: "standard"
 		references: true
 		templates: true
@@ -166,6 +166,8 @@ module.exports = settings =
 		price: 0
 		features: defaultFeatures
 	}]
+	
+	enableSubscriptions:false
 
 	# i18n
 	# ------
@@ -193,8 +195,8 @@ module.exports = settings =
 	# passwordStrengthOptions:
 	# 	pattern: "aA$3"
 	# 	length:
-	# 		min: 8
-	# 		max: 50
+	# 		min: 1
+	# 		max: 10
 
 	# Email support
 	# -------------

Разница между файлами не показана из-за своего большого размера
+ 176 - 171
services/web/npm-shrinkwrap.json


+ 5 - 0
services/web/package.json

@@ -46,6 +46,10 @@
     "nodemailer-sendgrid-transport": "^0.2.0",
     "nodemailer-ses-transport": "^1.3.0",
     "optimist": "0.6.1",
+    "passport": "^0.3.2",
+    "passport-local": "^1.0.0",
+    "pg": "^6.0.3",
+    "pg-hstore": "^2.3.2",
     "redback": "0.4.0",
     "redis": "0.10.1",
     "redis-sharelatex": "0.0.9",
@@ -53,6 +57,7 @@
     "requests": "^0.1.7",
     "rimraf": "2.2.6",
     "sanitizer": "0.1.1",
+    "sequelize": "^3.2.0",
     "settings-sharelatex": "git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0",
     "sixpack-client": "^1.0.0",
     "temp": "^0.8.3",

+ 5 - 0
services/web/public/coffee/directives/asyncForm.coffee

@@ -112,6 +112,8 @@ define [
 			[asyncFormCtrl, ngModelCtrl] = ctrl
 
 			ngModelCtrl.$parsers.unshift (modelValue) ->
+				
+			
 				isValid = passField.validatePass()
 				email = asyncFormCtrl.getEmail() || window.usersEmail
 				if !isValid
@@ -121,5 +123,8 @@ define [
 					if modelValue.indexOf(email) != -1 or modelValue.indexOf(startOfEmail) != -1
 						isValid = false
 						scope.complexPasswordErrorMessage = "Password can not contain email address"
+				if opts.length.max? and modelValue.length == opts.length.max
+					isValid = false
+					scope.complexPasswordErrorMessage = "Maximum password length #{opts.length.max} reached"
 				ngModelCtrl.$setValidity('complexPassword', isValid)
 				return modelValue

+ 539 - 0
services/web/public/coffee/directives/creditCards.coffee

@@ -0,0 +1,539 @@
+define [
+	"base"
+], (App) ->
+	App.factory 'ccUtils', () ->
+		defaultFormat = /(\d{1,4})/g;
+		defaultInputFormat =  /(?:^|\s)(\d{4})$/
+		
+		cards = [
+			# Credit cards
+			{
+				type: 'visa'
+				patterns: [4]
+				format: defaultFormat
+				length: [13, 16]
+				cvcLength: [3]
+				luhn: true
+			}
+			{
+				type: 'mastercard'
+				patterns: [
+					51, 52, 53, 54, 55,
+					22, 23, 24, 25, 26, 27
+				]
+				format: defaultFormat
+				length: [16]
+				cvcLength: [3]
+				luhn: true
+			}
+			{
+				type: 'amex'
+				patterns: [34, 37]
+				format: /(\d{1,4})(\d{1,6})?(\d{1,5})?/
+				length: [15]
+				cvcLength: [3..4]
+				luhn: true
+			}
+			{
+				type: 'dinersclub'
+				patterns: [30, 36, 38, 39]
+				format: /(\d{1,4})(\d{1,6})?(\d{1,4})?/
+				length: [14]
+				cvcLength: [3]
+				luhn: true
+			}
+			{
+				type: 'discover'
+				patterns: [60, 64, 65, 622]
+				format: defaultFormat
+				length: [16]
+				cvcLength: [3]
+				luhn: true
+			}
+			{
+				type: 'unionpay'
+				patterns: [62, 88]
+				format: defaultFormat
+				length: [16..19]
+				cvcLength: [3]
+				luhn: false
+			}
+			{
+				type: 'jcb'
+				patterns: [35]
+				format: defaultFormat
+				length: [16]
+				cvcLength: [3]
+				luhn: true
+			}
+		]
+
+		cardFromNumber = (num) ->
+			num = (num + '').replace(/\D/g, "")
+			for card in cards
+				for pattern in card.patterns
+					p = pattern + ""
+					return card if num.substr(0, p.length) == p
+
+		cardFromType = (type) ->
+			return card for card in cards when card.type is type
+
+		cardType = (num) ->
+			return null unless num
+			cardFromNumber(num)?.type or null
+
+		formatCardNumber = (num) ->
+			num = num.replace(/\D/g, '')
+			card = cardFromNumber(num)
+			return num unless card
+
+			upperLength = card.length[card.length.length - 1]
+			num = num[0...upperLength]
+
+			if card.format.global
+				num.match(card.format)?.join(' ')
+			else
+				groups = card.format.exec(num)
+				return unless groups?
+				groups.shift()
+				groups = $.grep(groups, (n) -> n) # Filter empty groups
+				groups.join(' ')
+
+		formatExpiry = (expiry) ->
+			parts = expiry.match(/^\D*(\d{1,2})(\D+)?(\d{1,4})?/)
+			return '' unless parts
+
+			mon = parts[1] || ''
+			sep = parts[2] || ''
+			year = parts[3] || ''
+
+			if year.length > 0
+				sep = ' / '
+
+			else if sep is ' /'
+				mon = mon.substring(0, 1)
+				sep = ''
+
+			else if mon.length == 2 or sep.length > 0
+				sep = ' / '
+
+			else if mon.length == 1 and mon not in ['0', '1']
+				mon = "0#{mon}"
+				sep = ' / '
+
+			return mon + sep + year
+
+		parseExpiry = (value = "") ->
+			[month, year] = value.split(/[\s\/]+/, 2)
+
+			# Allow for year shortcut
+			if year?.length is 2 and /^\d+$/.test(year)
+				prefix = (new Date).getFullYear()
+				prefix = prefix.toString()[0..1]
+				year   = prefix + year
+
+			month = parseInt(month, 10)
+			year  = parseInt(year, 10)
+
+			return unless !isNaN(month) and !isNaN(year)
+
+			month: month, year: year
+
+		return {
+			fromNumber: cardFromNumber
+			fromType: cardFromType
+			cardType: cardType 
+			formatExpiry: formatExpiry
+			formatCardNumber: formatCardNumber  
+			defaultFormat: defaultFormat
+			defaultInputFormat: defaultInputFormat
+			parseExpiry: parseExpiry
+		}
+
+	App.factory 'ccFormat', (ccUtils, $filter) ->
+		hasTextSelected = ($target) ->
+			# If some text is selected
+			return true if $target.prop('selectionStart')? and
+				$target.prop('selectionStart') isnt $target.prop('selectionEnd')
+
+			# If some text is selected in IE
+			if document?.selection?.createRange?
+				return true if document.selection.createRange().text
+
+			false
+
+		safeVal = (value, $target) ->
+			try
+				cursor = $target.prop('selectionStart')
+			catch error
+				cursor = null
+			
+			last = $target.val()
+			$target.val(value)
+
+			if cursor != null && $target.is(":focus")
+				cursor = value.length if cursor is last.length
+
+				# This hack looks for scenarios where we are changing an input's value such
+				# that "X| " is replaced with " |X" (where "|" is the cursor). In those
+				# scenarios, we want " X|".
+				#
+				# For example:
+				# 1. Input field has value "4444| "
+				# 2. User types "1"
+				# 3. Input field has value "44441| "
+				# 4. Reformatter changes it to "4444 |1"
+				# 5. By incrementing the cursor, we make it "4444 1|"
+				#
+				# This is awful, and ideally doesn't go here, but given the current design
+				# of the system there does not appear to be a better solution.
+				#
+				# Note that we can't just detect when the cursor-1 is " ", because that
+				# would incorrectly increment the cursor when backspacing, e.g. pressing
+				# backspace in this scenario: "4444 1|234 5".
+				if last != value
+					prevPair = last[cursor-1..cursor]
+					currPair = value[cursor-1..cursor]
+					digit = value[cursor]
+					cursor = cursor + 1 if /\d/.test(digit) and
+						prevPair == "#{digit} " and currPair == " #{digit}"
+
+				$target.prop('selectionStart', cursor)
+				$target.prop('selectionEnd', cursor)
+
+		# Replace Full-Width Chars
+		replaceFullWidthChars = (str = '') ->
+			fullWidth = '\uff10\uff11\uff12\uff13\uff14\uff15\uff16\uff17\uff18\uff19'
+			halfWidth = '0123456789'
+
+			value = ''
+			chars = str.split('')
+
+			# Avoid using reserved word `char`
+			for chr in chars
+				idx = fullWidth.indexOf(chr)
+				chr = halfWidth[idx] if idx > -1
+				value += chr
+
+			value
+
+		# Format Numeric
+		reFormatNumeric = (e) ->
+			$target = $(e.currentTarget)
+			setTimeout ->
+				value   = $target.val()
+				value   = replaceFullWidthChars(value)
+				value   = value.replace(/\D/g, '')
+				safeVal(value, $target)
+
+		# Format Card Number
+		reFormatCardNumber = (e) ->
+			$target = $(e.currentTarget)
+			setTimeout ->
+				value   = $target.val()
+				value   = replaceFullWidthChars(value)
+				value   = ccUtils.formatCardNumber(value)
+				safeVal(value, $target)
+
+		formatCardNumber = (e) ->
+			# Only format if input is a number
+			digit = String.fromCharCode(e.which)
+			return unless /^\d+$/.test(digit)
+
+			$target = $(e.currentTarget)
+			value   = $target.val()
+			card    = ccUtils.fromNumber(value + digit)
+			length  = (value.replace(/\D/g, '') + digit).length
+
+			upperLength = 16
+			upperLength = card.length[card.length.length - 1] if card
+			return if length >= upperLength
+
+			# Return if focus isn't at the end of the text
+			return if $target.prop('selectionStart')? and
+				$target.prop('selectionStart') isnt value.length
+
+			if card && card.type is 'amex'
+				# AMEX cards are formatted differently
+				re = /^(\d{4}|\d{4}\s\d{6})$/
+			else
+				re = /(?:^|\s)(\d{4})$/
+
+			# If '4242' + 4
+			if re.test(value)
+				e.preventDefault()
+				setTimeout -> $target.val(value + ' ' + digit)
+
+			# If '424' + 2
+			else if re.test(value + digit)
+				e.preventDefault()
+				setTimeout -> $target.val(value + digit + ' ')
+
+		formatBackCardNumber = (e) ->
+			$target = $(e.currentTarget)
+			value   = $target.val()
+
+			# Return unless backspacing
+			return unless e.which is 8
+
+			# Return if focus isn't at the end of the text
+			return if $target.prop('selectionStart')? and
+				$target.prop('selectionStart') isnt value.length
+
+			# Remove the digit + trailing space
+			if /\d\s$/.test(value)
+				e.preventDefault()
+				setTimeout -> $target.val(value.replace(/\d\s$/, ''))
+			# Remove digit if ends in space + digit
+			else if /\s\d?$/.test(value)
+				e.preventDefault()
+				setTimeout -> $target.val(value.replace(/\d$/, ''))
+
+		getFormattedCardNumber = (num) ->
+			num = num.replace(/\D/g, '')
+			card = ccUtils.fromNumber(num)
+			return num unless card
+
+			upperLength = card.length[card.length.length - 1]
+			num = num[0...upperLength]
+
+			if card.format.global
+				num.match(card.format)?.join(' ')
+			else
+				groups = card.format.exec(num)
+				return unless groups?
+				groups.shift()
+				groups = $.grep(groups, (n) -> n) # Filter empty groups
+				groups.join(' ')
+
+		parseCardNumber = (value) ->
+    		if value? then value.replace(/\s/g, '') else value
+
+		# Format Expiry
+		reFormatExpiry = (e) ->
+			$target = $(e.currentTarget)
+			setTimeout ->
+				value   = $target.val()
+				value   = replaceFullWidthChars(value)
+				value   = ccUtils.formatExpiry(value)
+				safeVal(value, $target)
+
+
+		formatExpiry = (e) ->
+			# Only format if input is a number
+			digit = String.fromCharCode(e.which)
+			return unless /^\d+$/.test(digit)
+
+			$target = $(e.currentTarget)
+			val     = $target.val() + digit
+
+			if /^\d$/.test(val) and val not in ['0', '1']
+				e.preventDefault()
+				setTimeout -> $target.val("0#{val} / ")
+
+			else if /^\d\d$/.test(val)
+				e.preventDefault()
+				setTimeout ->
+					# Split for months where we have the second digit > 2 (past 12) and turn
+					# that into (m1)(m2) => 0(m1) / (m2)
+					m1 = parseInt(val[0], 10)
+					m2 = parseInt(val[1], 10)
+					if m2 > 2 and m1 != 0
+						$target.val("0#{m1} / #{m2}")
+					else
+						$target.val("#{val} / ")
+
+		formatForwardExpiry = (e) ->
+			digit = String.fromCharCode(e.which)
+			return unless /^\d+$/.test(digit)
+
+			$target = $(e.currentTarget)
+			val     = $target.val()
+
+			if /^\d\d$/.test(val)
+				$target.val("#{val} / ")
+
+		formatForwardSlash = (e) ->
+			which = String.fromCharCode(e.which)
+			return unless which is '/' or which is ' '
+
+			$target = $(e.currentTarget)
+			val     = $target.val()
+
+			if /^\d$/.test(val) and val isnt '0'
+				$target.val("0#{val} / ")
+
+		formatBackExpiry = (e) ->
+			$target = $(e.currentTarget)
+			value   = $target.val()
+
+			# Return unless backspacing
+			return unless e.which is 8
+
+			# Return if focus isn't at the end of the text
+			return if $target.prop('selectionStart')? and
+				$target.prop('selectionStart') isnt value.length
+
+			# Remove the trailing space + last digit
+			if /\d\s\/\s$/.test(value)
+				e.preventDefault()
+				setTimeout -> $target.val(value.replace(/\d\s\/\s$/, ''))
+
+		parseExpiry = (value) ->
+			if value?
+				dateAsObj = ccUtils.parseExpiry(value)
+				
+				return unless dateAsObj?
+
+				expiry = new Date dateAsObj.year, dateAsObj.month - 1
+
+				return $filter('date')(expiry, 'MM/yyyy')
+
+		# Format CVC
+		reFormatCVC = (e) ->
+			$target = $(e.currentTarget)
+			setTimeout ->
+				value   = $target.val()
+				value   = replaceFullWidthChars(value)
+				value   = value.replace(/\D/g, '')[0...4]
+				safeVal(value, $target)
+
+		# Restrictions
+		restrictNumeric = (e) ->
+			# Key event is for a browser shortcut
+			return true if e.metaKey or e.ctrlKey
+
+			# If keycode is a space
+			return false if e.which is 32
+
+			# If keycode is a special char (WebKit)
+			return true if e.which is 0
+
+			# If char is a special char (Firefox)
+			return true if e.which < 33
+
+			input = String.fromCharCode(e.which)
+
+			# Char is a number or a space
+			!!/[\d\s]/.test(input)
+
+		restrictCardNumber = (e) ->
+			$target = $(e.currentTarget)
+			digit   = String.fromCharCode(e.which)
+			return unless /^\d+$/.test(digit)
+
+			return if hasTextSelected($target)
+
+			# Restrict number of digits
+			value = ($target.val() + digit).replace(/\D/g, '')
+			card  = ccUtils.fromNumber(value)
+
+			if card
+				value.length <= card.length[card.length.length - 1]
+			else
+				# All other cards are 16 digits long
+				value.length <= 16
+
+		restrictExpiry = (e) ->
+			$target = $(e.currentTarget)
+			digit   = String.fromCharCode(e.which)
+			return unless /^\d+$/.test(digit)
+
+			return if hasTextSelected($target)
+
+			value = $target.val() + digit
+			value = value.replace(/\D/g, '')
+
+			return false if value.length > 6
+
+		restrictCVC = (e) ->
+			$target = $(e.currentTarget)
+			digit   = String.fromCharCode(e.which)
+			return unless /^\d+$/.test(digit)
+
+			return if hasTextSelected($target)
+
+			val     = $target.val() + digit
+			val.length <= 4
+
+		setCardType = (e) ->
+			$target  = $(e.currentTarget)
+			val      = $target.val()
+			cardType = ccUtils.cardType(val) or 'unknown'
+
+			unless $target.hasClass(cardType)
+				allTypes = (card.type for card in cards)
+
+				$target.removeClass('unknown')
+				$target.removeClass(allTypes.join(' '))
+
+				$target.addClass(cardType)
+				$target.toggleClass('identified', cardType isnt 'unknown')
+				$target.trigger('payment.cardType', cardType)
+
+		return {
+			hasTextSelected
+			replaceFullWidthChars
+			reFormatNumeric
+			reFormatCardNumber
+			formatCardNumber
+			formatBackCardNumber
+			getFormattedCardNumber
+			parseCardNumber
+			reFormatExpiry
+			formatExpiry
+			formatForwardExpiry
+			formatForwardSlash
+			formatBackExpiry
+			parseExpiry
+			reFormatCVC
+			restrictNumeric
+			restrictCardNumber
+			restrictExpiry
+			restrictCVC
+			setCardType
+		}
+		
+	App.directive "ccFormatExpiry", (ccFormat) ->
+		restrict: "A"
+		require: "ngModel"
+		link: (scope, el, attrs, ngModel) ->
+			el.on "keypress", 	ccFormat.restrictNumeric
+			el.on "keypress", 	ccFormat.restrictExpiry
+			el.on "keypress", 	ccFormat.formatExpiry
+			el.on "keypress", 	ccFormat.formatForwardSlash
+			el.on "keypress", 	ccFormat.formatForwardExpiry
+			el.on "keydown", 	ccFormat.formatBackExpiry
+			el.on "change", 	ccFormat.reFormatExpiry
+			el.on "input", 		ccFormat.reFormatExpiry
+			el.on "paste", 		ccFormat.reFormatExpiry
+
+			ngModel.$parsers.push ccFormat.parseExpiry
+			ngModel.$formatters.push ccFormat.parseExpiry
+
+	App.directive "ccFormatCardNumber", (ccFormat) ->
+		restrict: "A"
+		require: "ngModel"
+		link: (scope, el, attrs, ngModel) ->
+			el.on "keypress", 	ccFormat.restrictNumeric
+			el.on "keypress", 	ccFormat.restrictCardNumber
+			el.on "keypress", 	ccFormat.formatCardNumber
+			el.on "keydown", 	ccFormat.formatBackCardNumber
+			el.on "paste", 		ccFormat.reFormatCardNumber
+
+			ngModel.$parsers.push ccFormat.parseCardNumber
+			ngModel.$formatters.push ccFormat.getFormattedCardNumber
+
+	App.directive "ccFormatSecCode", (ccFormat) ->
+		restrict: "A"
+		require: "ngModel"
+		link: (scope, el, attrs, ngModel) ->
+			el.on "keypress", 	ccFormat.restrictNumeric
+			el.on "keypress", 	ccFormat.restrictCVC
+			el.on "paste", 		ccFormat.reFormatCVC
+			el.on "change", 	ccFormat.reFormatCVC
+			el.on "input", 		ccFormat.reFormatCVC
+
+
+
+	

Некоторые файлы не были показаны из-за большого количества измененных файлов