diff --git a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee index aaf4172cf4..1a5e13e86e 100644 --- a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee @@ -5,7 +5,8 @@ logger = require 'logger-sharelatex' module.exports = LinkedFilesController = { Agents: { - url: require('./UrlAgent') + url: require('./UrlAgent'), + project_file: require('./ProjectFileAgent') } createLinkedFile: (req, res, next) -> @@ -22,11 +23,17 @@ module.exports = LinkedFilesController = { linkedFileData = Agent.sanitizeData(data) linkedFileData.provider = provider - Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) -> - if error? - logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk' - return Agent.handleError(error, req, res, next) - EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error) -> - return next(error) if error? - res.send(204) # created -} \ No newline at end of file + Agent.checkAuth project_id, linkedFileData, user_id, (err, allowed) -> + return Agent.handleError(err, req, res, next) if err? + return res.sendStatus(403) if !allowed + Agent.decorateLinkedFileData linkedFileData, (err, newLinkedFileData) -> + return Agent.handleError(err) if err? + linkedFileData = newLinkedFileData + Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) -> + if error? + logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk' + return Agent.handleError(error, req, res, next) + EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error, file) -> + return next(error) if error? + res.json(new_file_id: file._id) # created + } diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee new file mode 100644 index 0000000000..5ea4554426 --- /dev/null +++ b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee @@ -0,0 +1,127 @@ +FileWriter = require('../../infrastructure/FileWriter') +AuthorizationManager = require('../Authorization/AuthorizationManager') +ProjectLocator = require('../Project/ProjectLocator') +ProjectGetter = require('../Project/ProjectGetter') +DocstoreManager = require('../Docstore/DocstoreManager') +FileStoreHandler = require('../FileStore/FileStoreHandler') +FileWriter = require('../../infrastructure/FileWriter') +_ = require "underscore" +Settings = require 'settings-sharelatex' + + +AccessDeniedError = (message) -> + error = new Error(message) + error.name = 'AccessDenied' + error.__proto__ = AccessDeniedError.prototype + return error +AccessDeniedError.prototype.__proto__ = Error.prototype + + +BadEntityTypeError = (message) -> + error = new Error(message) + error.name = 'BadEntityType' + error.__proto__ = BadEntityTypeError.prototype + return error +BadEntityTypeError.prototype.__proto__ = Error.prototype + + +BadDataError = (message) -> + error = new Error(message) + error.name = 'BadData' + error.__proto__ = BadDataError.prototype + return error +BadDataError.prototype.__proto__ = Error.prototype + + +ProjectNotFoundError = (message) -> + error = new Error(message) + error.name = 'ProjectNotFound' + error.__proto__ = ProjectNotFoundError.prototype + return error +ProjectNotFoundError.prototype.__proto__ = Error.prototype + + +SourceFileNotFoundError = (message) -> + error = new Error(message) + error.name = 'BadData' + error.__proto__ = SourceFileNotFoundError.prototype + return error +SourceFileNotFoundError.prototype.__proto__ = Error.prototype + + +module.exports = ProjectFileAgent = + + sanitizeData: (data) -> + return _.pick( + data, + 'source_project_id', + 'source_entity_path' + ) + + _validate: (data) -> + return ( + data.source_project_id? && + data.source_entity_path? + ) + + decorateLinkedFileData: (data, callback = (err, newData) ->) -> + callback = _.once(callback) + { source_project_id } = data + return callback(new BadDataError()) if !source_project_id? + ProjectGetter.getProject source_project_id, (err, project) -> + return callback(err) if err? + return callback(new ProjectNotFoundError()) if !project? + callback(err, _.extend(data, {source_project_display_name: project.name})) + + checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) -> + callback = _.once(callback) + if !ProjectFileAgent._validate(data) + return callback(new BadDataError()) + {source_project_id, source_entity_path} = data + AuthorizationManager.canUserReadProject current_user_id, source_project_id, null, (err, canRead) -> + return callback(err) if err? + callback(null, canRead) + + writeIncomingFileToDisk: + (project_id, data, current_user_id, callback = (error, fsPath) ->) -> + callback = _.once(callback) + if !ProjectFileAgent._validate(data) + return callback(new BadDataError()) + {source_project_id, source_entity_path} = data + ProjectLocator.findElementByPath { + project_id: source_project_id, + path: source_entity_path + }, (err, entity, type) -> + if err? + if err.toString().match(/^not found.*/) + err = new SourceFileNotFoundError() + return callback(err) + ProjectFileAgent._writeEntityToDisk source_project_id, entity._id, type, callback + + _writeEntityToDisk: (project_id, entity_id, type, callback=(err, location)->) -> + callback = _.once(callback) + if type == 'doc' + DocstoreManager.getDoc project_id, entity_id, (err, lines) -> + return callback(err) if err? + FileWriter.writeLinesToDisk entity_id, lines, callback + else if type == 'file' + FileStoreHandler.getFileStream project_id, entity_id, null, (err, fileStream) -> + return callback(err) if err? + FileWriter.writeStreamToDisk entity_id, fileStream, callback + else + callback(new BadEntityTypeError()) + + handleError: (error, req, res, next) -> + if error instanceof AccessDeniedError + res.status(403).send("You do not have access to this project") + else if error instanceof BadDataError + res.status(400).send("The submitted data is not valid") + else if error instanceof BadEntityTypeError + res.status(400).send("The file is the wrong type") + else if error instanceof SourceFileNotFoundError + res.status(404).send("Source file not found") + else if error instanceof ProjectNotFoundError + res.status(404).send("Project not found") + else + next(error) + next() diff --git a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee index ad96aa628f..7a15fe52d3 100644 --- a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee @@ -27,6 +27,12 @@ module.exports = UrlAgent = { url: @._prependHttpIfNeeded(data.url) } + decorateLinkedFileData: (data, callback = (err, newData) ->) -> + return callback(null, data) + + checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) -> + callback(null, true) + writeIncomingFileToDisk: (project_id, data, current_user_id, callback = (error, fsPath) ->) -> callback = _.once(callback) url = data.url @@ -65,4 +71,4 @@ module.exports = UrlAgent = { if !Settings.apis?.linkedUrlProxy?.url? throw new Error('no linked url proxy configured') return "#{Settings.apis.linkedUrlProxy.url}?url=#{encodeURIComponent(url)}" -} \ 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 4ca886fed0..8ea2dc3189 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -25,6 +25,7 @@ Sources = require "../Authorization/Sources" TokenAccessHandler = require '../TokenAccess/TokenAccessHandler' CollaboratorsHandler = require '../Collaborators/CollaboratorsHandler' Modules = require '../../infrastructure/Modules' +ProjectEntityHandler = require './ProjectEntityHandler' crypto = require 'crypto' module.exports = ProjectController = @@ -138,6 +139,33 @@ module.exports = ProjectController = return next(err) if err? res.sendStatus 200 + userProjectsJson: (req, res, next) -> + user_id = AuthenticationController.getLoggedInUserId(req) + ProjectGetter.findAllUsersProjects user_id, + 'name lastUpdated publicAccesLevel archived owner_ref tokens', (err, projects) -> + return next(err) if err? + projects = ProjectController._buildProjectList(projects) + .filter((p) -> !p.archived) + .filter((p) -> !p.isV1Project) + .map((p) -> {_id: p.id, name: p.name, accessLevel: p.accessLevel}) + + res.json({projects: projects}) + + projectEntitiesJson: (req, res, next) -> + user_id = AuthenticationController.getLoggedInUserId(req) + project_id = req.params.Project_id + ProjectGetter.getProject project_id, (err, project) -> + return next(err) if err? + ProjectEntityHandler.getAllEntitiesFromProject project, (err, docs, files) -> + return next(err) if err? + entities = docs.concat(files) + .sort (a, b) -> a.path > b.path # Sort by path ascending + .map (e) -> { + path: e.path, + type: if e.doc? then 'doc' else 'file' + } + res.json({project_id: project_id, entities: entities}) + projectListPage: (req, res, next)-> timer = new metrics.Timer("project-list") user_id = AuthenticationController.getLoggedInUserId(req) @@ -313,6 +341,7 @@ module.exports = ProjectController = maxDocLength: Settings.max_doc_length useV2History: !!project.overleaf?.history?.display showRichText: req.query?.rt == 'true' + showTestControls: req.query?.tc == 'true' || user.isAdmin showPublishModal: req.query?.pm == 'true' timer.done() diff --git a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee index 27ead91841..22034200f5 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee @@ -45,37 +45,40 @@ wrapWithLock = (methodWithoutLock) -> methodWithLock module.exports = ProjectEntityUpdateHandler = self = - # this doesn't need any locking because it's only called by ProjectDuplicator - copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)-> - project_id = project._id - projectHistoryId = project.overleaf?.history?.id - logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project" - return callback(err) if err? - ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id)=> - if !origonalFileRef? - logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null" - return callback() - # convert any invalid characters in original file to '_' - fileRef = new File name : SafePath.clean(origonalFileRef.name) - FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)-> - if err? - logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3" - return callback(err) - ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result)=> - if err? - logger.err { err, project_id, folder_id }, "error putting element as part of copy" - return callback(err) - TpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) -> + copyFileFromExistingProjectWithProject: wrapWithLock + beforeLock: (next) -> + (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)-> + project_id = project._id + logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project" + ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id) -> + if !origonalFileRef? + logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null" + return callback() + # convert any invalid characters in original file to '_' + fileRef = new File name : SafePath.clean(origonalFileRef.name) + FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)-> if err? - logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker" - newFiles = [ - file: fileRef - path: result?.path?.fileSystem - url: fileStoreUrl - ] - DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles}, (error) -> - return callback(error) if error? - callback null, fileRef, folder_id + logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3" + return callback(err) + next(project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback) + withLock: (project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback = (error, fileRef, folder_id) ->)-> + project_id = project._id + projectHistoryId = project.overleaf?.history?.id + ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result, newProject) -> + if err? + logger.err { err, project_id, folder_id }, "error putting element as part of copy" + return callback(err) + TpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) -> + if err? + logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker" + newFiles = [ + file: fileRef + path: result?.path?.fileSystem + url: fileStoreUrl + ] + DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles, newProject}, (error) -> + return callback(error) if error? + callback null, fileRef, folder_id updateDocLines: (project_id, doc_id, lines, version, ranges, callback = (error) ->)-> ProjectGetter.getProjectWithoutDocLines project_id, (err, project)-> diff --git a/services/web/app/coffee/Features/References/ReferencesHandler.coffee b/services/web/app/coffee/Features/References/ReferencesHandler.coffee index 8728896631..959833351f 100644 --- a/services/web/app/coffee/Features/References/ReferencesHandler.coffee +++ b/services/web/app/coffee/Features/References/ReferencesHandler.coffee @@ -10,6 +10,8 @@ Async = require('async') oneMinInMs = 60 * 1000 fiveMinsInMs = oneMinInMs * 5 +if !settings.apis?.references?.url? + logger.log "references search not enabled" module.exports = ReferencesHandler = diff --git a/services/web/app/coffee/Features/Templates/TemplatesController.coffee b/services/web/app/coffee/Features/Templates/TemplatesController.coffee new file mode 100644 index 0000000000..fce6c9502c --- /dev/null +++ b/services/web/app/coffee/Features/Templates/TemplatesController.coffee @@ -0,0 +1,80 @@ +path = require('path') +Project = require('../../../js/models/Project').Project +ProjectUploadManager = require('../../../js/Features/Uploads/ProjectUploadManager') +ProjectOptionsHandler = require('../../../js/Features/Project/ProjectOptionsHandler') +AuthenticationController = require('../../../js/Features/Authentication/AuthenticationController') +settings = require('settings-sharelatex') +fs = require('fs') +request = require('request') +uuid = require('uuid') +logger = require('logger-sharelatex') +async = require("async") + + +module.exports = TemplatesController = + + getV1Template: (req, res)-> + templateVersionId = req.params.Template_version_id + templateId = req.query.id + if !/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId) + logger.err templateVersionId:templateVersionId, templateId: templateId, "invalid template id or version" + return res.sendStatus 400 + data = {} + data.templateVersionId = templateVersionId + data.templateId = templateId + data.name = req.query.templateName + data.compiler = req.query.latexEngine + res.render path.resolve(__dirname, "../../../views/project/editor/new_from_template"), data + + createProjectFromV1Template: (req, res)-> + currentUserId = AuthenticationController.getLoggedInUserId(req) + zipUrl = "#{settings.apis.v1.url}/api/v1/sharelatex/templates/#{req.body.templateVersionId}" + zipReq = request(zipUrl, { + 'auth': { + 'user': settings.apis.v1.user, + 'pass': settings.apis.v1.pass + } + }) + + TemplatesController.createFromZip( + zipReq, + { + templateName: req.body.templateName, + currentUserId: currentUserId, + compiler: req.body.compiler + docId: req.body.docId + templateId: req.body.templateId + templateVersionId: req.body.templateVersionId + }, + req, + res + ) + + createFromZip: (zipReq, options, req, res)-> + dumpPath = "#{settings.path.dumpFolder}/#{uuid.v4()}" + writeStream = fs.createWriteStream(dumpPath) + + zipReq.on "error", (error) -> + logger.error err: error, "error getting zip from template API" + zipReq.pipe(writeStream) + writeStream.on 'close', -> + ProjectUploadManager.createProjectFromZipArchive options.currentUserId, options.templateName, dumpPath, (err, project)-> + if err? + logger.err err:err, zipReq:zipReq, "problem building project from zip" + return res.sendStatus 500 + setCompiler project._id, options.compiler, -> + fs.unlink dumpPath, -> + delete req.session.templateData + conditions = {_id:project._id} + update = { + fromV1TemplateId:options.templateId, + fromV1TemplateVersionId:options.templateVersionId + } + Project.update conditions, update, {}, (err)-> + res.redirect "/project/#{project._id}" + +setCompiler = (project_id, compiler, callback)-> + if compiler? + ProjectOptionsHandler.setCompiler project_id, compiler, callback + else + callback() diff --git a/services/web/app/coffee/Features/Templates/TemplatesMiddlewear b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear new file mode 100644 index 0000000000..300721c889 --- /dev/null +++ b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear @@ -0,0 +1,8 @@ +settings = require("settings-sharelatex") +logger = require("logger-sharelatex") + +module.exports = + saveTemplateDataInSession: (req, res, next)-> + if req.query.templateName + req.session.templateData = req.query + next() diff --git a/services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee new file mode 100644 index 0000000000..8baa0ca605 --- /dev/null +++ b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee @@ -0,0 +1,9 @@ +settings = require("settings-sharelatex") +logger = require("logger-sharelatex") + + +module.exports = + saveTemplateDataInSession: (req, res, next)-> + if req.query.templateName + req.session.templateData = req.query + next() diff --git a/services/web/app/coffee/Features/Templates/TemplatesRouter.coffee b/services/web/app/coffee/Features/Templates/TemplatesRouter.coffee new file mode 100644 index 0000000000..3061789591 --- /dev/null +++ b/services/web/app/coffee/Features/Templates/TemplatesRouter.coffee @@ -0,0 +1,10 @@ +AuthenticationController = require('../Authentication/AuthenticationController') +TemplatesController = require("./TemplatesController") +TemplatesMiddlewear = require('./TemplatesMiddlewear') + +module.exports = + apply: (app)-> + + app.get '/project/new/template/:Template_version_id', TemplatesMiddlewear.saveTemplateDataInSession, AuthenticationController.requireLogin(), TemplatesController.getV1Template + + app.post '/project/new/template', AuthenticationController.requireLogin(), TemplatesController.createProjectFromV1Template diff --git a/services/web/app/coffee/infrastructure/FileWriter.coffee b/services/web/app/coffee/infrastructure/FileWriter.coffee index dedeed9bad..27b1f16921 100644 --- a/services/web/app/coffee/infrastructure/FileWriter.coffee +++ b/services/web/app/coffee/infrastructure/FileWriter.coffee @@ -6,16 +6,31 @@ Settings = require 'settings-sharelatex' request = require 'request' module.exports = FileWriter = + + _ensureDumpFolderExists: (callback=(error)->) -> + fs.mkdir Settings.path.dumpFolder, (error) -> + if error? and error.code != 'EEXIST' + # Ignore error about already existing + return callback(error) + callback(null) + + writeLinesToDisk: (identifier, lines, callback = (error, fsPath)->) -> + callback = _.once(callback) + fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}" + FileWriter._ensureDumpFolderExists (error) -> + return callback(error) if error? + fs.writeFile fsPath, lines.join('\n'), (error) -> + return callback(error) if error? + callback(null, fsPath) + writeStreamToDisk: (identifier, stream, callback = (error, fsPath) ->) -> callback = _.once(callback) fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}" stream.pause() - fs.mkdir Settings.path.dumpFolder, (error) -> + FileWriter._ensureDumpFolderExists (error) -> + return callback(error) if error? stream.resume() - if error? and error.code != 'EEXIST' - # Ignore error about already existing - return callback(error) writeStream = fs.createWriteStream(fsPath) stream.pipe(writeStream) @@ -39,4 +54,4 @@ module.exports = FileWriter = else err = new Error("bad response from url: #{response.statusCode}") logger.err {err, identifier, url}, err.message - callback(err) \ No newline at end of file + callback(err) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 61ffab6498..22c4abe925 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -48,6 +48,7 @@ MetaController = require('./Features/Metadata/MetaController') TokenAccessController = require('./Features/TokenAccess/TokenAccessController') Features = require('./infrastructure/Features') LinkedFilesRouter = require './Features/LinkedFiles/LinkedFilesRouter' +TemplatesRouter = require './Features/Templates/TemplatesRouter' logger = require("logger-sharelatex") _ = require("underscore") @@ -80,10 +81,10 @@ module.exports = class Router ContactRouter.apply(webRouter, privateApiRouter) AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter) LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter) + TemplatesRouter.apply(webRouter) Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) - if Settings.enableSubscriptions webRouter.get '/user/bonus', AuthenticationController.requireLogin(), ReferalController.bonus @@ -119,6 +120,11 @@ module.exports = class Router webRouter.get '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo privateApiRouter.get '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo + webRouter.get '/user/projects', AuthenticationController.requireLogin(), ProjectController.userProjectsJson + webRouter.get '/project/:Project_id/entities', AuthenticationController.requireLogin(), + AuthorizationMiddlewear.ensureUserCanReadProject, + ProjectController.projectEntitiesJson + webRouter.get '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage webRouter.post '/project/new', AuthenticationController.requireLogin(), ProjectController.newProject @@ -202,7 +208,7 @@ module.exports = class Router webRouter.get "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.get "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails webRouter.get "/project/:Project_id/filetree/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi - webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi + webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.post '/project/:project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreDocFromDeletedDoc webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2 privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory diff --git a/services/web/app/views/project/editor/binary-file.pug b/services/web/app/views/project/editor/binary-file.pug index e5690c1874..35d75c9fb3 100644 --- a/services/web/app/views/project/editor/binary-file.pug +++ b/services/web/app/views/project/editor/binary-file.pug @@ -47,7 +47,18 @@ div.binary-file.full-size( | | at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }} - span(ng-if="openFile.linkedFileData.provider == 'url'") + div(ng-if="openFile.linkedFileData.provider == 'project_file'") + p + i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon + | Imported from + | + a(ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank") + | {{ openFile.linkedFileData.source_project_display_name }} + | /{{ openFile.linkedFileData.source_entity_path.slice(1) }}, + | + | at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }} + + span(ng-if="openFile.linkedFileData.provider == 'url' || openFile.linkedFileData.provider == 'project_file'") button.btn.btn-success( href, ng-click="refreshFile(openFile)", ng-disabled="refreshing" @@ -63,3 +74,7 @@ div.binary-file.full-size( i.fa.fa-fw.fa-download | | #{translate("download")} + div(ng-if="refreshError") + br + .alert.alert-danger.col-md-6.col-md-offset-3 + | Error: {{ refreshError}} diff --git a/services/web/app/views/project/editor/editor.pug b/services/web/app/views/project/editor/editor.pug index 3cac3a9490..c24d967da7 100644 --- a/services/web/app/views/project/editor/editor.pug +++ b/services/web/app/views/project/editor/editor.pug @@ -33,11 +33,11 @@ div.full-size( i.fa.fa-arrow-left |   #{translate("open_a_file_on_the_left")} - != moduleIncludes('editor:toolbar', locals) + != moduleIncludes('editor:main', locals) #editor( ace-editor="editor", - ng-if="!editor.richText", + ng-if="!editor.showRichText", ng-show="!!editor.sharejs_doc && !editor.opening", style=showRichText ? "top: 32px" : "", theme="settings.theme", @@ -73,8 +73,6 @@ div.full-size( line-height="settings.lineHeight || ui.defaultLineHeight" ) - != moduleIncludes('editor:body', locals) - include ./review-panel .ui-layout-east diff --git a/services/web/app/views/project/editor/file-tree.pug b/services/web/app/views/project/editor/file-tree.pug index 55fc660abc..d16258d1c9 100644 --- a/services/web/app/views/project/editor/file-tree.pug +++ b/services/web/app/views/project/editor/file-tree.pug @@ -342,6 +342,77 @@ script(type='text/ng-template', id='newDocModalTemplate') span(ng-show="state.inflight") #{translate("creating")}... +// Project Linked Files Modal +script(type='text/ng-template', id='projectLinkedFileModalTemplate') + .modal-header + h3 New file from Project + + .modal-body + div + div.alert.alert-danger(ng-if="state.error") Error, something went wrong! + div + form + .form-controls + label(for="project-select") Select a Project + span(ng-show="state.inFlight.projects") + |   + i.fa.fa-spinner.fa-spin + select.form-control( + name="project-select" + ng-model="data.selectedProjectId" + ng-disabled="!shouldEnableProjectSelect()" + ) + option(value="" disabled selected) - Please Select a Project + option( + ng-repeat="project in data.projects" + value="{{ project._id }}" + ) {{ project.name }} + + br + .form-controls + label(for="project-entity-select") Select a File + span(ng-show="state.inFlight.entities") + |   + i.fa.fa-spinner.fa-spin + select.form-control( + name="project-entity-select" + ng-model="data.selectedProjectEntity" + ng-disabled="!shouldEnableProjectEntitySelect()" + ) + option(value="" disabled selected) - Please Select a File + option( + ng-repeat="projectEntity in data.projectEntities" + value="{{ projectEntity.path }}" + ) {{ projectEntity.path.slice(1) }} + br + + .form-controls + label(for="name") File Name In This Project + input.form-control( + type="text" + placeholder="example.tex" + required + ng-model="data.name" + name="name" + ) + br + + .modal-footer + span(ng-show="state.inFlight.create") + i.fa.fa-spinner.fa-spin + |   + button.btn.btn-default( + ng-disabled="state.inflight" + ng-click="cancel()" + ) #{translate("cancel")} + button.btn.btn-primary( + ng-disabled="!shouldEnableCreateButton()" + ng-click="create()" + ) + span(ng-hide="state.inflight") #{translate("create")} + span(ng-show="state.inflight") #{translate("creating")}... + + script(type='text/ng-template', id='linkedFileModalTemplate') .modal-header h3 New file from URL diff --git a/services/web/app/views/project/editor/history/diffPanelV1.pug b/services/web/app/views/project/editor/history/diffPanelV1.pug index 1720f48b59..30588adf46 100644 --- a/services/web/app/views/project/editor/history/diffPanelV1.pug +++ b/services/web/app/views/project/editor/history/diffPanelV1.pug @@ -13,7 +13,7 @@ }" ) | in {{history.diff.pathname}} - .toolbar-right + .toolbar-right(ng-if="permissions.write") a.btn.btn-danger.btn-sm( href, ng-click="openRestoreDiffModal()" diff --git a/services/web/app/views/project/editor/left-menu.pug b/services/web/app/views/project/editor/left-menu.pug index ed6ef85b44..d85a45723b 100644 --- a/services/web/app/views/project/editor/left-menu.pug +++ b/services/web/app/views/project/editor/left-menu.pug @@ -62,6 +62,23 @@ aside#left-menu.full-size( != moduleIncludes("editorLeftMenu:editing_services", locals) + if showTestControls + h4 Test Controls + ul.list-unstyled.nav(ng-controller="TestControlsController") + li + a(href="#" ng-click="richText()") + i.fa.fa-exclamation.fa-fw + | Rich Text + li + a(href="#" ng-click="openProjectLinkedFileModal()") + i.fa.fa-exclamation.fa-fw + | Project-Linked-File Modal + li + a(href="#" ng-click="openLinkedFileModal()") + i.fa.fa-exclamation.fa-fw + | URL-Linked-File Modal + + h4(ng-show="!anonymous") #{translate("settings")} form.settings(ng-controller="SettingsController", ng-show="!anonymous") .containter-fluid @@ -179,6 +196,7 @@ aside#left-menu.full-size( option(value="pdfjs") #{translate("built_in")} option(value="native") #{translate("native")} + h4 #{translate("hotkeys")} ul.list-unstyled.nav li(ng-controller="HotkeysController") diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug new file mode 100644 index 0000000000..6dc27a4241 --- /dev/null +++ b/services/web/app/views/project/editor/new_from_template.pug @@ -0,0 +1,26 @@ +extends ../../layout + +block content + script. + $(document).ready(function(){ + $('#create_form').submit(); + }); + + .editor.full-size + .loading-screen() + .loading-screen-brand-container + .loading-screen-brand( + style="height: 20%;" + ) + + h3.loading-screen-label() #{translate("Opening template")} + span.loading-screen-ellip . + span.loading-screen-ellip . + span.loading-screen-ellip . + + form(id='create_form' method='POST' action='/project/new/template/') + input(type="hidden", name="_csrf", value=csrfToken) + input(type="hidden" name="templateId" value=templateId) + input(type="hidden" name="templateVersionId" value=templateVersionId) + input(type="hidden" name="templateName" value=name) + input(type="hidden" name="compiler" value=compiler) diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug index bfc53a8360..bc37683489 100644 --- a/services/web/app/views/project/list/item.pug +++ b/services/web/app/views/project/list/item.pug @@ -1,4 +1,7 @@ -.col-xs-6 +- var titleClasses = settings.overleaf ? "col-xs-6 col-sm-4 col-md-6" : "col-xs-6" +- var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4" + +div(class=titleClasses) input.select-item( select-individual, type="checkbox", @@ -37,8 +40,42 @@ tooltip-placement="right" tooltip-append-to-body="true" ) -.col-xs-4 + +div(class=lastUpdatedClasses) if settings.overleaf span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}} else span.last-modified {{project.lastUpdated | formatDate}} + +if settings.overleaf + .hidden-xs.col-sm-3.col-md-2.action-btn-row + button.btn.btn-link.action-btn( + tooltip=translate('copy'), + tooltip-placement="top", + tooltip-append-to-body="true", + ng-click="clone($event)" + ) + i.icon.fa.fa-files-o + button.btn.btn-link.action-btn( + tooltip=translate('download'), + tooltip-placement="top", + tooltip-append-to-body="true", + ng-click="download($event)" + ) + i.icon.fa.fa-cloud-download + button.btn.btn-link.action-btn( + ng-if="!project.archived" + tooltip=translate('archive'), + tooltip-placement="top", + tooltip-append-to-body="true", + ng-click="archive($event)" + ) + i.icon.fa.fa-inbox + button.btn.btn-link.action-btn( + ng-if="project.archived" + tooltip=translate('unarchive'), + tooltip-placement="top", + tooltip-append-to-body="true", + ng-click="restore($event)" + ) + i.icon.fa.fa-reply \ No newline at end of file diff --git a/services/web/app/views/project/list/project-list.pug b/services/web/app/views/project/list/project-list.pug index df3c2bf681..cfea4aa6c6 100644 --- a/services/web/app/views/project/list/project-list.pug +++ b/services/web/app/views/project/list/project-list.pug @@ -131,7 +131,10 @@ ) li.container-fluid .row - .col-xs-6 + - var titleClasses = settings.overleaf ? " col-xs-6 col-sm-4 col-md-6" : "col-xs-6" + - var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4" + + div(class=titleClasses) input.select-all( select-all, type="checkbox" @@ -142,9 +145,12 @@ .col-xs-2 span.header.clickable(ng-click="changePredicate('accessLevel')") #{translate("owner")} i.tablesort.fa(ng-class="getSortIconClass('accessLevel')") - .col-xs-4 + div(class=lastUpdatedClasses) span.header.clickable(ng-click="changePredicate('lastUpdated')") #{translate("last_modified")} i.tablesort.fa(ng-class="getSortIconClass('lastUpdated')") + if settings.overleaf + .hidden-xs.col-sm-3.col-md-2.action-btn-row-header + span.header #{translate("actions")} li.project_entry.container-fluid( ng-repeat="project in visibleProjects | orderBy:predicate:reverse", ng-controller="ProjectListItemController" diff --git a/services/web/app/views/project/list/v1-item.pug b/services/web/app/views/project/list/v1-item.pug index b4a3ccb99d..5a8e37bca0 100644 --- a/services/web/app/views/project/list/v1-item.pug +++ b/services/web/app/views/project/list/v1-item.pug @@ -1,4 +1,4 @@ -.col-xs-6 +.col-xs-6.col-sm-4.col-md-6 .select-item span.v1-badge( aria-label=translate("v1_badge") @@ -21,5 +21,5 @@ .col-xs-2 span.owner {{ownerName()}} -.col-xs-4 +.col-xs-4.col-sm-3.col-md-2 span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}} \ No newline at end of file diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 0892804778..7f4024c368 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -146,8 +146,8 @@ module.exports = settings = url: "http://#{process.env['CONTACTS_HOST'] or 'localhost'}:3036" sixpack: url: "" - # references: - # url: "http://localhost:3040" + references: + url: "http://#{process.env['REFERENCES_HOST'] or 'localhost'}:3040" notifications: url: "http://#{process.env['NOTIFICATIONS_HOST'] or 'localhost'}:3042" analytics: diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml index a062c0df4e..9596fe5126 100644 --- a/services/web/docker-compose.yml +++ b/services/web/docker-compose.yml @@ -17,6 +17,7 @@ services: PROJECT_HISTORY_ENABLED: 'true' ENABLED_LINKED_FILE_TYPES: 'url' LINKED_URL_PROXY: 'http://localhost:6543' + ENABLED_LINKED_FILE_TYPES: 'url,project_file' SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee depends_on: - redis diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 7c8602eb76..6c5b1d920e 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -18,6 +18,7 @@ define [ "ide/chat/index" "ide/clone/index" "ide/hotkeys/index" + "ide/test-controls/index" "ide/wordcount/index" "ide/directives/layout" "ide/directives/validFile" @@ -34,6 +35,7 @@ define [ "directives/videoPlayState" "services/queued-http" "services/validateCaptcha" + "services/wait-for" "filters/formatDate" "main/event" "main/account-upgrade" @@ -54,7 +56,7 @@ define [ SafariScrollPatcher ) -> - App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata) -> + App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata, $q) -> # Don't freak out if we're already in an apply callback $scope.$originalApply = $scope.$apply $scope.$apply = (fn = () ->) -> diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee index c14e097842..bba455c447 100644 --- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee +++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee @@ -2,7 +2,7 @@ define [ "base" "moment" ], (App, moment) -> - App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", ($scope, $rootScope, $http, $timeout, $element, ide) -> + App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", "waitFor", ($scope, $rootScope, $http, $timeout, $element, ide, waitFor) -> TWO_MEGABYTES = 2 * 1024 * 1024 @@ -31,6 +31,7 @@ define [ data: null $scope.refreshing = false + $scope.refreshError = null MAX_URL_LENGTH = 60 FRONT_OF_URL_LENGTH = 35 @@ -48,9 +49,27 @@ define [ $scope.refreshFile = (file) -> $scope.refreshing = true + $scope.refreshError = null ide.fileTreeManager.refreshLinkedFile(file) - .then () -> - loadTextFileFilePreview() + .then (response) -> + { data } = response + { new_file_id } = data + $timeout( + () -> + waitFor( + () -> + ide.fileTreeManager.findEntityById(new_file_id) + 5000 + ) + .then (newFile) -> + ide.binaryFilesManager.openFile(newFile) + .catch (err) -> + console.warn(err) + , 0 + ) + $scope.refreshError = null + .catch (response) -> + $scope.refreshError = response.data .finally () -> $scope.refreshing = false @@ -86,11 +105,9 @@ define [ # show dots when payload is closs to cutoff if data.length >= (TWO_MEGABYTES - 200) $scope.textPreview.shouldShowDots = true - try # remove last partial line - data = data.replace(/\n.*$/, '') - finally - $scope.textPreview.data = data + data = data?.replace?(/\n.*$/, '') + $scope.textPreview.data = data $timeout(setHeight, 0) .catch (error) -> console.error(error) diff --git a/services/web/public/coffee/ide/editor/EditorManager.coffee b/services/web/public/coffee/ide/editor/EditorManager.coffee index e3cabf8e98..7246e09b83 100644 --- a/services/web/public/coffee/ide/editor/EditorManager.coffee +++ b/services/web/public/coffee/ide/editor/EditorManager.coffee @@ -14,7 +14,7 @@ define [ opening: true trackChanges: false wantTrackChanges: false - richText: false + showRichText: false } @$scope.$on "entity:selected", (event, entity) => diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee index d7a428ec80..8d717e09b8 100644 --- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee +++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee @@ -335,6 +335,11 @@ define [ return null + projectContainsFolder: () -> + for entity in @$scope.rootFolder.children + return true if entity.type == 'folder' + return false + existsInThisFolder: (folder, name) -> for entity in folder?.children or [] return true if entity.name is name diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index 3d4077b2dd..010e00476f 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -43,6 +43,19 @@ define [ } ) + $scope.openProjectLinkedFileModal = window.openProjectLinkedFileModal = () -> + unless 'project_file' in window.data.enabledLinkedFileTypes + console.warn("Project linked files are not enabled") + return + $modal.open( + templateUrl: "projectLinkedFileModalTemplate" + controller: "ProjectLinkedFileModalController" + scope: $scope + resolve: { + parent_folder: () -> ide.fileTreeManager.getCurrentFolder() + } + ) + $scope.orderByFoldersFirst = (entity) -> return '0' if entity?.type == "folder" return '1' @@ -201,6 +214,117 @@ define [ $modalInstance.dismiss('cancel') ] + App.controller "ProjectLinkedFileModalController", [ + "$scope", "ide", "$modalInstance", "$timeout", "parent_folder", + ($scope, ide, $modalInstance, $timeout, parent_folder) -> + $scope.data = + projects: null # or [] + selectedProjectId: null + projectEntities: null # or [] + selectedProjectEntity: null + name: null + $scope.state = + inFlight: + projects: false + entities: false + create: false + error: false + + $scope.$watch 'data.selectedProjectId', (newVal, oldVal) -> + return if !newVal + $scope.data.selectedProjectEntity = null + $scope.getProjectEntities($scope.data.selectedProjectId) + + # auto-set filename based on selected file + $scope.$watch 'data.selectedProjectEntity', (newVal, oldVal) -> + return if !newVal + fileName = newVal.split('/').reverse()[0] + if fileName + $scope.data.name = fileName + + _setInFlight = (type) -> + $scope.state.inFlight[type] = true + + _reset = (opts) -> + isError = opts.err == true + inFlight = $scope.state.inFlight + inFlight.projects = inFlight.entities = inFlight.create = false + $scope.state.error = isError + + $scope.shouldEnableProjectSelect = () -> + { state, data } = $scope + return !state.inFlight.projects && data.projects + + $scope.shouldEnableProjectEntitySelect = () -> + { state, data } = $scope + return !state.inFlight.projects && !state.inFlight.entities && data.projects && data.selectedProjectId + + $scope.shouldEnableCreateButton = () -> + state = $scope.state + data = $scope.data + return !state.inFlight.projects && + !state.inFlight.entities && + data.projects && + data.selectedProjectId && + data.projectEntities && + data.selectedProjectEntity && + data.name + + $scope.getUserProjects = () -> + _setInFlight('projects') + ide.$http.get("/user/projects", { + _csrf: window.csrfToken + }) + .then (resp) -> + $scope.data.projectEntities = null + $scope.data.projects = resp.data.projects.filter (p) -> + p._id != ide.project_id + _reset(err: false) + .catch (err) -> + _reset(err: true) + + $scope.getProjectEntities = (project_id) => + _setInFlight('entities') + ide.$http.get("/project/#{project_id}/entities", { + _csrf: window.csrfToken + }) + .then (resp) -> + if $scope.data.selectedProjectId == resp.data.project_id + $scope.data.projectEntities = resp.data.entities + _reset(err: false) + .catch (err) -> + _reset(err: true) + + $scope.init = () -> + $scope.getUserProjects() + $timeout($scope.init, 0) + + $scope.create = () -> + projectId = $scope.data.selectedProjectId + path = $scope.data.selectedProjectEntity + name = $scope.data.name + if !name || !path || !projectId + _reset(err: true) + return + _setInFlight('create') + ide.fileTreeManager + .createLinkedFile(name, parent_folder, 'project_file', { + source_project_id: projectId, + source_entity_path: path + }) + .then () -> + _reset(err: false) + $modalInstance.close() + .catch (response)-> + { data } = response + _reset(err: true) + + $scope.cancel = () -> + $modalInstance.dismiss('cancel') + + ] + + # TODO: rename all this to UrlLinkedFilModalController App.controller "LinkedFileModalController", [ "$scope", "ide", "$modalInstance", "$timeout", "parent_folder", ($scope, ide, $modalInstance, $timeout, parent_folder) -> diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee index c2ced4cf59..279c230afb 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee @@ -1,7 +1,7 @@ define [ "base" ], (App) -> - App.controller "HistoryV2DiffController", ($scope, ide, event_tracking) -> + App.controller "HistoryV2DiffController", ($scope, ide, event_tracking, waitFor) -> $scope.restoreState = inflight: false error: false @@ -24,17 +24,16 @@ define [ $scope.restoreState.inflight = false openEntity = (data) -> - iterations = 0 {id, type} = data - do tryOpen = () -> - if iterations > 5 - return - iterations += 1 - entity = ide.fileTreeManager.findEntityById(id) - if entity? and type == 'doc' - ide.editorManager.openDoc(entity) - else if entity? and type == 'file' - ide.binaryFilesManager.openFile(entity) - else - setTimeout(tryOpen, 500) - \ No newline at end of file + waitFor( + () -> + ide.fileTreeManager.findEntityById(id) + 3000 + ) + .then (entity) -> + if type == 'doc' + ide.editorManager.openDoc(entity) + else if type == 'file' + ide.binaryFilesManager.openFile(entity) + .catch (err) -> + console.warn(err) diff --git a/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee b/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee new file mode 100644 index 0000000000..ae7db45905 --- /dev/null +++ b/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee @@ -0,0 +1,16 @@ +define [ + "base" + "ace/ace" +], (App) -> + App.controller "TestControlsController", ($scope) -> + + $scope.openProjectLinkedFileModal = () -> + window.openProjectLinkedFileModal() + + $scope.openLinkedFileModal = () -> + window.openLinkedFileModal() + + $scope.richText = () -> + current = window.location.toString() + target = "#{current}#{if window.location.search then '&' else '?'}rt=true" + window.location.href = target diff --git a/services/web/public/coffee/ide/test-controls/index.coffee b/services/web/public/coffee/ide/test-controls/index.coffee new file mode 100644 index 0000000000..d60d9e1a01 --- /dev/null +++ b/services/web/public/coffee/ide/test-controls/index.coffee @@ -0,0 +1,3 @@ +define [ + "ide/test-controls/controllers/TestControlsController" +], () -> 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 36520d2cc7..6696243905 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -350,14 +350,15 @@ define [ $scope.archiveOrLeaveSelectedProjects() $scope.archiveOrLeaveSelectedProjects = () -> - selected_projects = $scope.getSelectedProjects() - selected_project_ids = $scope.getSelectedProjectIds() + $scope.archiveOrLeaveProjects($scope.getSelectedProjects()) + $scope.archiveOrLeaveProjects = (projects) -> + projectIds = projects.map (p) -> p.id # Remove project from any tags for tag in $scope.tags - $scope._removeProjectIdsFromTagArray(tag, selected_project_ids) + $scope._removeProjectIdsFromTagArray(tag, projectIds) - for project in selected_projects + for project in projects project.tags = [] if project.accessLevel == "owner" project.archived = true @@ -414,16 +415,17 @@ define [ $scope.updateVisibleProjects() $scope.restoreSelectedProjects = () -> - selected_projects = $scope.getSelectedProjects() - selected_project_ids = $scope.getSelectedProjectIds() + $scope.restoreProjects($scope.getSelectedProjects()) - for project in selected_projects + $scope.restoreProjects = (projects) -> + projectIds = projects.map (p) -> p.id + for project in projects project.archived = false - for project_id in selected_project_ids + for projectId in projectIds queuedHttp { method: "POST" - url: "/project/#{project_id}/restore" + url: "/project/#{projectId}/restore" headers: "X-CSRF-Token": window.csrfToken } @@ -437,13 +439,14 @@ define [ ) $scope.downloadSelectedProjects = () -> - selected_project_ids = $scope.getSelectedProjectIds() - event_tracking.send 'project-list-page-interaction', 'project action', 'Download Zip' - if selected_project_ids.length > 1 - path = "/project/download/zip?project_ids=#{selected_project_ids.join(',')}" - else - path = "/project/#{selected_project_ids[0]}/download/zip" + $scope.downloadProjectsById($scope.getSelectedProjectIds()) + $scope.downloadProjectsById = (projectIds) -> + event_tracking.send 'project-list-page-interaction', 'project action', 'Download Zip' + if projectIds.length > 1 + path = "/project/download/zip?project_ids=#{projectIds.join(',')}" + else + path = "/project/#{projectIds[0]}/download/zip" window.location = path $scope.openV1ImportModal = (project) -> @@ -490,3 +493,19 @@ define [ $scope.$watch "project.selected", (value) -> if value? $scope.updateSelectedProjects() + + $scope.clone = (e) -> + e.stopPropagation() + $scope.cloneProject($scope.project, "#{$scope.project.name} (Copy)") + + $scope.download = (e) -> + e.stopPropagation() + $scope.downloadProjectsById([$scope.project.id]) + + $scope.archive = (e) -> + e.stopPropagation() + $scope.archiveOrLeaveProjects([$scope.project]) + + $scope.restore = (e) -> + e.stopPropagation() + $scope.restoreProjects([$scope.project]) diff --git a/services/web/public/coffee/services/wait-for.coffee b/services/web/public/coffee/services/wait-for.coffee new file mode 100644 index 0000000000..409142354c --- /dev/null +++ b/services/web/public/coffee/services/wait-for.coffee @@ -0,0 +1,20 @@ +define [ + "base" +], (App) -> + App.factory "waitFor", ($q) -> + waitFor = (testFunction, timeout, pollInterval=500) -> + iterationLimit = Math.floor(timeout / pollInterval) + iterations = 0 + $q( + (resolve, reject) -> + do tryIteration = () -> + if iterations > iterationLimit + return reject(new Error("waiting too long, #{JSON.stringify({timeout, pollInterval})}")) + iterations += 1 + result = testFunction() + if result? + resolve(result) + else + setTimeout(tryIteration, pollInterval) + ) + return waitFor diff --git a/services/web/public/stylesheets/app/project-list.less b/services/web/public/stylesheets/app/project-list.less index 982086a262..569035ad72 100644 --- a/services/web/public/stylesheets/app/project-list.less +++ b/services/web/public/stylesheets/app/project-list.less @@ -369,6 +369,16 @@ ul.project-list { .v1-badge { margin-left: -4px; } + + .action-btn-row-header, .action-btn-row { + padding-right: 20px; + text-align: right; + } + + .action-btn { + padding: 0 0.3em; + margin-left: 0.2em; + } } i.tablesort { padding-left: 8px; diff --git a/services/web/public/stylesheets/components/forms.less b/services/web/public/stylesheets/components/forms.less index 8eb92411cd..00653b105b 100755 --- a/services/web/public/stylesheets/components/forms.less +++ b/services/web/public/stylesheets/components/forms.less @@ -145,10 +145,15 @@ output { opacity: 1; // iOS fix for unreadable disabled content } - // Reset height for `textarea`s + // Reset height for `textarea`s, and smaller border-radius textarea& { height: auto; + border-radius: @border-radius-base; } + // Smaller border-radius for `select` inputs + select& { + border-radius: @border-radius-base; + } } diff --git a/services/web/test/acceptance/coffee/LinkedFilesTests.coffee b/services/web/test/acceptance/coffee/LinkedFilesTests.coffee index 9ca7ecae42..f3e8694e2a 100644 --- a/services/web/test/acceptance/coffee/LinkedFilesTests.coffee +++ b/services/web/test/acceptance/coffee/LinkedFilesTests.coffee @@ -27,6 +27,122 @@ describe "LinkedFiles", -> @owner.login -> mkdirp Settings.path.dumpFolder, done + describe "creating a project linked file", -> + before (done) -> + @source_doc_name = 'test.txt' + async.series [ + (cb) => + @owner.createProject 'plf-test-one', {template: 'blank'}, (error, project_id) => + @project_one_id = project_id + cb(error) + (cb) => + @owner.getProject @project_one_id, (error, project) => + @project_one = project + @project_one_root_folder_id = project.rootFolder[0]._id.toString() + cb(error) + (cb) => + @owner.createProject 'plf-test-two', {template: 'blank'}, (error, project_id) => + @project_two_id = project_id + cb(error) + (cb) => + @owner.getProject @project_two_id, (error, project) => + @project_two = project + @project_two_root_folder_id = project.rootFolder[0]._id.toString() + cb(error) + (cb) => + @owner.createDocInProject @project_two_id, + @project_two_root_folder_id, + @source_doc_name, + (error, doc_id) => + @source_doc_id = doc_id + cb(error) + (cb) => + @owner.createDocInProject @project_two_id, + @project_two_root_folder_id, + 'some-harmless-doc.txt', + (error, doc_id) => + cb(error) + ], done + + it 'should produce a list of the users projects', (done) -> + @owner.request.get { + url: "/user/projects", + json: true + }, (err, response, body) => + expect(err).to.not.exist + expect(body).to.deep.equal { + projects: [ + { _id: @project_one_id, name: 'plf-test-one', accessLevel: 'owner' }, + { _id: @project_two_id, name: 'plf-test-two', accessLevel: 'owner' } + ] + } + done() + + it 'should produce a list of entities in the project', (done) -> + @owner.request.get { + url: "/project/#{@project_two_id}/entities", + json: true + }, (err, response, body) => + expect(err).to.not.exist + expect(body).to.deep.equal { + project_id: @project_two_id, + entities: [ + { path: '/main.tex', type: 'doc' }, + { path: '/some-harmless-doc.txt', type: 'doc' }, + { path: '/test.txt', type: 'doc' } + ] + } + done() + + + it 'should import a file from the source project', (done) -> + @owner.request.post { + url: "/project/#{@project_one_id}/linked_file", + json: + name: 'test-link.txt', + parent_folder_id: @project_one_root_folder_id, + provider: 'project_file', + data: + source_project_id: @project_two_id, + source_entity_path: "/#{@source_doc_name}", + }, (error, response, body) => + new_file_id = body.new_file_id + @existing_file_id = new_file_id + expect(new_file_id).to.exist + @owner.getProject @project_one_id, (error, project) => + return done(error) if error? + firstFile = project.rootFolder[0].fileRefs[0] + expect(firstFile._id.toString()).to.equal(new_file_id.toString()) + expect(firstFile.linkedFileData).to.deep.equal { + provider: 'project_file', + source_project_id: @project_two_id, + source_entity_path: "/#{@source_doc_name}", + source_project_display_name: "plf-test-two" + } + expect(firstFile.name).to.equal('test-link.txt') + done() + + it 'should refresh the file', (done) -> + @owner.request.post { + url: "/project/#{@project_one_id}/linked_file", + json: + name: 'test-link.txt', + parent_folder_id: @project_one_root_folder_id, + provider: 'project_file', + data: + source_project_id: @project_two_id, + source_entity_path: "/#{@source_doc_name}", + }, (error, response, body) => + new_file_id = body.new_file_id + expect(new_file_id).to.exist + expect(new_file_id).to.not.equal @existing_file_id + @owner.getProject @project_one_id, (error, project) => + return done(error) if error? + firstFile = project.rootFolder[0].fileRefs[0] + expect(firstFile._id.toString()).to.equal(new_file_id.toString()) + expect(firstFile.name).to.equal('test-link.txt') + done() + describe "creating a URL based linked file", -> before (done) -> @owner.createProject "url-linked-files-project", {template: "blank"}, (error, project_id) => @@ -50,7 +166,7 @@ describe "LinkedFiles", -> name: 'url-test-file-1' }, (error, response, body) => throw error if error? - expect(response.statusCode).to.equal 204 + expect(response.statusCode).to.equal 200 @owner.getProject @project_id, (error, project) => throw error if error? file = project.rootFolder[0].fileRefs[0] @@ -76,7 +192,7 @@ describe "LinkedFiles", -> name: 'url-test-file-2' }, (error, response, body) => throw error if error? - expect(response.statusCode).to.equal 204 + expect(response.statusCode).to.equal 200 @owner.request.post { url: "/project/#{@project_id}/linked_file", json: @@ -88,7 +204,7 @@ describe "LinkedFiles", -> name: 'url-test-file-2' }, (error, response, body) => throw error if error? - expect(response.statusCode).to.equal 204 + expect(response.statusCode).to.equal 200 @owner.getProject @project_id, (error, project) => throw error if error? file = project.rootFolder[0].fileRefs[1] @@ -168,7 +284,7 @@ describe "LinkedFiles", -> name: 'url-test-file-6' }, (error, response, body) => throw error if error? - expect(response.statusCode).to.equal 204 + expect(response.statusCode).to.equal 200 @owner.getProject @project_id, (error, project) => throw error if error? file = _.find project.rootFolder[0].fileRefs, (file) -> diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee index a02ab5c42c..61c2e84a8c 100644 --- a/services/web/test/acceptance/coffee/helpers/User.coffee +++ b/services/web/test/acceptance/coffee/helpers/User.coffee @@ -143,6 +143,18 @@ class User return callback(err) callback(null) + createDocInProject: (project_id, parent_folder_id, name, callback=(error, doc_id)->) -> + @getCsrfToken (error) => + return callback(error) if error? + @request.post { + url: "/project/#{project_id}/doc", + json: { + name: name, + parent_folder_id: parent_folder_id + } + }, (error, response, body) => + callback(null, body._id) + addUserToProject: (project_id, user, privileges, callback = (error, user) ->) -> if privileges == 'readAndWrite' updateOp = {$addToSet: {collaberator_refs: user._id.toString()}} diff --git a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee index ec7246754c..1b66ba9bf9 100644 --- a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee @@ -67,6 +67,7 @@ describe "ProjectController", -> protectTokens: sinon.stub() @CollaboratorsHandler = userIsTokenMember: sinon.stub().callsArgWith(2, null, false) + @ProjectEntityHandler = {} @Modules = hooks: fire: sinon.stub() @@ -98,6 +99,7 @@ describe "ProjectController", -> "../TokenAccess/TokenAccessHandler": @TokenAccessHandler "../Collaborators/CollaboratorsHandler": @CollaboratorsHandler "../../infrastructure/Modules": @Modules + "./ProjectEntityHandler": @ProjectEntityHandler @projectName = "£12321jkj9ujkljds" @req = @@ -520,7 +522,62 @@ describe "ProjectController", -> @ProjectUpdateHandler.markAsOpened.calledWith(@project_id).should.equal true done() @ProjectController.loadEditor @req, @res - + + describe 'userProjectsJson', -> + beforeEach (done) -> + projects = [ + {archived: true, id: 'a', name: 'A', accessLevel: 'a', somethingElse: 1} + {archived: false, id: 'b', name: 'B', accessLevel: 'b', somethingElse: 1} + {archived: false, id: 'c', name: 'C', accessLevel: 'c', somethingElse: 1} + {archived: false, id: 'd', name: 'D', accessLevel: 'd', somethingElse: 1} + ] + @ProjectGetter.findAllUsersProjects = sinon.stub().callsArgWith(2, null, []) + @ProjectController._buildProjectList = sinon.stub().returns(projects) + @AuthenticationController.getLoggedInUserId = sinon.stub().returns 'abc' + done() + + it 'should produce a list of projects', (done) -> + @res.json = (data) => + expect(data).to.deep.equal { + projects: [ + {_id: 'b', name: 'B', accessLevel: 'b'}, + {_id: 'c', name: 'C', accessLevel: 'c'}, + {_id: 'd', name: 'D', accessLevel: 'd'} + ] + } + done() + @ProjectController.userProjectsJson @req, @res, @next + + describe 'projectEntitiesJson', -> + beforeEach () -> + @AuthenticationController.getLoggedInUserId = sinon.stub().returns 'abc' + @req.params = {Project_id: 'abcd'} + @project = { _id: 'abcd' } + @docs = [ + {path: '/things/b.txt', doc: true}, + {path: '/main.tex', doc: true} + ] + @files = [ + {path: '/things/a.txt'} + ] + @ProjectGetter.getProject = sinon.stub().callsArgWith(1, null, @project) + @ProjectEntityHandler.getAllEntitiesFromProject = sinon.stub().callsArgWith(1, null, @docs, @files) + + it 'should produce a list of entities', (done) -> + @res.json = (data) => + expect(data).to.deep.equal { + project_id: 'abcd', + entities: [ + {path: '/main.tex', type: 'doc'}, + {path: '/things/a.txt', type: 'file'}, + {path: '/things/b.txt', type: 'doc'} + ] + } + expect(@ProjectGetter.getProject.callCount).to.equal 1 + expect(@ProjectEntityHandler.getAllEntitiesFromProject.callCount).to.equal 1 + done() + @ProjectController.projectEntitiesJson @req, @res, @next + describe '_isInPercentageRollout', -> before -> @ids = [ diff --git a/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee new file mode 100644 index 0000000000..5cf52eca39 --- /dev/null +++ b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee @@ -0,0 +1,76 @@ +should = require('chai').should() +SandboxedModule = require('sandboxed-module') +assert = require('assert') +path = require('path') +sinon = require('sinon') +modulePath = '../../../../app/js/Features/Templates/TemplatesController' + + +describe 'TemplatesController', -> + + project_id = "213432" + + beforeEach -> + @request = sinon.stub() + @request.returns { + pipe:-> + on:-> + } + @fs = { + unlink : sinon.stub() + createWriteStream : sinon.stub().returns(on:(_, cb)->cb()) + } + @ProjectUploadManager = {createProjectFromZipArchive : sinon.stub().callsArgWith(3, null, {_id:project_id})} + @dumpFolder = "dump/path" + @ProjectOptionsHandler = {setCompiler:sinon.stub().callsArgWith(2)} + @uuid = "1234" + @ProjectDetailsHandler = + getProjectDescription:sinon.stub() + @Project = + update: sinon.stub().callsArgWith(3, null) + @controller = SandboxedModule.require modulePath, requires: + '../../../js/Features/Uploads/ProjectUploadManager':@ProjectUploadManager + '../../../js/Features/Project/ProjectOptionsHandler':@ProjectOptionsHandler + '../../../js/Features/Authentication/AuthenticationController': @AuthenticationController = {getLoggedInUserId: sinon.stub()} + './TemplatesPublisher':@TemplatesPublisher + "logger-sharelatex": + log:-> + err:-> + "settings-sharelatex": + path: + dumpFolder:@dumpFolder + siteUrl: @siteUrl = "http://localhost:3000" + apis: + v1: + url: @v1Url="http://overleaf.com" + user: "sharelatex" + pass: "password" + overleaf: + host: @v1Url + "uuid":v4:=>@uuid + "request": @request + "fs":@fs + "../../../../app/js/models/Project": {Project: @Project} + @zipUrl = "%2Ftemplates%2F52fb86a81ae1e566597a25f6%2Fv%2F4%2Fzip&templateName=Moderncv%20Banking&compiler=pdflatex" + @templateName = "project name here" + @user_id = "1234" + @req = + session: + user: _id:@user_id + templateData: + zipUrl: @zipUrl + templateName: @templateName + @redirect = {} + @AuthenticationController.getLoggedInUserId.returns(@user_id) + + describe 'v1Templates', -> + + it "should fetch zip from v1 based on template id", (done)-> + @templateVersionId = 15 + @req.body = {templateVersionId: @templateVersionId} + + redirect = => + @request.calledWith("#{@v1Url}/api/v1/sharelatex/templates/#{@templateVersionId}").should.equal true + done() + res = redirect:redirect + @controller.createProjectFromV1Template @req, res