diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js b/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js index 21e6f23a71..57f0459b96 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js @@ -9,6 +9,7 @@ const PrivilegeLevels = require('../Authorization/PrivilegeLevels') const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher') const CollaboratorsGetter = require('./CollaboratorsGetter') const Errors = require('../Errors/Errors') +const TpdsUpdateSender = require('../ThirdPartyDataStore/TpdsUpdateSender') module.exports = { userIsTokenMember: callbackify(userIsTokenMember), @@ -98,6 +99,8 @@ async function addUserIdToProject( privilegeLevel ) { const project = await ProjectGetter.promises.getProject(projectId, { + owner_ref: 1, + name: 1, collaberator_refs: 1, readOnly_refs: 1, }) @@ -127,6 +130,14 @@ async function addUserIdToProject( await Project.updateOne({ _id: projectId }, { $addToSet: level }).exec() + // Ensure there is a dedicated folder for this "new" project. + await TpdsUpdateSender.promises.createProject({ + projectId, + projectName: project.name, + ownerId: project.owner_ref, + userId, + }) + // Flush to TPDS in background to add files to collaborator's Dropbox TpdsProjectFlusher.promises.flushProjectToTpds(projectId).catch(err => { logger.error( diff --git a/services/web/app/src/Features/Project/ProjectCreationHandler.js b/services/web/app/src/Features/Project/ProjectCreationHandler.js index c7f589723f..bae235ffe4 100644 --- a/services/web/app/src/Features/Project/ProjectCreationHandler.js +++ b/services/web/app/src/Features/Project/ProjectCreationHandler.js @@ -15,6 +15,7 @@ const path = require('path') const { callbackify } = require('util') const _ = require('underscore') const AnalyticsManager = require('../Analytics/AnalyticsManager') +const TpdsUpdateSender = require('../ThirdPartyDataStore/TpdsUpdateSender') const MONTH_NAMES = [ 'January', @@ -35,9 +36,19 @@ const templateProjectDir = Features.hasFeature('saas') ? 'example-project' : 'example-project-sp' -async function createBlankProject(ownerId, projectName, attributes = {}) { +async function createBlankProject( + ownerId, + projectName, + attributes = {}, + options +) { const isImport = attributes && attributes.overleaf - const project = await _createBlankProject(ownerId, projectName, attributes) + const project = await _createBlankProject( + ownerId, + projectName, + attributes, + options + ) const segmentation = _.pick(attributes, [ 'fromV1TemplateId', 'fromV1TemplateVersionId', @@ -131,7 +142,12 @@ async function _addExampleProjectFiles(ownerId, projectName, project) { ) } -async function _createBlankProject(ownerId, projectName, attributes = {}) { +async function _createBlankProject( + ownerId, + projectName, + attributes = {}, + { skipCreatingInTPDS = false } = {} +) { metrics.inc('project-creation') const timer = new metrics.Timer('project-creation') await ProjectDetailsHandler.promises.validateProjectName(projectName) @@ -171,6 +187,14 @@ async function _createBlankProject(ownerId, projectName, attributes = {}) { const user = await User.findById(ownerId, 'ace.spellCheckLanguage') project.spellCheckLanguage = user.ace.spellCheckLanguage await project.save() + if (!skipCreatingInTPDS) { + await TpdsUpdateSender.promises.createProject({ + projectId: project._id, + projectName, + ownerId, + userId: ownerId, + }) + } timer.done() return project } diff --git a/services/web/app/src/Features/ThirdPartyDataStore/TpdsController.js b/services/web/app/src/Features/ThirdPartyDataStore/TpdsController.js index d68ce91553..82342176f8 100644 --- a/services/web/app/src/Features/ThirdPartyDataStore/TpdsController.js +++ b/services/web/app/src/Features/ThirdPartyDataStore/TpdsController.js @@ -7,9 +7,29 @@ const Path = require('path') const metrics = require('@overleaf/metrics') const NotificationsBuilder = require('../Notifications/NotificationsBuilder') const SessionManager = require('../Authentication/SessionManager') +const ProjectCreationHandler = require('../Project/ProjectCreationHandler') +const ProjectDetailsHandler = require('../Project/ProjectDetailsHandler') const HttpErrorHandler = require('../Errors/HttpErrorHandler') const TpdsQueueManager = require('./TpdsQueueManager') +async function createProject(req, res) { + const { user_id: userId } = req.params + let { projectName } = req.body + projectName = await ProjectDetailsHandler.promises.generateUniqueName( + userId, + projectName + ) + const project = await ProjectCreationHandler.promises.createBlankProject( + userId, + projectName, + {}, + { skipCreatingInTPDS: true } + ) + res.json({ + projectId: project._id.toString(), + }) +} + // mergeUpdate and deleteUpdate are used by Dropbox, where the project is only // passed as the name, as the first part of the file path. They have to check // the project exists, find it, and create it if not. They also ignore 'noisy' @@ -175,6 +195,7 @@ function splitPath(projectId, path) { } module.exports = { + createProject: expressify(createProject), mergeUpdate: expressify(mergeUpdate), deleteUpdate: expressify(deleteUpdate), updateFolder: expressify(updateFolder), diff --git a/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateSender.js b/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateSender.js index 2931d69758..747dfd1dbe 100644 --- a/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateSender.js +++ b/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateSender.js @@ -142,6 +142,33 @@ async function deleteEntity(params) { } } +async function createProject(params) { + if (!tpdsUrl) return // Server CE/Pro + + const { projectId, projectName, ownerId, userId } = params + + const job = { + method: 'post', + headers: { + sl_project_id: projectId.toString(), + sl_all_user_ids: JSON.stringify([userId.toString()]), + sl_project_owner_user_id: ownerId.toString(), + }, + uri: Path.join( + tpdsUrl, + 'user', + userId.toString(), + 'project', + 'new', + encodeURIComponent(projectName) + ), + title: 'createProject', + sl_all_user_ids: JSON.stringify([userId]), + } + + await enqueue(userId, 'standardHttpRequest', job) +} + async function deleteProject(params) { const { projectId } = params // deletion only applies to project archiver @@ -274,6 +301,7 @@ const TpdsUpdateSender = { addEntity: callbackify(addEntity), addFile: callbackify(addFile), deleteEntity: callbackify(deleteEntity), + createProject: callbackify(createProject), deleteProject: callbackify(deleteProject), enqueue: callbackify(enqueue), moveEntity: callbackify(moveEntity), @@ -283,6 +311,7 @@ const TpdsUpdateSender = { addEntity, addFile, deleteEntity, + createProject, deleteProject, enqueue, moveEntity, diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js index 50e1623722..77b0300a9a 100644 --- a/services/web/app/src/router.js +++ b/services/web/app/src/router.js @@ -908,6 +908,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) { DocumentController.setDocument ) + privateApiRouter.post( + '/user/:user_id/project/new', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.createProject + ) privateApiRouter.post( '/tpds/folder-update', AuthenticationController.requirePrivateApiAuth(), diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js index d4e324a07f..b3cad303d0 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js +++ b/services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js @@ -20,6 +20,8 @@ describe('CollaboratorsHandler', function () { this.addingUserId = ObjectId() this.project = { _id: ObjectId(), + owner_ref: this.addingUserId, + name: 'Foo', } this.archivedProject = { @@ -46,6 +48,11 @@ describe('CollaboratorsHandler', function () { flushProjectToTpds: sinon.stub().resolves(), }, } + this.TpdsUpdateSender = { + promises: { + createProject: sinon.stub().resolves(), + }, + } this.ProjectGetter = { promises: { getProject: sinon.stub().resolves(this.project), @@ -66,6 +73,7 @@ describe('CollaboratorsHandler', function () { '../Contacts/ContactManager': this.ContactManager, '../../models/Project': { Project }, '../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher, + '../ThirdPartyDataStore/TpdsUpdateSender': this.TpdsUpdateSender, '../Project/ProjectGetter': this.ProjectGetter, '../Project/ProjectHelper': this.ProjectHelper, './CollaboratorsGetter': this.CollaboratorsGetter, @@ -212,6 +220,17 @@ describe('CollaboratorsHandler', function () { ) }) + it('should create the project folder in dropbox', function () { + expect( + this.TpdsUpdateSender.promises.createProject + ).to.have.been.calledWith({ + projectId: this.project._id, + projectName: this.project.name, + ownerId: this.addingUserId, + userId: this.userId, + }) + }) + it('should flush the project to the TPDS', function () { expect( this.TpdsProjectFlusher.promises.flushProjectToTpds diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsControllerTests.js b/services/web/test/unit/src/ThirdPartyDataStore/TpdsControllerTests.js index b3fa2ddf57..1f9919fa7b 100644 --- a/services/web/test/unit/src/ThirdPartyDataStore/TpdsControllerTests.js +++ b/services/web/test/unit/src/ThirdPartyDataStore/TpdsControllerTests.js @@ -1,8 +1,10 @@ +const { ObjectId } = require('mongodb') +const { expect } = require('chai') const SandboxedModule = require('sandboxed-module') const sinon = require('sinon') -const { expect } = require('chai') -const { ObjectId } = require('mongodb') const Errors = require('../../../../app/src/Features/Errors/Errors') +const MockResponse = require('../helpers/MockResponse') +const MockRequest = require('../helpers/MockRequest') const MODULE_PATH = '../../../../app/src/Features/ThirdPartyDataStore/TpdsController.js' @@ -44,6 +46,15 @@ describe('TpdsController', function () { conflict: sinon.stub(), } + this.newProject = { _id: ObjectId() } + this.ProjectCreationHandler = { + promises: { createBlankProject: sinon.stub().resolves(this.newProject) }, + } + this.ProjectDetailsHandler = { + promises: { + generateUniqueName: sinon.stub().resolves('unique'), + }, + } this.TpdsController = SandboxedModule.require(MODULE_PATH, { requires: { './TpdsUpdateHandler': this.TpdsUpdateHandler, @@ -52,12 +63,42 @@ describe('TpdsController', function () { '../Authentication/SessionManager': this.SessionManager, '../Errors/HttpErrorHandler': this.HttpErrorHandler, './TpdsQueueManager': this.TpdsQueueManager, + '../Project/ProjectCreationHandler': this.ProjectCreationHandler, + '../Project/ProjectDetailsHandler': this.ProjectDetailsHandler, }, }) this.user_id = 'dsad29jlkjas' }) + describe('creating a project', function () { + it('should yield the new projects id', function (done) { + const res = new MockResponse() + const req = new MockRequest() + req.params.user_id = this.user_id + req.body = { projectName: 'foo' } + res.callback = err => { + if (err) done(err) + expect(res.body).to.equal( + JSON.stringify({ projectId: this.newProject._id.toString() }) + ) + expect( + this.ProjectDetailsHandler.promises.generateUniqueName + ).to.have.been.calledWith(this.user_id, 'foo') + expect( + this.ProjectCreationHandler.promises.createBlankProject + ).to.have.been.calledWith( + this.user_id, + 'unique', + {}, + { skipCreatingInTPDS: true } + ) + done() + } + this.TpdsController.createProject(req, res) + }) + }) + describe('getting an update', function () { beforeEach(function () { this.projectName = 'projectName'