diff --git a/services/web/.gitignore b/services/web/.gitignore index b03b4ad35d..f9bcc5cd9b 100644 --- a/services/web/.gitignore +++ b/services/web/.gitignore @@ -67,3 +67,5 @@ Gemfile.lock .DS_Store app/views/external + +modules diff --git a/services/web/Gruntfile.coffee b/services/web/Gruntfile.coffee index 75a8bc4c02..a0f0b3b92e 100644 --- a/services/web/Gruntfile.coffee +++ b/services/web/Gruntfile.coffee @@ -10,7 +10,7 @@ module.exports = (grunt) -> grunt.loadNpmTasks 'grunt-execute' grunt.loadNpmTasks 'grunt-bunyan' - grunt.initConfig + config = execute: app: src: "app.js" @@ -27,10 +27,6 @@ module.exports = (grunt) -> app: src: 'app.coffee' dest: 'app.js' - - BackgroundJobsWorker: - src: 'BackgroundJobsWorker.coffee' - dest: 'BackgroundJobsWorker.js' sharejs: options: @@ -163,6 +159,75 @@ module.exports = (grunt) -> "help" ] + moduleCompileServerTasks = [] + moduleCompileUnitTestTasks = [] + moduleUnitTestTasks = [] + moduleCompileClientTasks = [] + moduleIdeClientSideIncludes = [] + moduleMainClientSideIncludes = [] + if fs.existsSync "./modules" + for module in fs.readdirSync "./modules" + if fs.existsSync "./modules/#{module}/index.coffee" + config.coffee["module_#{module}_server"] = { + expand: true, + flatten: false, + cwd: "modules/#{module}/app/coffee", + src: ['**/*.coffee'], + dest: "modules/#{module}/app/js", + ext: '.js' + } + config.coffee["module_#{module}_index"] = { + src: "modules/#{module}/index.coffee", + dest: "modules/#{module}/index.js" + } + + moduleCompileServerTasks.push "coffee:module_#{module}_server" + moduleCompileServerTasks.push "coffee:module_#{module}_index" + + config.coffee["module_#{module}_unit_tests"] = { + expand: true, + flatten: false, + cwd: "modules/#{module}/test/unit/coffee", + src: ['**/*.coffee'], + dest: "modules/#{module}/test/unit/js", + ext: '.js' + } + config.mochaTest["module_#{module}_unit"] = { + src: ["modules/#{module}/test/unit/js/*.js"] + options: + reporter: grunt.option('reporter') or 'spec' + grep: grunt.option("grep") + } + + moduleCompileUnitTestTasks.push "coffee:module_#{module}_unit_tests" + moduleUnitTestTasks.push "mochaTest:module_#{module}_unit" + + if fs.existsSync "./modules/#{module}/public/coffee/ide/index.coffee" + config.coffee["module_#{module}_client_ide"] = { + expand: true, + flatten: false, + cwd: "modules/#{module}/public/coffee/ide", + src: ['**/*.coffee'], + dest: "public/js/ide/#{module}", + ext: '.js' + } + moduleCompileClientTasks.push "coffee:module_#{module}_client_ide" + moduleIdeClientSideIncludes.push "ide/#{module}/index" + + if fs.existsSync "./modules/#{module}/public/coffee/main/index.coffee" + config.coffee["module_#{module}_client_main"] = { + expand: true, + flatten: false, + cwd: "modules/#{module}/public/coffee/main", + src: ['**/*.coffee'], + dest: "public/js/main/#{module}", + ext: '.js' + } + moduleCompileClientTasks.push "coffee:module_#{module}_client_main" + moduleMainClientSideIncludes.push "main/#{module}/index" + + grunt.initConfig config + grunt.registerTask 'wrap_sharejs', 'Wrap the compiled ShareJS code for AMD module loading', () -> content = fs.readFileSync "public/js/libs/sharejs.js" fs.writeFileSync "public/js/libs/sharejs.js", """ @@ -174,8 +239,20 @@ module.exports = (grunt) -> grunt.registerTask 'help', 'Display this help list', 'availabletasks' - grunt.registerTask 'compile:server', 'Compile the server side coffee script', ['clean:app', 'coffee:app', 'coffee:app_dir'] - grunt.registerTask 'compile:client', 'Compile the client side coffee script', ['coffee:client', 'coffee:sharejs', 'wrap_sharejs'] + grunt.registerTask 'compile:modules:server', 'Compile all the modules', moduleCompileServerTasks + grunt.registerTask 'compile:modules:unit_tests', 'Compile all the modules unit tests', moduleCompileUnitTestTasks + grunt.registerTask 'compile:modules:client', 'Compile all the module client side code', moduleCompileClientTasks + grunt.registerTask 'compile:modules:inject_clientside_includes', () -> + content = fs.readFileSync("public/js/ide.js").toString() + content = content.replace(/, "__IDE_CLIENTSIDE_INCLUDES__"/g, moduleIdeClientSideIncludes.map((i) -> ", \"#{i}\"").join("")) + fs.writeFileSync "public/js/ide.js", content + + content = fs.readFileSync("public/js/main.js").toString() + content = content.replace(/, "__MAIN_CLIENTSIDE_INCLUDES__"/g, moduleMainClientSideIncludes.map((i) -> ", \"#{i}\"").join("")) + fs.writeFileSync "public/js/main.js", content + + grunt.registerTask 'compile:server', 'Compile the server side coffee script', ['clean:app', 'coffee:app', 'coffee:app_dir', 'compile:modules:server'] + grunt.registerTask 'compile:client', 'Compile the client side coffee script', ['coffee:client', 'coffee:sharejs', 'wrap_sharejs', "compile:modules:client", 'compile:modules:inject_clientside_includes'] grunt.registerTask 'compile:css', 'Compile the less files to css', ['less'] grunt.registerTask 'compile:minify', 'Concat and minify the client side js', ['requirejs'] grunt.registerTask 'compile:unit_tests', 'Compile the unit tests', ['clean:unit_tests', 'coffee:unit_tests'] @@ -187,6 +264,8 @@ module.exports = (grunt) -> grunt.registerTask 'test:unit', 'Run the unit tests (use --grep= or --feature= for individual tests)', ['compile:server', 'compile:unit_tests', 'mochaTest:unit'] grunt.registerTask 'test:smoke', 'Run the smoke tests', ['compile:smoke_tests', 'mochaTest:smoke'] + + grunt.registerTask 'test:modules:unit', 'Run the unit tests for the modules', ['compile:modules:server', 'compile:modules:unit_tests'].concat(moduleUnitTestTasks) grunt.registerTask 'run', "Compile and run the web-sharelatex server", ['compile', 'bunyan', 'execute'] grunt.registerTask 'default', 'run' diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee index bb323ce052..184d892d4c 100644 --- a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee +++ b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee @@ -50,6 +50,10 @@ module.exports = AuthenticationController = callback null, null getLoggedInUser: (req, options = {allow_auth_token: false}, callback = (error, user) ->) -> + if typeof(options) == "function" + callback = options + options = {allow_auth_token: false} + if req.session?.user?._id? query = req.session.user._id else if req.query?.auth_token? and options.allow_auth_token diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index 21179a0deb..cdc21e5cec 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -58,6 +58,10 @@ module.exports = EditorController = # can be done affter the connection has happened ConnectedUsersManager.updateUserPosition project_id, client.id, user, null, -> + + # Only show the 'renamed or deleted' message once + ProjectDeleter.unmarkAsDeletedByExternalSource project + leaveProject: (client, user) -> self = @ diff --git a/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee b/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee index d5030cffaa..8b1a7b6e2c 100644 --- a/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee +++ b/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee @@ -34,9 +34,10 @@ module.exports = setNewUserPassword: (req, res)-> {passwordResetToken, password} = req.body if !password? or password.length == 0 or !passwordResetToken? or passwordResetToken.length == 0 - return res.send 500 - PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err)-> - if err? - res.send 500 + return res.send 400 + PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found) -> + return next(err) if err? + if found + res.send 200 else - res.send 200 \ No newline at end of file + res.send 404, {message: req.i18n.translate("password_reset_token_expired")} \ No newline at end of file diff --git a/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee b/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee index eee8d51a72..16f0cbbe43 100644 --- a/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee +++ b/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee @@ -23,10 +23,11 @@ module.exports = return callback(error) if error? callback null, true - setNewUserPassword: (token, password, callback)-> + setNewUserPassword: (token, password, callback = (error, found) ->)-> PasswordResetTokenHandler.getUserIdFromTokenAndExpire token, (err, user_id)-> if err then return callback(err) if !user_id? - logger.err user_id:user_id, "token for password reset did not find user_id" - return callback("no user found") - AuthenticationManager.setUserPassword user_id, password, callback \ No newline at end of file + return callback null, false + AuthenticationManager.setUserPassword user_id, password, (err) -> + if err then return callback(err) + callback null, true \ No newline at end of file diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index dc6508be04..512fe00dba 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -128,6 +128,8 @@ module.exports = ProjectController = Project.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref', cb hasSubscription: (cb)-> LimitationsManager.userHasSubscriptionOrIsGroupMember req.session.user, cb + user: (cb) -> + User.findById user_id, "featureSwitches", cb }, (err, results)-> if err? logger.err err:err, "error getting data for project list page" @@ -135,6 +137,7 @@ module.exports = ProjectController = logger.log results:results, user_id:user_id, "rendering project list" tags = results.tags[0] projects = ProjectController._buildProjectList results.projects[0], results.projects[1], results.projects[2] + user = results.user ProjectController._injectProjectOwners projects, (error, projects) -> return next(error) if error? @@ -143,6 +146,7 @@ module.exports = ProjectController = priority_title: true projects: projects tags: tags + user: user hasSubscription: results.hasSubscription } @@ -212,6 +216,7 @@ module.exports = ProjectController = referal_id : user.referal_id subscription : freeTrial: {allowed: allowedFreeTrial} + featureSwitches: user.featureSwitches } userSettings: { mode : user.ace.mode @@ -283,8 +288,7 @@ defaultSettingsForAnonymousUser = (user_id)-> freeTrial: allowed: true featureSwitches: - dropbox: false - trackChanges: false + github: false THEME_LIST = [] do generateThemeList = () -> diff --git a/services/web/app/coffee/Features/Project/ProjectDeleter.coffee b/services/web/app/coffee/Features/Project/ProjectDeleter.coffee index dfe7592fb3..9a7decec28 100644 --- a/services/web/app/coffee/Features/Project/ProjectDeleter.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDeleter.coffee @@ -7,7 +7,7 @@ FileStoreHandler = require("../FileStore/FileStoreHandler") module.exports = ProjectDeleter = - markAsDeletedByExternalSource : (project_id, callback)-> + markAsDeletedByExternalSource : (project_id, callback = (error) ->)-> logger.log project_id:project_id, "marking project as deleted by external data source" conditions = {_id:project_id} update = {deletedByExternalDataSource:true} @@ -15,6 +15,15 @@ module.exports = ProjectDeleter = Project.update conditions, update, {}, (err)-> require('../Editor/EditorController').notifyUsersProjectHasBeenDeletedOrRenamed project_id, -> callback() + + unmarkAsDeletedByExternalSource: (project, callback = (error) ->) -> + logger.log project_id: "removing flag marking project as deleted by external data source" + if project.deletedByExternalDataSource + conditions = {_id:project._id.toString()} + update = {deletedByExternalDataSource: false} + Project.update conditions, update, {}, callback + else + callback() deleteUsersProjects: (owner_id, callback)-> logger.log owner_id:owner_id, "deleting users projects" diff --git a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsController.coffee b/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsController.coffee index 1d3c16d8e2..15e2179053 100644 --- a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsController.coffee +++ b/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsController.coffee @@ -1,4 +1,5 @@ tpdsUpdateHandler = require('./TpdsUpdateHandler') +UpdateMerger = require "./UpdateMerger" logger = require('logger-sharelatex') Path = require('path') metrics = require("../../infrastructure/Metrics") @@ -31,6 +32,24 @@ module.exports = logger.log user_id:user_id, filePath:filePath, projectName:projectName, "telling tpds delete has been processed" res.send 200 req.session.destroy() + + updateProjectContents: (req, res, next = (error) ->) -> + {project_id} = req.params + path = "/" + req.params[0] # UpdateMerger expects leading slash + logger.log project_id: project_id, path: path, "received project contents update" + UpdateMerger.mergeUpdate project_id, path, req, (error) -> + return next(error) if error? + res.send(200) + req.session.destroy() + + deleteProjectContents: (req, res, next = (error) ->) -> + {project_id} = req.params + path = "/" + req.params[0] # UpdateMerger expects leading slash + logger.log project_id: project_id, path: path, "received project contents delete request" + UpdateMerger.deleteUpdate project_id, path, (error) -> + return next(error) if error? + res.send(200) + req.session.destroy() parseParams: parseParams = (req)-> path = req.params[0] diff --git a/services/web/app/coffee/Features/User/UserPagesController.coffee b/services/web/app/coffee/Features/User/UserPagesController.coffee index a99633529d..20da921c28 100644 --- a/services/web/app/coffee/Features/User/UserPagesController.coffee +++ b/services/web/app/coffee/Features/User/UserPagesController.coffee @@ -33,8 +33,7 @@ module.exports = dropboxHandler.getUserRegistrationStatus user._id, (err, status)-> userIsRegisteredWithDropbox = !err? and status.registered res.render 'user/settings', - title:'account_settings', - userHasDropboxFeature: user.features.dropbox + title:'account_settings' userIsRegisteredWithDropbox: userIsRegisteredWithDropbox user: user, languages: Settings.languages, diff --git a/services/web/app/coffee/infrastructure/ExpressLocals.coffee b/services/web/app/coffee/infrastructure/ExpressLocals.coffee index 89e5de7d65..cb3e7837aa 100644 --- a/services/web/app/coffee/infrastructure/ExpressLocals.coffee +++ b/services/web/app/coffee/infrastructure/ExpressLocals.coffee @@ -6,6 +6,7 @@ SubscriptionFormatters = require('../Features/Subscription/SubscriptionFormatter querystring = require('querystring') SystemMessageManager = require("../Features/SystemMessages/SystemMessageManager") _ = require("underscore") +Modules = require "./Modules" fingerprints = {} Path = require 'path' @@ -146,3 +147,9 @@ module.exports = (app)-> res.locals.currentLngCode = req.lng next() + app.use (req, res, next) -> + if Settings.reloadModuleViewsOnEachRequest + Modules.loadViewIncludes() + res.locals.moduleIncludes = Modules.moduleIncludes + next() + diff --git a/services/web/app/coffee/infrastructure/Modules.coffee b/services/web/app/coffee/infrastructure/Modules.coffee new file mode 100644 index 0000000000..4c45e488e1 --- /dev/null +++ b/services/web/app/coffee/infrastructure/Modules.coffee @@ -0,0 +1,36 @@ +fs = require "fs" +Path = require "path" +jade = require "jade" + +MODULE_BASE_PATH = Path.resolve(__dirname + "/../../../modules") + +module.exports = Modules = + modules: [] + loadModules: () -> + for moduleName in fs.readdirSync(MODULE_BASE_PATH) + if fs.existsSync(Path.join(MODULE_BASE_PATH, moduleName, "index.js")) + loadedModule = require(Path.join(MODULE_BASE_PATH, moduleName, "index")) + loadedModule.name = moduleName + @modules.push loadedModule + + applyRouter: (app) -> + for module in @modules + module.router?.apply(app) + + viewIncludes: {} + loadViewIncludes: (app) -> + @viewIncludes = {} + 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")) + + moduleIncludes: (view, locals) -> + partials = Modules.viewIncludes[view] or [] + html = "" + for partial in partials + compiler = jade.compile(partial, doctype: "html") + html += compiler(locals) + return html + +Modules.loadModules() \ No newline at end of file diff --git a/services/web/app/coffee/infrastructure/Server.coffee b/services/web/app/coffee/infrastructure/Server.coffee index 79a7cb443e..45d1ad889b 100644 --- a/services/web/app/coffee/infrastructure/Server.coffee +++ b/services/web/app/coffee/infrastructure/Server.coffee @@ -24,6 +24,7 @@ ReferalConnect = require('../Features/Referal/ReferalConnect') RedirectManager = require("./RedirectManager") OldAssetProxy = require("./OldAssetProxy") translations = require("translations-sharelatex").setup(Settings.i18n) +Modules = require "./Modules" 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) @@ -46,13 +47,13 @@ app.ignoreCsrf = (method, route) -> ignoreCsrfRoutes.push new express.Route(method, route) - app.configure () -> if Settings.behindProxy app.enable('trust proxy') app.use express.static(__dirname + '/../../../public', {maxAge: staticCacheAge }) app.set 'views', __dirname + '/../../views' app.set 'view engine', 'jade' + Modules.loadViewIncludes app app.use express.bodyParser(uploadDir: Settings.path.uploadFolder) app.use express.bodyParser(uploadDir: __dirname + "/../../../data/uploads") app.use translations.expressMiddlewear diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee index 83cf5eaf50..7e11f3bc18 100644 --- a/services/web/app/coffee/models/User.coffee +++ b/services/web/app/coffee/models/User.coffee @@ -33,8 +33,7 @@ UserSchema = new Schema dropbox: { type:Boolean, default: Settings.defaultFeatures.dropbox } } featureSwitches : { - dropbox: {type:Boolean, default:true}, - oldHistory: {type:Boolean} + github: {type: Boolean} } referal_id : {type:String, default:() -> uuid.v4().split("-")[0]} refered_users: [ type:ObjectId, ref:'User' ] diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 642744049a..31932fdc23 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -39,6 +39,7 @@ WikiController = require("./Features/Wiki/WikiController") ConnectedUsersController = require("./Features/ConnectedUsers/ConnectedUsersController") DropboxRouter = require "./Features/Dropbox/DropboxRouter" dropboxHandler = require "./Features/Dropbox/DropboxHandler" +Modules = require "./infrastructure/Modules" logger = require("logger-sharelatex") _ = require("underscore") @@ -67,6 +68,8 @@ module.exports = class Router StaticPagesRouter.apply(app) TemplatesRouter.apply(app) DropboxRouter.apply(app) + + Modules.applyRouter(app) app.get '/blog', BlogController.getIndexPage app.get '/blog/*', BlogController.getPage @@ -154,6 +157,11 @@ module.exports = class Router app.del '/user/:user_id/update/*', httpAuth, TpdsController.deleteUpdate app.ignoreCsrf('post', '/user/:user_id/update/*') app.ignoreCsrf('delete', '/user/:user_id/update/*') + + app.post '/project/:project_id/contents/*', httpAuth, TpdsController.updateProjectContents + app.del '/project/:project_id/contents/*', httpAuth, TpdsController.deleteProjectContents + app.ignoreCsrf('post', '/project/:project_id/contents/*') + app.ignoreCsrf('delete', '/project/:project_id/contents/*') app.post "/spelling/check", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi app.post "/spelling/learn", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi diff --git a/services/web/app/views/project/editor/left-menu.jade b/services/web/app/views/project/editor/left-menu.jade index 8b4d46fc4e..af5edf2686 100644 --- a/services/web/app/views/project/editor/left-menu.jade +++ b/services/web/app/views/project/editor/left-menu.jade @@ -46,14 +46,16 @@ aside#left-menu.full-size( i.fa.fa-external-link.fa-fw |    #{translate("publish_as_template")} - - span(ng-controller="DropboxController", ng-show="permissions.admin") + div(ng-show="permissions.admin") h4() #{translate("sync")} - ul.list-unstyled.nav() - li - a(ng-click="openDropboxModal()") - i.fa.fa-dropbox.fa-fw - |    Dropbox + span(ng-controller="DropboxController") + ul.list-unstyled.nav() + li + a(ng-click="openDropboxModal()") + i.fa.fa-dropbox.fa-fw + |    Dropbox + + !{moduleIncludes("editorLeftMenu", locals)} h4(ng-show="!anonymous") #{translate("settings")} form.settings(ng-controller="SettingsController", ng-show="!anonymous") diff --git a/services/web/app/views/project/list/side-bar.jade b/services/web/app/views/project/list/side-bar.jade index 3da80de978..15fa928ddd 100644 --- a/services/web/app/views/project/list/side-bar.jade +++ b/services/web/app/views/project/list/side-bar.jade @@ -21,6 +21,7 @@ href, ng-click="openUploadProjectModal()" ) #{translate("upload_project")} + !{moduleIncludes("newProjectMenu", locals)} if (templates) li.divider li.dropdown-header #{translate("templates")} diff --git a/services/web/app/views/user/settings.jade b/services/web/app/views/user/settings.jade index 3cf36a5012..fe79a32c29 100644 --- a/services/web/app/views/user/settings.jade +++ b/services/web/app/views/user/settings.jade @@ -96,22 +96,30 @@ block content ng-disabled="changePasswordForm.$invalid" ) #{translate("change")} - hr.soften + hr h3 #{translate("dropbox_integration")} span.small a(href='/help/kb/dropbox-2') (#{translate("learn_more")}) - - if(!userHasDropboxFeature) - .alert.alert-info #{translate("dropbox_is_premium")}     - a.btn.btn-info(href='/user/subscription/plans') #{translate("upgrade")} - - else if(userIsRegisteredWithDropbox) - .alert.alert-success #{translate("account_is_linked")} - row - a(href='/dropbox/unlink').btn #{translate("unlink_dropbox")} - - else - a.btn.btn-info(href='/dropbox/beginAuth') #{translate("link_to_dropbox")} + - if(!user.features.dropbox) + p.small #{translate("dropbox_sync_description")} + .alert.alert-info + p #{translate("dropbox_is_premium")} + p + a.btn.btn-info(href='/user/subscription/plans') #{translate("upgrade")} + - else if(userIsRegisteredWithDropbox) + .alert.alert-success + | #{translate("account_is_linked")}. + | + a(href='/dropbox/unlink') #{translate("unlink_dropbox")} + - else + p.small #{translate("dropbox_sync_description")} + p + a.btn.btn-info(href='/dropbox/beginAuth') #{translate("link_to_dropbox")} + + | !{moduleIncludes("userSettings", locals)} - hr.soften + hr p.small | #{translate("newsletter_info_and_unsubscribe")} diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 4ed3a020b3..b742123720 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -90,6 +90,8 @@ module.exports = url: "http://localhost:3013" templates: url: "http://localhost:3007" + githubSync: + url: "http://localhost:3022" recurly: privateKey: "" apiKey: "" @@ -304,4 +306,5 @@ module.exports = "/templates/index": "/templates/" proxyUrls: {} - + + reloadModuleViewsOnEachRequest: true diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 95ea922da5..9446d7f87d 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -17,6 +17,7 @@ define [ "ide/hotkeys/index" "ide/directives/layout" "ide/services/ide" + "__IDE_CLIENTSIDE_INCLUDES__" "analytics/AbTestingManager" "directives/focus" "directives/fineUpload" diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee index 86e1637094..fdfe5a86b7 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee @@ -78,6 +78,34 @@ define [ readOnly: true editor.commands.removeCommand "replace" + # Bold text on CMD+B + editor.commands.addCommand + name: "bold", + bindKey: win: "Ctrl-B", mac: "Command-B" + exec: (editor) -> + selection = editor.getSelection() + if selection.isEmpty() + editor.insert("\\textbf{}") + editor.navigateLeft(1) + else + text = editor.getCopyText() + editor.insert("\\textbf{" + text + "}") + readOnly: false + + # Italicise text on CMD+I + editor.commands.addCommand + name: "italics", + bindKey: win: "Ctrl-I", mac: "Command-I" + exec: (editor) -> + selection = editor.getSelection() + if selection.isEmpty() + editor.insert("\\textit{}") + editor.navigateLeft(1) + else + text = editor.getCopyText() + editor.insert("\\textit{" + text + "}") + readOnly: false + scope.$watch "onCtrlEnter", (callback) -> if callback? editor.commands.addCommand diff --git a/services/web/public/coffee/main.coffee b/services/web/public/coffee/main.coffee index 683fb23b4f..9998979a63 100644 --- a/services/web/public/coffee/main.coffee +++ b/services/web/public/coffee/main.coffee @@ -22,5 +22,6 @@ define [ "directives/selectAll" "directives/maxHeight" "filters/formatDate" + "__MAIN_CLIENTSIDE_INCLUDES__" ], () -> angular.bootstrap(document.body, ["SharelatexApp"]) diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index 2b173ace71..90d2f98e40 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -196,11 +196,12 @@ define [ $scope.createTag = (name) -> event_tracking.send 'project-list-page-interaction', 'project action', 'createTag' - $scope.tags.push { + $scope.tags.push tag = { name: name project_ids: [] showWhenEmpty: true } + return tag $scope.openNewTagModal = (e) -> modalInstance = $modal.open( @@ -210,11 +211,8 @@ define [ modalInstance.result.then( (newTagName) -> - $scope.createTag(newTagName) - console.log $scope.tag, $scope.addSelectedProjectsToTag, newTagName - $scope.addSelectedProjectsToTag($scope.tag) - - # add selected projects to this new tag + tag = $scope.createTag(newTagName) + $scope.addSelectedProjectsToTag(tag) ) $scope.createProject = (name, template = "none") -> diff --git a/services/web/public/stylesheets/app/editor.less b/services/web/public/stylesheets/app/editor.less index 6c0da22dc5..71b3cde0aa 100644 --- a/services/web/public/stylesheets/app/editor.less +++ b/services/web/public/stylesheets/app/editor.less @@ -159,15 +159,16 @@ position: absolute; z-index: 2; } - .highlights-before-label { + .highlights-before-label, .highlights-after-label { position: absolute; - top: @line-height-computed / 2; right: @line-height-computed; + z-index: 1; + } + .highlights-before-label { + top: @line-height-computed / 2; } .highlights-after-label { - position: absolute; bottom: @line-height-computed / 2; - right: @line-height-computed; } } diff --git a/services/web/public/stylesheets/app/recurly.less b/services/web/public/stylesheets/app/recurly.less index bdeb5bc336..c953f34bd1 100644 --- a/services/web/public/stylesheets/app/recurly.less +++ b/services/web/public/stylesheets/app/recurly.less @@ -22,17 +22,14 @@ } .recurly .plan { color: #333; - overflow: hidden; - position: relative; - zoom: 1; font-family: @font-family-serif; + padding: 0 20px 40px; + position: relative; + margin-top: -20px; } .recurly .plan .name { - float: left; font-size: 32px; - min-width: 200px; - padding-left: 20px; - padding-right: 40px; + margin-right: 130px; } .recurly .plan .quantity.field { clear: none; @@ -56,9 +53,10 @@ color: #666; } .recurly .plan .recurring_cost { - float: right; text-align: right; - padding-right: 20px; + position: absolute; + right: 20px; + top: 0; } .recurly .plan .recurring_cost .cost { font-size: 32px; @@ -68,14 +66,7 @@ padding-bottom: 20px; } .recurly .free_trial { - clear: left; - float: left; font-size: 13px; - height: 22px; - margin: 0; - position: absolute; - top: 35px; - left: 20px; font-style: italic; } .recurly .setup_fee { diff --git a/services/web/test/UnitTests/coffee/Editor/EditorControllerTests.coffee b/services/web/test/UnitTests/coffee/Editor/EditorControllerTests.coffee index 50f65f054b..d7e754605a 100644 --- a/services/web/test/UnitTests/coffee/Editor/EditorControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Editor/EditorControllerTests.coffee @@ -89,6 +89,7 @@ describe "EditorController", -> @AuthorizationManager.setPrivilegeLevelOnClient = sinon.stub() @EditorRealTimeController.emitToRoom = sinon.stub() @ConnectedUsersManager.updateUserPosition.callsArgWith(4) + @ProjectDeleter.unmarkAsDeletedByExternalSource = sinon.stub() describe "when authorized", -> beforeEach -> @@ -122,8 +123,12 @@ describe "EditorController", -> it "should mark the user as connected with the ConnectedUsersManager", -> @ConnectedUsersManager.updateUserPosition.calledWith(@project_id, @client.id, @user, null).should.equal true - - + + it "should remove the flag to send a user a message about the project being deleted", -> + @ProjectDeleter.unmarkAsDeletedByExternalSource + .calledWith(@project) + .should.equal true + describe "when not authorized", -> beforeEach -> @AuthorizationManager.getPrivilegeLevelForProject = diff --git a/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee b/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee index c7665e0546..a6590b31da 100644 --- a/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee @@ -87,34 +87,34 @@ describe "PasswordResetController", -> describe "setNewUserPassword", -> it "should tell the user handler to reset the password", (done)-> - @PasswordResetHandler.setNewUserPassword.callsArgWith(2) + @PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, true) @res.send = (code)=> code.should.equal 200 @PasswordResetHandler.setNewUserPassword.calledWith(@token, @password).should.equal true done() @PasswordResetController.setNewUserPassword @req, @res - it "should send a 500 if there is an error", (done)-> - @PasswordResetHandler.setNewUserPassword.callsArgWith(2, "error") + it "should send 404 if the token didn't work", (done)-> + @PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, false) @res.send = (code)=> - code.should.equal 500 + code.should.equal 404 done() @PasswordResetController.setNewUserPassword @req, @res - it "should error if there is no password", (done)-> + it "should return 400 (Bad Request) if there is no password", (done)-> @req.body.password = "" @PasswordResetHandler.setNewUserPassword.callsArgWith(2) @res.send = (code)=> - code.should.equal 500 + code.should.equal 400 @PasswordResetHandler.setNewUserPassword.called.should.equal false done() @PasswordResetController.setNewUserPassword @req, @res - it "should error if there is no password", (done)-> + it "should return 400 (Bad Request) if there is no passwordResetToken", (done)-> @req.body.passwordResetToken = "" @PasswordResetHandler.setNewUserPassword.callsArgWith(2) @res.send = (code)=> - code.should.equal 500 + code.should.equal 400 @PasswordResetHandler.setNewUserPassword.called.should.equal false done() @PasswordResetController.setNewUserPassword @req, @res diff --git a/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetHandlerTests.coffee b/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetHandlerTests.coffee index ce5aaa0f75..4e614b855b 100644 --- a/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetHandlerTests.coffee @@ -63,17 +63,18 @@ describe "PasswordResetHandler", -> describe "setNewUserPassword", -> - it "should return err if no user id can be found", (done)-> + it "should return false if no user id can be found", (done)-> @PasswordResetTokenHandler.getUserIdFromTokenAndExpire.callsArgWith(1) - @PasswordResetHandler.setNewUserPassword @token, @password, (err)=> - err.should.exists + @PasswordResetHandler.setNewUserPassword @token, @password, (err, found) => + found.should.equal false @AuthenticationManager.setUserPassword.called.should.equal false done() it "should set the user password", (done)-> @PasswordResetTokenHandler.getUserIdFromTokenAndExpire.callsArgWith(1, null, @user_id) @AuthenticationManager.setUserPassword.callsArgWith(2) - @PasswordResetHandler.setNewUserPassword @token, @password, (err)=> + @PasswordResetHandler.setNewUserPassword @token, @password, (err, found) => + found.should.equal true @AuthenticationManager.setUserPassword.calledWith(@user_id, @password).should.equal true done() diff --git a/services/web/test/UnitTests/coffee/Project/ProjectControllerTests.coffee b/services/web/test/UnitTests/coffee/Project/ProjectControllerTests.coffee index 25ea9ec75e..77bfcebd35 100644 --- a/services/web/test/UnitTests/coffee/Project/ProjectControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Project/ProjectControllerTests.coffee @@ -198,8 +198,8 @@ describe "ProjectController", -> first_name: 'James' 'user-2': first_name: 'Henry' + @users[@user._id] = @user # Owner @UserModel.findById = (id, fields, callback) => - fields.should.equal 'first_name last_name' callback null, @users[id] @LimitationsManager.userHasSubscriptionOrIsGroupMember.callsArgWith(1, null, false) @@ -224,6 +224,12 @@ describe "ProjectController", -> done() @ProjectController.projectListPage @req, @res + it "should send the user", (done)-> + @res.render = (pageName, opts)=> + opts.user.should.deep.equal @user + done() + @ProjectController.projectListPage @req, @res + it "should inject the users", (done) -> @res.render = (pageName, opts)=> opts.projects[0].owner.should.equal (@users[@projects[0].owner_ref]) @@ -275,6 +281,7 @@ describe "ProjectController", -> @UserModel.findById.callsArgWith(1, null, @user) @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, {}) @SecurityManager.userCanAccessProject.callsArgWith 2, true, "owner" + @ProjectDeleter.unmarkAsDeletedByExternalSource = sinon.stub() it "should render the project/editor page", (done)-> @res.render = (pageName, opts)=> diff --git a/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee b/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee index 978a14f024..99f2a5352b 100644 --- a/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee +++ b/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee @@ -30,6 +30,7 @@ describe 'Project deleter', -> '../../models/Project':{Project:@Project} '../DocumentUpdater/DocumentUpdaterHandler': @documentUpdaterHandler "../Tags/TagsHandler":@TagsHandler + "../FileStore/FileStoreHandler": @FileStoreHandler = {} 'logger-sharelatex': log:-> @@ -46,6 +47,32 @@ describe 'Project deleter', -> @deleter.markAsDeletedByExternalSource project_id, => @editorController.notifyUsersProjectHasBeenDeletedOrRenamed.calledWith(project_id).should.equal true done() + + describe "unmarkAsDeletedByExternalSource", -> + beforeEach -> + @Project.update = sinon.stub().callsArg(3) + @callback = sinon.stub() + @project = { + _id: @project_id + } + + describe "when the project does not have the flag set", -> + beforeEach -> + @project.deletedByExternalDataSource = false + @deleter.unmarkAsDeletedByExternalSource @project, @callback + + it "should not update the project", -> + @Project.update.called.should.equal false + + describe "when the project does have the flag set", -> + beforeEach -> + @project.deletedByExternalDataSource = true + @deleter.unmarkAsDeletedByExternalSource @project, @callback + + it "should remove the flag from the project", -> + @Project.update + .calledWith({_id: @project_id}, {deletedByExternalDataSource:false}) + .should.equal true describe "deleteUsersProjects", -> diff --git a/services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsControllerTests.coffee b/services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsControllerTests.coffee index e6a3fc6a55..12bc57df8d 100644 --- a/services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsControllerTests.coffee @@ -5,11 +5,12 @@ modulePath = require('path').join __dirname, '../../../../app/js/Features/ThirdP -describe 'third party data store', -> +describe 'TpdsController', -> beforeEach -> - @updateHandler = {} - @controller = SandboxedModule.require modulePath, requires: - './TpdsUpdateHandler':@updateHandler + @TpdsUpdateHandler = {} + @TpdsController = SandboxedModule.require modulePath, requires: + './TpdsUpdateHandler':@TpdsUpdateHandler + './UpdateMerger': @UpdateMerger = {} 'logger-sharelatex': log:-> err:-> @@ -24,11 +25,11 @@ describe 'third party data store', -> params:{0:path, "user_id":@user_id} session: destroy:-> - @updateHandler.newUpdate = sinon.stub().callsArg(5) + @TpdsUpdateHandler.newUpdate = sinon.stub().callsArg(5) res = send: => - @updateHandler.newUpdate.calledWith(@user_id, "projectName","/here.txt", req).should.equal true + @TpdsUpdateHandler.newUpdate.calledWith(@user_id, "projectName","/here.txt", req).should.equal true done() - @controller.mergeUpdate req, res + @TpdsController.mergeUpdate req, res describe 'getting a delete update', -> it 'should process the delete with the update reciver', (done)-> @@ -37,18 +38,18 @@ describe 'third party data store', -> params:{0:path, "user_id":@user_id} session: destroy:-> - @updateHandler.deleteUpdate = sinon.stub().callsArg(4) + @TpdsUpdateHandler.deleteUpdate = sinon.stub().callsArg(4) res = send: => - @updateHandler.deleteUpdate.calledWith(@user_id, "projectName", "/here.txt").should.equal true + @TpdsUpdateHandler.deleteUpdate.calledWith(@user_id, "projectName", "/here.txt").should.equal true done() - @controller.deleteUpdate req, res + @TpdsController.deleteUpdate req, res describe 'parseParams', -> it 'should take the project name off the start and replace with slash', -> path = "noSlashHere" req = params:{0:path, user_id:@user_id} - result = @controller.parseParams(req) + result = @TpdsController.parseParams(req) result.user_id.should.equal @user_id result.filePath.should.equal "/" result.projectName.should.equal path @@ -57,7 +58,7 @@ describe 'third party data store', -> it 'should take the project name off the start and return it with no slashes in', -> path = "/project/file.tex" req = params:{0:path, user_id:@user_id} - result = @controller.parseParams(req) + result = @TpdsController.parseParams(req) result.user_id.should.equal @user_id result.filePath.should.equal "/file.tex" result.projectName.should.equal "project" @@ -65,8 +66,57 @@ describe 'third party data store', -> it 'should take the project name of and return a slash for the file path', -> path = "/project_name" req = params:{0:path, user_id:@user_id} - result = @controller.parseParams(req) + result = @TpdsController.parseParams(req) result.projectName.should.equal "project_name" result.filePath.should.equal "/" + + describe 'updateProjectContents', -> + beforeEach -> + @UpdateMerger.mergeUpdate = sinon.stub().callsArg(3) + @req = + params: + 0: @path = "chapters/main.tex" + project_id: @project_id = "project-id-123" + session: + destroy: sinon.stub() + @res = + send: sinon.stub() + + @TpdsController.updateProjectContents @req, @res + + it "should merge the update", -> + @UpdateMerger.mergeUpdate + .calledWith(@project_id, "/" + @path, @req) + .should.equal true + + it "should return a success", -> + @res.send.calledWith(200).should.equal true + it "should clear the session", -> + @req.session.destroy.called.should.equal true + + describe 'deleteProjectContents', -> + beforeEach -> + @UpdateMerger.deleteUpdate = sinon.stub().callsArg(2) + @req = + params: + 0: @path = "chapters/main.tex" + project_id: @project_id = "project-id-123" + session: + destroy: sinon.stub() + @res = + send: sinon.stub() + + @TpdsController.deleteProjectContents @req, @res + + it "should delete the file", -> + @UpdateMerger.deleteUpdate + .calledWith(@project_id, "/" + @path) + .should.equal true + + it "should return a success", -> + @res.send.calledWith(200).should.equal true + + it "should clear the session", -> + @req.session.destroy.called.should.equal true