first commit
This commit is contained in:
6
services/web/modules/history-v1/index.mjs
Normal file
6
services/web/modules/history-v1/index.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @import { WebModule } from "../../types/web-module" */
|
||||
|
||||
/** @type {WebModule} */
|
||||
const HistoryModule = {}
|
||||
|
||||
export default HistoryModule
|
@@ -0,0 +1,7 @@
|
||||
const base = require(process.env.BASE_CONFIG)
|
||||
|
||||
module.exports = base.mergeWith({
|
||||
test: {
|
||||
counterInit: 190000,
|
||||
},
|
||||
})
|
@@ -0,0 +1,338 @@
|
||||
import { expect } from 'chai'
|
||||
|
||||
import _ from 'lodash'
|
||||
import { db, ObjectId } from '../../../../../app/src/infrastructure/mongodb.js'
|
||||
import User from '../../../../../test/acceptance/src/helpers/User.mjs'
|
||||
import MockV1HistoryApiClass from '../../../../../test/acceptance/src/mocks/MockV1HistoryApi.mjs'
|
||||
|
||||
let MockV1HistoryApi
|
||||
|
||||
before(function () {
|
||||
MockV1HistoryApi = MockV1HistoryApiClass.instance()
|
||||
})
|
||||
|
||||
describe('History', function () {
|
||||
beforeEach(function (done) {
|
||||
this.owner = new User()
|
||||
this.owner.login(done)
|
||||
})
|
||||
|
||||
describe('zip download of version', function () {
|
||||
it('should stream the zip file of a version', function (done) {
|
||||
this.owner.createProject('example-project', (error, projectId) => {
|
||||
this.project_id = projectId
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.v1_history_id = 42
|
||||
db.projects.updateOne(
|
||||
{
|
||||
_id: new ObjectId(this.project_id),
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
'overleaf.history.id': this.v1_history_id,
|
||||
},
|
||||
},
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.owner.request(
|
||||
`/project/${this.project_id}/version/42/zip`,
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
expect(response.headers['content-type']).to.equal(
|
||||
'application/zip'
|
||||
)
|
||||
expect(response.headers['content-disposition']).to.equal(
|
||||
'attachment; filename="example-project (Version 42).zip"'
|
||||
)
|
||||
expect(body).to.equal(
|
||||
`Mock zip for ${this.v1_history_id} at version 42`
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('request abort', function () {
|
||||
// Optional manual verification: add unique logging statements into
|
||||
// HistoryController._pipeHistoryZipToResponse
|
||||
// in each of the `req.destroyed` branches and confirm that each branch
|
||||
// was covered.
|
||||
beforeEach(function setupNewProject(done) {
|
||||
this.owner.createProject('example-project', (error, projectId) => {
|
||||
this.project_id = projectId
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.v1_history_id = 42
|
||||
db.projects.updateOne(
|
||||
{ _id: new ObjectId(this.project_id) },
|
||||
{
|
||||
$set: {
|
||||
'overleaf.history.id': this.v1_history_id,
|
||||
},
|
||||
},
|
||||
done
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should abort the upstream request', function (done) {
|
||||
const request = this.owner.request(
|
||||
`/project/${this.project_id}/version/100/zip`
|
||||
)
|
||||
request.on('error', err => {
|
||||
if (err.code !== 'ECONNRESET') {
|
||||
done(err)
|
||||
}
|
||||
})
|
||||
request.on('response', response => {
|
||||
expect(response.statusCode).to.equal(200)
|
||||
let receivedChunks = 0
|
||||
response.on('data', () => {
|
||||
receivedChunks++
|
||||
})
|
||||
response.resume()
|
||||
|
||||
setTimeout(() => {
|
||||
request.abort()
|
||||
const receivedSoFar = receivedChunks
|
||||
const sentSoFar = MockV1HistoryApi.sentChunks
|
||||
// Ihe next assertions should verify that chunks are emitted
|
||||
// and received -- the exact number is not important.
|
||||
// In theory we are now emitting the 3rd chunk,
|
||||
// so this should be exactly 3, to not make this
|
||||
// test flaky, we allow +- 2 chunks.
|
||||
expect(sentSoFar).to.be.within(1, 4)
|
||||
expect(receivedSoFar).to.be.within(1, 4)
|
||||
setTimeout(() => {
|
||||
// The fake-s3 service should have stopped emitting chunks.
|
||||
// If not, that would be +5 in an ideal world (1 every 100ms).
|
||||
// On the happy-path (it stopped) it emitted +1 which was
|
||||
// in-flight and another +1 before it received the abort.
|
||||
expect(MockV1HistoryApi.sentChunks).to.be.below(sentSoFar + 5)
|
||||
expect(MockV1HistoryApi.sentChunks).to.be.within(
|
||||
sentSoFar,
|
||||
sentSoFar + 2
|
||||
)
|
||||
done()
|
||||
}, 500)
|
||||
}, 200)
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip the v1-history request', function (done) {
|
||||
const request = this.owner.request(
|
||||
`/project/${this.project_id}/version/100/zip`
|
||||
)
|
||||
setTimeout(() => {
|
||||
// This is a race-condition to abort the request after the
|
||||
// processing of all the the express middleware completed.
|
||||
// In case we abort before they complete, we do not hit our
|
||||
// abort logic, but express internal logic, which is OK.
|
||||
request.abort()
|
||||
}, 2)
|
||||
request.on('error', done)
|
||||
setTimeout(() => {
|
||||
expect(MockV1HistoryApi.requestedZipPacks).to.equal(0)
|
||||
done()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
it('should skip the async-polling', function (done) {
|
||||
const request = this.owner.request(
|
||||
`/project/${this.project_id}/version/100/zip`
|
||||
)
|
||||
MockV1HistoryApi.events.on('v1-history-pack-zip', () => {
|
||||
request.abort()
|
||||
})
|
||||
request.on('error', done)
|
||||
setTimeout(() => {
|
||||
expect(MockV1HistoryApi.fakeZipCall).to.equal(0)
|
||||
done()
|
||||
}, 3000) // initial polling delay is 2s
|
||||
})
|
||||
|
||||
it('should skip the upstream request', function (done) {
|
||||
const request = this.owner.request(
|
||||
`/project/${this.project_id}/version/100/zip`
|
||||
)
|
||||
MockV1HistoryApi.events.on('v1-history-pack-zip', () => {
|
||||
setTimeout(() => {
|
||||
request.abort()
|
||||
}, 1000)
|
||||
})
|
||||
request.on('error', done)
|
||||
setTimeout(() => {
|
||||
expect(MockV1HistoryApi.fakeZipCall).to.equal(0)
|
||||
done()
|
||||
}, 3000) // initial polling delay is 2s
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 402 for non-v2-history project', function (done) {
|
||||
this.owner.createProject('non-v2-project', (error, projectId) => {
|
||||
this.project_id = projectId
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
db.projects.updateOne(
|
||||
{
|
||||
_id: new ObjectId(this.project_id),
|
||||
},
|
||||
{
|
||||
$unset: {
|
||||
'overleaf.history.id': true,
|
||||
},
|
||||
},
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.owner.request(
|
||||
`/project/${this.project_id}/version/42/zip`,
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
expect(response.statusCode).to.equal(402)
|
||||
done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('zip download, with upstream 404', function () {
|
||||
beforeEach(function () {
|
||||
_.remove(
|
||||
MockV1HistoryApi.app._router.stack,
|
||||
appRoute =>
|
||||
appRoute.route?.path ===
|
||||
'/api/projects/:project_id/version/:version/zip'
|
||||
)
|
||||
MockV1HistoryApi.app.post(
|
||||
'/api/projects/:project_id/version/:version/zip',
|
||||
(req, res, next) => {
|
||||
res.sendStatus(404)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
MockV1HistoryApi = MockV1HistoryApiClass.instance()
|
||||
MockV1HistoryApi.reset()
|
||||
MockV1HistoryApi.applyRoutes()
|
||||
})
|
||||
|
||||
it('should produce 404 when post request produces 404', function (done) {
|
||||
this.owner.createProject('example-project', (error, projectId) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.project_id = projectId
|
||||
this.v1_history_id = 42
|
||||
db.projects.updateOne(
|
||||
{
|
||||
_id: new ObjectId(this.project_id),
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
'overleaf.history.id': this.v1_history_id,
|
||||
},
|
||||
},
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.owner.request(
|
||||
`/project/${this.project_id}/version/42/zip`,
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
expect(response.statusCode).to.equal(404)
|
||||
done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('zip download, with no zipUrl from upstream', function () {
|
||||
beforeEach(function () {
|
||||
_.remove(
|
||||
MockV1HistoryApi.app._router.stack,
|
||||
appRoute =>
|
||||
appRoute.route?.path ===
|
||||
'/api/projects/:project_id/version/:version/zip'
|
||||
)
|
||||
MockV1HistoryApi.app.get(
|
||||
'/api/projects/:project_id/version/:version/zip',
|
||||
(req, res, next) => {
|
||||
res.sendStatus(500)
|
||||
}
|
||||
)
|
||||
MockV1HistoryApi.app.post(
|
||||
'/api/projects/:project_id/version/:version/zip',
|
||||
(req, res, next) => {
|
||||
res.json({ message: 'lol' })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
MockV1HistoryApi = MockV1HistoryApiClass.instance()
|
||||
MockV1HistoryApi.reset()
|
||||
MockV1HistoryApi.applyRoutes()
|
||||
})
|
||||
|
||||
it('should produce 500', function (done) {
|
||||
this.owner.createProject('example-project', (error, projectId) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.project_id = projectId
|
||||
this.v1_history_id = 42
|
||||
db.projects.updateOne(
|
||||
{
|
||||
_id: new ObjectId(this.project_id),
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
'overleaf.history.id': this.v1_history_id,
|
||||
},
|
||||
},
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.owner.request(
|
||||
`/project/${this.project_id}/version/42/zip`,
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
expect(response.statusCode).to.equal(500)
|
||||
done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
22
services/web/modules/history-v1/test/acceptance/src/Init.mjs
Normal file
22
services/web/modules/history-v1/test/acceptance/src/Init.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import '../../../../../test/acceptance/src/helpers/InitApp.mjs'
|
||||
import MockDocstoreApi from '../../../../../test/acceptance/src/mocks/MockDocstoreApi.mjs'
|
||||
import MockDocUpdaterApi from '../../../../../test/acceptance/src/mocks/MockDocUpdaterApi.mjs'
|
||||
import MockFilestoreApi from '../../../../../test/acceptance/src/mocks/MockFilestoreApi.mjs'
|
||||
import MockNotificationsApi from '../../../../../test/acceptance/src/mocks/MockNotificationsApi.mjs'
|
||||
import MockProjectHistoryApi from '../../../../../test/acceptance/src/mocks/MockProjectHistoryApi.mjs'
|
||||
import MockSpellingApi from '../../../../../test/acceptance/src/mocks/MockSpellingApi.mjs'
|
||||
import MockV1Api from '../../../../../test/acceptance/src/mocks/MockV1Api.mjs'
|
||||
import MockV1HistoryApi from '../../../../../test/acceptance/src/mocks/MockV1HistoryApi.mjs'
|
||||
|
||||
const mockOpts = {
|
||||
debug: ['1', 'true', 'TRUE'].includes(process.env.DEBUG_MOCKS),
|
||||
}
|
||||
|
||||
MockDocstoreApi.initialize(23016, mockOpts)
|
||||
MockDocUpdaterApi.initialize(23003, mockOpts)
|
||||
MockFilestoreApi.initialize(23009, mockOpts)
|
||||
MockNotificationsApi.initialize(23042, mockOpts)
|
||||
MockProjectHistoryApi.initialize(23054, mockOpts)
|
||||
MockSpellingApi.initialize(23005, mockOpts)
|
||||
MockV1Api.initialize(25000, mockOpts)
|
||||
MockV1HistoryApi.initialize(23100, mockOpts)
|
@@ -0,0 +1,114 @@
|
||||
import { expect } from 'chai'
|
||||
import mongodb from 'mongodb-legacy'
|
||||
import User from '../../../../../test/acceptance/src/helpers/User.mjs'
|
||||
import MockProjectHistoryApiClass from '../../../../../test/acceptance/src/mocks/MockProjectHistoryApi.mjs'
|
||||
|
||||
const { ObjectId } = mongodb
|
||||
|
||||
let MockProjectHistoryApi
|
||||
|
||||
before(function () {
|
||||
MockProjectHistoryApi = MockProjectHistoryApiClass.instance()
|
||||
})
|
||||
|
||||
describe('Labels', function () {
|
||||
beforeEach(function (done) {
|
||||
this.owner = new User()
|
||||
this.owner.login(error => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
this.owner.createProject(
|
||||
'example-project',
|
||||
{ template: 'example' },
|
||||
(error, projectId) => {
|
||||
this.project_id = projectId
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('getting labels', function (done) {
|
||||
const labelId = new ObjectId().toString()
|
||||
const comment = 'a label comment'
|
||||
const version = 3
|
||||
MockProjectHistoryApi.addLabel(this.project_id, {
|
||||
id: labelId,
|
||||
comment,
|
||||
version,
|
||||
})
|
||||
|
||||
this.owner.request(
|
||||
{
|
||||
method: 'GET',
|
||||
url: `/project/${this.project_id}/labels`,
|
||||
json: true,
|
||||
},
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
expect(body).to.deep.equal([{ id: labelId, comment, version }])
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('creating a label', function (done) {
|
||||
const comment = 'a label comment'
|
||||
const version = 3
|
||||
|
||||
this.owner.request(
|
||||
{
|
||||
method: 'POST',
|
||||
url: `/project/${this.project_id}/labels`,
|
||||
json: { comment, version },
|
||||
},
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
const { label_id: labelId } = body
|
||||
expect(MockProjectHistoryApi.getLabels(this.project_id)).to.deep.equal([
|
||||
{ id: labelId, comment, version },
|
||||
])
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('deleting a label', function (done) {
|
||||
const labelId = new ObjectId().toString()
|
||||
const comment = 'a label comment'
|
||||
const version = 3
|
||||
MockProjectHistoryApi.addLabel(this.project_id, {
|
||||
id: labelId,
|
||||
comment,
|
||||
version,
|
||||
})
|
||||
|
||||
this.owner.request(
|
||||
{
|
||||
method: 'DELETE',
|
||||
url: `/project/${this.project_id}/labels/${labelId}`,
|
||||
json: true,
|
||||
},
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
expect(response.statusCode).to.equal(204)
|
||||
expect(MockProjectHistoryApi.getLabels(this.project_id)).to.deep.equal(
|
||||
[]
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
||||
import { expect } from 'chai'
|
||||
|
||||
import _ from 'lodash'
|
||||
import fs from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import Path from 'node:path'
|
||||
import User from '../../../../../test/acceptance/src/helpers/User.mjs'
|
||||
import MockProjectHistoryApiClass from '../../../../../test/acceptance/src/mocks/MockProjectHistoryApi.mjs'
|
||||
import MockDocstoreApiClass from '../../../../../test/acceptance/src/mocks/MockDocstoreApi.mjs'
|
||||
import MockFilestoreApiClass from '../../../../../test/acceptance/src/mocks/MockFilestoreApi.mjs'
|
||||
import MockV1HistoryApiClass from '../../../../../test/acceptance/src/mocks/MockV1HistoryApi.mjs'
|
||||
import Features from '../../../../../app/src/infrastructure/Features.js'
|
||||
|
||||
let MockProjectHistoryApi, MockDocstoreApi, MockFilestoreApi, MockV1HistoryApi
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
before(function () {
|
||||
MockProjectHistoryApi = MockProjectHistoryApiClass.instance()
|
||||
MockDocstoreApi = MockDocstoreApiClass.instance()
|
||||
MockFilestoreApi = MockFilestoreApiClass.instance()
|
||||
MockV1HistoryApi = MockV1HistoryApiClass.instance()
|
||||
})
|
||||
|
||||
describe('RestoringFiles', function () {
|
||||
beforeEach(function (done) {
|
||||
this.owner = new User()
|
||||
this.owner.login(error => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
this.owner.createProject(
|
||||
'example-project',
|
||||
{ template: 'example' },
|
||||
(error, projectId) => {
|
||||
this.project_id = projectId
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoring from v2 history', function () {
|
||||
describe('restoring a text file', function () {
|
||||
beforeEach(function (done) {
|
||||
MockProjectHistoryApi.addOldFile(
|
||||
this.project_id,
|
||||
42,
|
||||
'foo.tex',
|
||||
'hello world, this is foo.tex!'
|
||||
)
|
||||
this.owner.request(
|
||||
{
|
||||
method: 'POST',
|
||||
url: `/project/${this.project_id}/restore_file`,
|
||||
json: {
|
||||
pathname: 'foo.tex',
|
||||
version: 42,
|
||||
},
|
||||
},
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should have created a doc', function (done) {
|
||||
this.owner.getProject(this.project_id, (error, project) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
let doc = _.find(
|
||||
project.rootFolder[0].docs,
|
||||
doc => doc.name === 'foo.tex'
|
||||
)
|
||||
doc = MockDocstoreApi.docs[this.project_id][doc._id]
|
||||
expect(doc.lines).to.deep.equal(['hello world, this is foo.tex!'])
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoring a binary file', function () {
|
||||
beforeEach(function (done) {
|
||||
this.pngData = fs.readFileSync(
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../../../../../test/acceptance/files/1pixel.png'
|
||||
),
|
||||
'binary'
|
||||
)
|
||||
MockProjectHistoryApi.addOldFile(
|
||||
this.project_id,
|
||||
42,
|
||||
'image.png',
|
||||
this.pngData
|
||||
)
|
||||
this.owner.request(
|
||||
{
|
||||
method: 'POST',
|
||||
url: `/project/${this.project_id}/restore_file`,
|
||||
json: {
|
||||
pathname: 'image.png',
|
||||
version: 42,
|
||||
},
|
||||
},
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
if (Features.hasFeature('project-history-blobs')) {
|
||||
it('should have created a file in history-v1', function (done) {
|
||||
this.owner.getProject(this.project_id, (error, project) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
let file = _.find(
|
||||
project.rootFolder[0].fileRefs,
|
||||
file => file.name === 'image.png'
|
||||
)
|
||||
file =
|
||||
MockV1HistoryApi.blobs[project.overleaf.history.id.toString()][
|
||||
file.hash
|
||||
]
|
||||
expect(file).to.deep.equal(Buffer.from(this.pngData))
|
||||
done()
|
||||
})
|
||||
})
|
||||
}
|
||||
if (Features.hasFeature('filestore')) {
|
||||
it('should have created a file in filestore', function (done) {
|
||||
this.owner.getProject(this.project_id, (error, project) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
let file = _.find(
|
||||
project.rootFolder[0].fileRefs,
|
||||
file => file.name === 'image.png'
|
||||
)
|
||||
file = MockFilestoreApi.getFile(this.project_id, file._id)
|
||||
expect(file).to.deep.equal(this.pngData)
|
||||
done()
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('restoring to a directory that exists', function () {
|
||||
beforeEach(function (done) {
|
||||
MockProjectHistoryApi.addOldFile(
|
||||
this.project_id,
|
||||
42,
|
||||
'foldername/foo2.tex',
|
||||
'hello world, this is foo-2.tex!'
|
||||
)
|
||||
this.owner.request.post(
|
||||
{
|
||||
uri: `project/${this.project_id}/folder`,
|
||||
json: {
|
||||
name: 'foldername',
|
||||
},
|
||||
},
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
this.owner.request(
|
||||
{
|
||||
method: 'POST',
|
||||
url: `/project/${this.project_id}/restore_file`,
|
||||
json: {
|
||||
pathname: 'foldername/foo2.tex',
|
||||
version: 42,
|
||||
},
|
||||
},
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should have created the doc in the named folder', function (done) {
|
||||
this.owner.getProject(this.project_id, (error, project) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
const folder = _.find(
|
||||
project.rootFolder[0].folders,
|
||||
folder => folder.name === 'foldername'
|
||||
)
|
||||
let doc = _.find(folder.docs, doc => doc.name === 'foo2.tex')
|
||||
doc = MockDocstoreApi.docs[this.project_id][doc._id]
|
||||
expect(doc.lines).to.deep.equal(['hello world, this is foo-2.tex!'])
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoring to a directory that no longer exists', function () {
|
||||
beforeEach(function (done) {
|
||||
MockProjectHistoryApi.addOldFile(
|
||||
this.project_id,
|
||||
42,
|
||||
'nothere/foo3.tex',
|
||||
'hello world, this is foo-3.tex!'
|
||||
)
|
||||
this.owner.request(
|
||||
{
|
||||
method: 'POST',
|
||||
url: `/project/${this.project_id}/restore_file`,
|
||||
json: {
|
||||
pathname: 'nothere/foo3.tex',
|
||||
version: 42,
|
||||
},
|
||||
},
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should have created the folder and restored the doc to it', function (done) {
|
||||
this.owner.getProject(this.project_id, (error, project) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
const folder = _.find(
|
||||
project.rootFolder[0].folders,
|
||||
folder => folder.name === 'nothere'
|
||||
)
|
||||
expect(folder).to.exist
|
||||
let doc = _.find(folder.docs, doc => doc.name === 'foo3.tex')
|
||||
doc = MockDocstoreApi.docs[this.project_id][doc._id]
|
||||
expect(doc.lines).to.deep.equal(['hello world, this is foo-3.tex!'])
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoring to a filename that already exists', function () {
|
||||
beforeEach(function (done) {
|
||||
MockProjectHistoryApi.addOldFile(
|
||||
this.project_id,
|
||||
42,
|
||||
'main.tex',
|
||||
'hello world, this is main.tex!'
|
||||
)
|
||||
this.owner.request(
|
||||
{
|
||||
method: 'POST',
|
||||
url: `/project/${this.project_id}/restore_file`,
|
||||
json: {
|
||||
pathname: 'main.tex',
|
||||
version: 42,
|
||||
},
|
||||
},
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should have created the doc in the root folder', function (done) {
|
||||
this.owner.getProject(this.project_id, (error, project) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
let doc = _.find(project.rootFolder[0].docs, doc =>
|
||||
doc.name.match(/main \(Restored on/)
|
||||
)
|
||||
expect(doc).to.exist
|
||||
doc = MockDocstoreApi.docs[this.project_id][doc._id]
|
||||
expect(doc.lines).to.deep.equal(['hello world, this is main.tex!'])
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1 @@
|
||||
{ "extends": "../../../../tsconfig.backend.json" }
|
256
services/web/modules/launchpad/app/src/LaunchpadController.mjs
Normal file
256
services/web/modules/launchpad/app/src/LaunchpadController.mjs
Normal file
@@ -0,0 +1,256 @@
|
||||
import OError from '@overleaf/o-error'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import Settings from '@overleaf/settings'
|
||||
import Path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import logger from '@overleaf/logger'
|
||||
import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.js'
|
||||
import EmailHandler from '../../../../app/src/Features/Email/EmailHandler.js'
|
||||
import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
|
||||
import { User } from '../../../../app/src/models/User.js'
|
||||
import AuthenticationManager from '../../../../app/src/Features/Authentication/AuthenticationManager.js'
|
||||
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
|
||||
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.js'
|
||||
import { hasAdminAccess } from '../../../../app/src/Features/Helpers/AdminAuthorizationHelper.js'
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
/**
|
||||
* Container for functions that need to be mocked in tests
|
||||
*
|
||||
* TODO: Rewrite tests in terms of exported functions only
|
||||
*/
|
||||
const _mocks = {}
|
||||
|
||||
_mocks._atLeastOneAdminExists = async () => {
|
||||
const user = await UserGetter.promises.getUser(
|
||||
{ isAdmin: true },
|
||||
{ _id: 1, isAdmin: 1 }
|
||||
)
|
||||
return Boolean(user)
|
||||
}
|
||||
|
||||
async function _atLeastOneAdminExists() {
|
||||
return await _mocks._atLeastOneAdminExists()
|
||||
}
|
||||
|
||||
function getAuthMethod() {
|
||||
if (Settings.ldap) {
|
||||
return 'ldap'
|
||||
} else if (Settings.saml) {
|
||||
return 'saml'
|
||||
} else {
|
||||
return 'local'
|
||||
}
|
||||
}
|
||||
|
||||
async function launchpadPage(req, res) {
|
||||
// TODO: check if we're using external auth?
|
||||
// * how does all this work with ldap and saml?
|
||||
const sessionUser = SessionManager.getSessionUser(req.session)
|
||||
const authMethod = getAuthMethod()
|
||||
const adminUserExists = await _atLeastOneAdminExists()
|
||||
|
||||
if (!sessionUser) {
|
||||
if (!adminUserExists) {
|
||||
res.render(Path.resolve(__dirname, '../views/launchpad'), {
|
||||
adminUserExists,
|
||||
authMethod,
|
||||
})
|
||||
} else {
|
||||
AuthenticationController.setRedirectInSession(req)
|
||||
res.redirect('/login')
|
||||
}
|
||||
} else {
|
||||
const user = await UserGetter.promises.getUser(sessionUser._id, {
|
||||
isAdmin: 1,
|
||||
})
|
||||
if (hasAdminAccess(user)) {
|
||||
res.render(Path.resolve(__dirname, '../views/launchpad'), {
|
||||
wsUrl: Settings.wsUrl,
|
||||
adminUserExists,
|
||||
authMethod,
|
||||
})
|
||||
} else {
|
||||
res.redirect('/restricted')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTestEmail(req, res) {
|
||||
const { email } = req.body
|
||||
if (!email) {
|
||||
logger.debug({}, 'no email address supplied')
|
||||
return res.status(400).json({
|
||||
message: 'no email address supplied',
|
||||
})
|
||||
}
|
||||
logger.debug({ email }, 'sending test email')
|
||||
const emailOptions = { to: email }
|
||||
try {
|
||||
await EmailHandler.promises.sendEmail('testEmail', emailOptions)
|
||||
logger.debug({ email }, 'sent test email')
|
||||
res.json({ message: res.locals.translate('email_sent') })
|
||||
} catch (err) {
|
||||
OError.tag(err, 'error sending test email', {
|
||||
email,
|
||||
})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function registerExternalAuthAdmin(authMethod) {
|
||||
return expressify(async function (req, res) {
|
||||
if (getAuthMethod() !== authMethod) {
|
||||
logger.debug(
|
||||
{ authMethod },
|
||||
'trying to register external admin, but that auth service is not enabled, disallow'
|
||||
)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const { email } = req.body
|
||||
if (!email) {
|
||||
logger.debug({ authMethod }, 'no email supplied, disallow')
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
logger.debug({ email }, 'attempted register first admin user')
|
||||
|
||||
const exists = await _atLeastOneAdminExists()
|
||||
|
||||
if (exists) {
|
||||
logger.debug({ email }, 'already have at least one admin user, disallow')
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const body = {
|
||||
email,
|
||||
password: 'password_here',
|
||||
first_name: email,
|
||||
last_name: '',
|
||||
}
|
||||
logger.debug(
|
||||
{ body, authMethod },
|
||||
'creating admin account for specified external-auth user'
|
||||
)
|
||||
|
||||
let user
|
||||
try {
|
||||
user = await UserRegistrationHandler.promises.registerNewUser(body)
|
||||
} catch (err) {
|
||||
OError.tag(err, 'error with registerNewUser', {
|
||||
email,
|
||||
authMethod,
|
||||
})
|
||||
throw err
|
||||
}
|
||||
|
||||
try {
|
||||
const reversedHostname = user.email
|
||||
.split('@')[1]
|
||||
.split('')
|
||||
.reverse()
|
||||
.join('')
|
||||
await User.updateOne(
|
||||
{ _id: user._id },
|
||||
{
|
||||
$set: { isAdmin: true, emails: [{ email, reversedHostname }] },
|
||||
}
|
||||
).exec()
|
||||
} catch (err) {
|
||||
OError.tag(err, 'error setting user to admin', {
|
||||
user_id: user._id,
|
||||
})
|
||||
throw err
|
||||
}
|
||||
|
||||
AuthenticationController.setRedirectInSession(req, '/launchpad')
|
||||
logger.debug(
|
||||
{ email, userId: user._id, authMethod },
|
||||
'created first admin account'
|
||||
)
|
||||
|
||||
res.json({ redir: '/launchpad', email })
|
||||
})
|
||||
}
|
||||
|
||||
async function registerAdmin(req, res) {
|
||||
const { email } = req.body
|
||||
const { password } = req.body
|
||||
if (!email || !password) {
|
||||
logger.debug({}, 'must supply both email and password, disallow')
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
logger.debug({ email }, 'attempted register first admin user')
|
||||
const exists = await _atLeastOneAdminExists()
|
||||
|
||||
if (exists) {
|
||||
logger.debug(
|
||||
{ email: req.body.email },
|
||||
'already have at least one admin user, disallow'
|
||||
)
|
||||
return res.status(403).json({
|
||||
message: { type: 'error', text: 'admin user already exists' },
|
||||
})
|
||||
}
|
||||
|
||||
const invalidEmail = AuthenticationManager.validateEmail(email)
|
||||
if (invalidEmail) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: { type: 'error', text: invalidEmail.message } })
|
||||
}
|
||||
|
||||
const invalidPassword = AuthenticationManager.validatePassword(
|
||||
password,
|
||||
email
|
||||
)
|
||||
if (invalidPassword) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: { type: 'error', text: invalidPassword.message } })
|
||||
}
|
||||
|
||||
const body = { email, password }
|
||||
|
||||
const user = await UserRegistrationHandler.promises.registerNewUser(body)
|
||||
|
||||
logger.debug({ userId: user._id }, 'making user an admin')
|
||||
|
||||
try {
|
||||
const reversedHostname = user.email
|
||||
.split('@')[1]
|
||||
.split('')
|
||||
.reverse()
|
||||
.join('')
|
||||
await User.updateOne(
|
||||
{ _id: user._id },
|
||||
{
|
||||
$set: {
|
||||
isAdmin: true,
|
||||
emails: [{ email, reversedHostname }],
|
||||
},
|
||||
}
|
||||
).exec()
|
||||
} catch (err) {
|
||||
OError.tag(err, 'error setting user to admin', {
|
||||
user_id: user._id,
|
||||
})
|
||||
throw err
|
||||
}
|
||||
|
||||
logger.debug({ email, userId: user._id }, 'created first admin account')
|
||||
res.json({ redir: '/launchpad' })
|
||||
}
|
||||
|
||||
const LaunchpadController = {
|
||||
launchpadPage: expressify(launchpadPage),
|
||||
registerAdmin: expressify(registerAdmin),
|
||||
registerExternalAuthAdmin,
|
||||
sendTestEmail: expressify(sendTestEmail),
|
||||
_atLeastOneAdminExists,
|
||||
_mocks,
|
||||
}
|
||||
|
||||
export default LaunchpadController
|
43
services/web/modules/launchpad/app/src/LaunchpadRouter.mjs
Normal file
43
services/web/modules/launchpad/app/src/LaunchpadRouter.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import logger from '@overleaf/logger'
|
||||
|
||||
import LaunchpadController from './LaunchpadController.mjs'
|
||||
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
|
||||
import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.js'
|
||||
|
||||
export default {
|
||||
apply(webRouter) {
|
||||
logger.debug({}, 'Init launchpad router')
|
||||
|
||||
webRouter.get('/launchpad', LaunchpadController.launchpadPage)
|
||||
webRouter.post(
|
||||
'/launchpad/register_admin',
|
||||
LaunchpadController.registerAdmin
|
||||
)
|
||||
webRouter.post(
|
||||
'/launchpad/register_ldap_admin',
|
||||
LaunchpadController.registerExternalAuthAdmin('ldap')
|
||||
)
|
||||
webRouter.post(
|
||||
'/launchpad/register_saml_admin',
|
||||
LaunchpadController.registerExternalAuthAdmin('saml')
|
||||
)
|
||||
webRouter.post(
|
||||
'/launchpad/send_test_email',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
LaunchpadController.sendTestEmail
|
||||
)
|
||||
|
||||
if (AuthenticationController.addEndpointToLoginWhitelist) {
|
||||
AuthenticationController.addEndpointToLoginWhitelist('/launchpad')
|
||||
AuthenticationController.addEndpointToLoginWhitelist(
|
||||
'/launchpad/register_admin'
|
||||
)
|
||||
AuthenticationController.addEndpointToLoginWhitelist(
|
||||
'/launchpad/register_ldap_admin'
|
||||
)
|
||||
AuthenticationController.addEndpointToLoginWhitelist(
|
||||
'/launchpad/register_saml_admin'
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
1
services/web/modules/launchpad/app/src/tsconfig.json
Normal file
1
services/web/modules/launchpad/app/src/tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "extends": "../../../../tsconfig.backend.json" }
|
226
services/web/modules/launchpad/app/views/launchpad.pug
Normal file
226
services/web/modules/launchpad/app/views/launchpad.pug
Normal file
@@ -0,0 +1,226 @@
|
||||
extends ../../../../app/views/layout-marketing
|
||||
|
||||
mixin launchpad-check(section)
|
||||
div(data-ol-launchpad-check=section)
|
||||
span(data-ol-inflight="pending")
|
||||
i.fa.fa-fw.fa-spinner.fa-spin
|
||||
span #{translate('checking')}
|
||||
|
||||
span(hidden data-ol-inflight="idle")
|
||||
div(data-ol-result="success")
|
||||
i.fa.fa-check
|
||||
span #{translate('ok')}
|
||||
button.btn.btn-inline-link
|
||||
span.text-danger #{translate('retry')}
|
||||
div(hidden data-ol-result="error")
|
||||
i.fa.fa-exclamation
|
||||
span #{translate('error')}
|
||||
button.btn.btn-inline-link
|
||||
span.text-danger #{translate('retry')}
|
||||
div.alert.alert-danger
|
||||
span(data-ol-error)
|
||||
|
||||
block entrypointVar
|
||||
- entrypoint = 'modules/launchpad/pages/launchpad'
|
||||
|
||||
block vars
|
||||
- metadata = metadata || {}
|
||||
- bootstrap5PageStatus = 'disabled'
|
||||
|
||||
block append meta
|
||||
meta(name="ol-adminUserExists" data-type="boolean" content=adminUserExists)
|
||||
meta(name="ol-ideJsPath" content=buildJsPath('ide.js'))
|
||||
|
||||
block content
|
||||
script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js')
|
||||
|
||||
.content.content-alt#main-content
|
||||
.container
|
||||
.row
|
||||
.col-md-8.col-md-offset-2
|
||||
.card.launchpad-body
|
||||
.row
|
||||
.col-md-12
|
||||
.text-center
|
||||
h1 #{translate('welcome_to_sl')}
|
||||
p
|
||||
img(src=buildImgPath('/ol-brand/overleaf-o.svg'))
|
||||
|
||||
<!-- wrapper -->
|
||||
.row
|
||||
.col-md-8.col-md-offset-2
|
||||
|
||||
|
||||
<!-- create first admin form -->
|
||||
if !adminUserExists
|
||||
.row(data-ol-not-sent)
|
||||
.col-md-12
|
||||
h2 #{translate('create_first_admin_account')}
|
||||
|
||||
// Local Auth Form
|
||||
if authMethod === 'local'
|
||||
form(
|
||||
data-ol-async-form
|
||||
data-ol-register-admin
|
||||
action="/launchpad/register_admin"
|
||||
method="POST"
|
||||
)
|
||||
input(name='_csrf', type='hidden', value=csrfToken)
|
||||
+formMessages()
|
||||
.form-group
|
||||
label(for='email') #{translate("email")}
|
||||
input.form-control(
|
||||
type='email',
|
||||
name='email',
|
||||
placeholder="email@example.com"
|
||||
autocomplete="username"
|
||||
required,
|
||||
autofocus="true"
|
||||
)
|
||||
.form-group
|
||||
label(for='password') #{translate("password")}
|
||||
input.form-control#passwordField(
|
||||
type='password',
|
||||
name='password',
|
||||
placeholder="********",
|
||||
autocomplete="new-password"
|
||||
required,
|
||||
)
|
||||
.actions
|
||||
button.btn-primary.btn(
|
||||
type='submit'
|
||||
data-ol-disabled-inflight
|
||||
)
|
||||
span(data-ol-inflight="idle") #{translate("register")}
|
||||
span(hidden data-ol-inflight="pending") #{translate("registering")}…
|
||||
|
||||
// Ldap Form
|
||||
if authMethod === 'ldap'
|
||||
h3 #{translate('ldap')}
|
||||
p
|
||||
| #{translate('ldap_create_admin_instructions')}
|
||||
|
||||
form(
|
||||
data-ol-async-form
|
||||
data-ol-register-admin
|
||||
action="/launchpad/register_ldap_admin"
|
||||
method="POST"
|
||||
)
|
||||
input(name='_csrf', type='hidden', value=csrfToken)
|
||||
+formMessages()
|
||||
.form-group
|
||||
label(for='email') #{translate("email")}
|
||||
input.form-control(
|
||||
type='email',
|
||||
name='email',
|
||||
placeholder="email@example.com"
|
||||
autocomplete="username"
|
||||
required,
|
||||
autofocus="true"
|
||||
)
|
||||
.actions
|
||||
button.btn-primary.btn(
|
||||
type='submit'
|
||||
data-ol-disabled-inflight
|
||||
)
|
||||
span(data-ol-inflight="idle") #{translate("register")}
|
||||
span(hidden data-ol-inflight="pending") #{translate("registering")}…
|
||||
|
||||
// Saml Form
|
||||
if authMethod === 'saml'
|
||||
h3 #{translate('saml')}
|
||||
p
|
||||
| #{translate('saml_create_admin_instructions')}
|
||||
|
||||
form(
|
||||
data-ol-async-form
|
||||
data-ol-register-admin
|
||||
action="/launchpad/register_saml_admin"
|
||||
method="POST"
|
||||
)
|
||||
input(name='_csrf', type='hidden', value=csrfToken)
|
||||
+formMessages()
|
||||
.form-group
|
||||
label(for='email') #{translate("email")}
|
||||
input.form-control(
|
||||
type='email',
|
||||
name='email',
|
||||
placeholder="email@example.com"
|
||||
autocomplete="username"
|
||||
required,
|
||||
autofocus="true"
|
||||
)
|
||||
.actions
|
||||
button.btn-primary.btn(
|
||||
type='submit'
|
||||
data-ol-disabled-inflight
|
||||
)
|
||||
span(data-ol-inflight="idle") #{translate("register")}
|
||||
span(hidden data-ol-inflight="pending") #{translate("registering")}…
|
||||
|
||||
br
|
||||
|
||||
<!-- status indicators -->
|
||||
if adminUserExists
|
||||
.row
|
||||
.col-md-12.status-indicators
|
||||
|
||||
h2 #{translate('status_checks')}
|
||||
|
||||
<!-- websocket -->
|
||||
.row.row-spaced-small
|
||||
.col-sm-5
|
||||
| #{translate('websockets')}
|
||||
.col-sm-7
|
||||
+launchpad-check('websocket')
|
||||
|
||||
<!-- break -->
|
||||
hr.thin
|
||||
|
||||
<!-- other actions -->
|
||||
.row
|
||||
.col-md-12
|
||||
h2 #{translate('other_actions')}
|
||||
|
||||
h3 #{translate('send_test_email')}
|
||||
form.form(
|
||||
data-ol-async-form
|
||||
action="/launchpad/send_test_email"
|
||||
method="POST"
|
||||
)
|
||||
.form-group
|
||||
label(for="email") Email
|
||||
input.form-control(
|
||||
type="text"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
)
|
||||
button.btn-primary.btn(
|
||||
type='submit'
|
||||
data-ol-disabled-inflight
|
||||
)
|
||||
span(data-ol-inflight="idle") #{translate("send")}
|
||||
span(hidden data-ol-inflight="pending") #{translate("sending")}…
|
||||
|
||||
p
|
||||
+formMessages()
|
||||
|
||||
|
||||
|
||||
<!-- break -->
|
||||
hr.thin
|
||||
|
||||
|
||||
<!-- Go to app -->
|
||||
.row
|
||||
.col-md-12
|
||||
.text-center
|
||||
br
|
||||
p
|
||||
a(href="/admin").btn.btn-info
|
||||
| Go To Admin Panel
|
||||
|
|
||||
a(href="/project").btn.btn-primary
|
||||
| Start Using #{settings.appName}
|
||||
br
|
@@ -0,0 +1,82 @@
|
||||
/* global io */
|
||||
import '@/marketing'
|
||||
import {
|
||||
inflightHelper,
|
||||
toggleDisplay,
|
||||
} from '../../../../../frontend/js/features/form-helpers/hydrate-form'
|
||||
import getMeta from '../../../../../frontend/js/utils/meta'
|
||||
|
||||
function setUpStatusIndicator(el, fn) {
|
||||
inflightHelper(el)
|
||||
|
||||
const displaySuccess = el.querySelectorAll('[data-ol-result="success"]')
|
||||
const displayError = el.querySelectorAll('[data-ol-result="error"]')
|
||||
|
||||
// The checks are very lightweight and do not appear to do anything
|
||||
// from looking at the UI. Add an artificial delay of 1s to show that
|
||||
// we are actually doing something. :)
|
||||
const artificialProgressDelay = 1000
|
||||
|
||||
function run() {
|
||||
setTimeout(() => {
|
||||
fn()
|
||||
.then(() => {
|
||||
toggleDisplay(displayError, displaySuccess)
|
||||
})
|
||||
.catch(error => {
|
||||
el.querySelector('[data-ol-error]').textContent = error.message
|
||||
toggleDisplay(displaySuccess, displayError)
|
||||
})
|
||||
.finally(() => {
|
||||
el.dispatchEvent(new Event('idle'))
|
||||
})
|
||||
}, artificialProgressDelay)
|
||||
}
|
||||
|
||||
el.querySelectorAll('button').forEach(retryBtn => {
|
||||
retryBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault()
|
||||
el.dispatchEvent(new Event('pending'))
|
||||
run()
|
||||
})
|
||||
})
|
||||
|
||||
run()
|
||||
}
|
||||
|
||||
function setUpStatusIndicators() {
|
||||
setUpStatusIndicator(
|
||||
document.querySelector('[data-ol-launchpad-check="websocket"]'),
|
||||
() => {
|
||||
const timeout = 10 * 1000
|
||||
const socket = io.connect(null, {
|
||||
reconnect: false,
|
||||
'connect timeout': timeout,
|
||||
'force new connection': true,
|
||||
query: new URLSearchParams({
|
||||
projectId: '404404404404404404404404',
|
||||
}).toString(),
|
||||
})
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => reject(new Error('timed out')), timeout)
|
||||
socket.on('connectionRejected', function (err) {
|
||||
if (err.code === 'ProjectNotFound') {
|
||||
// We received the response from joinProject, so the websocket is up.
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(err && err.message))
|
||||
}
|
||||
})
|
||||
socket.on('connect_failed', function (err) {
|
||||
reject(new Error(err && err.message))
|
||||
})
|
||||
}).finally(() => {
|
||||
socket.disconnect()
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (getMeta('ol-adminUserExists')) {
|
||||
setUpStatusIndicators()
|
||||
}
|
10
services/web/modules/launchpad/index.mjs
Normal file
10
services/web/modules/launchpad/index.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
import LaunchpadRouter from './app/src/LaunchpadRouter.mjs'
|
||||
|
||||
/** @import { WebModule } from "../../types/web-module" */
|
||||
|
||||
/** @type {WebModule} */
|
||||
const LaunchpadModule = {
|
||||
router: LaunchpadRouter,
|
||||
}
|
||||
|
||||
export default LaunchpadModule
|
@@ -0,0 +1,8 @@
|
||||
const base = require(process.env.BASE_CONFIG)
|
||||
|
||||
module.exports = base.mergeWith({
|
||||
enableLegacyLogin: true,
|
||||
test: {
|
||||
counterInit: 210000,
|
||||
},
|
||||
})
|
@@ -0,0 +1 @@
|
||||
import '../../../../../test/acceptance/src/helpers/InitApp.mjs'
|
@@ -0,0 +1,83 @@
|
||||
import { expect } from 'chai'
|
||||
import cheerio from 'cheerio'
|
||||
import UserHelper from '../../../../../test/acceptance/src/helpers/UserHelper.mjs'
|
||||
|
||||
describe('Launchpad', function () {
|
||||
const adminEmail = 'admin@example.com'
|
||||
const adminPassword = 'adreadfulsecret'
|
||||
const user = new UserHelper()
|
||||
|
||||
it('should show the launchpad page', async function () {
|
||||
const response = await user.fetch('/launchpad')
|
||||
expect(response.status).to.equal(200)
|
||||
const body = await response.text()
|
||||
const $ = cheerio.load(body)
|
||||
expect($('h2').first().text()).to.equal('Create the first Admin account')
|
||||
expect($('form[name="email"]').first()).to.exist
|
||||
expect($('form[name="password"]').first()).to.exist
|
||||
})
|
||||
|
||||
it('should allow for creation of the first admin user', async function () {
|
||||
// Load the launchpad page
|
||||
const initialPageResponse = await user.fetch('/launchpad')
|
||||
expect(initialPageResponse.status).to.equal(200)
|
||||
const initialPageBody = await initialPageResponse.text()
|
||||
const $ = cheerio.load(initialPageBody)
|
||||
expect($('h2').first().text()).to.equal('Create the first Admin account')
|
||||
expect($('form[name="email"]').first()).to.exist
|
||||
expect($('form[name="password"]').first()).to.exist
|
||||
|
||||
// Submit the form
|
||||
let csrfToken = await user.getCsrfToken()
|
||||
const postResponse = await user.fetch('/launchpad/register_admin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
_csrf: csrfToken,
|
||||
email: adminEmail,
|
||||
password: adminPassword,
|
||||
}),
|
||||
})
|
||||
expect(postResponse.status).to.equal(200)
|
||||
const postBody = await postResponse.json()
|
||||
expect(postBody).to.deep.equal({ redir: '/launchpad' })
|
||||
|
||||
// Try to load the page again
|
||||
const secondPageResponse = await user.fetch('/launchpad')
|
||||
expect(secondPageResponse.status).to.equal(302)
|
||||
expect(secondPageResponse.headers.get('location')).to.equal(
|
||||
UserHelper.url('/login').toString()
|
||||
)
|
||||
|
||||
// Forbid submitting the form again
|
||||
csrfToken = await user.getCsrfToken()
|
||||
const badPostResponse = await user.fetch('/launchpad/register_admin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
_csrf: csrfToken,
|
||||
email: adminEmail + '1',
|
||||
password: adminPassword + '1',
|
||||
}),
|
||||
})
|
||||
expect(badPostResponse.status).to.equal(403)
|
||||
|
||||
// Log in as this new admin user
|
||||
const adminUser = await UserHelper.loginUser({
|
||||
email: adminEmail,
|
||||
password: adminPassword,
|
||||
})
|
||||
// Check we are actually admin
|
||||
expect(await adminUser.isLoggedIn()).to.equal(true)
|
||||
expect(adminUser.user.isAdmin).to.equal(true)
|
||||
|
||||
// Check reversedHostName is stored
|
||||
expect(adminUser.user.emails[0].reversedHostname).to.equal('moc.elpmaxe')
|
||||
})
|
||||
})
|
@@ -0,0 +1 @@
|
||||
{ "extends": "../../../../tsconfig.backend.json" }
|
File diff suppressed because it is too large
Load Diff
1
services/web/modules/launchpad/test/unit/tsconfig.json
Normal file
1
services/web/modules/launchpad/test/unit/tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "extends": "../../../../tsconfig.backend.json" }
|
6
services/web/modules/server-ce-scripts/index.js
Normal file
6
services/web/modules/server-ce-scripts/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @import { WebModule } from "../../types/web-module" */
|
||||
|
||||
/** @type {WebModule} */
|
||||
const ServerCeScriptsModule = {}
|
||||
|
||||
module.exports = ServerCeScriptsModule
|
@@ -0,0 +1,44 @@
|
||||
import minimist from 'minimist'
|
||||
import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
|
||||
|
||||
async function main() {
|
||||
const argv = minimist(process.argv.slice(2), {
|
||||
string: ['user-id', 'compile-timeout'],
|
||||
})
|
||||
|
||||
const { 'user-id': userId, 'compile-timeout': rawCompileTimeout } = argv
|
||||
const compileTimeout = parseInt(rawCompileTimeout, 10)
|
||||
if (
|
||||
!userId ||
|
||||
!ObjectId.isValid(userId) ||
|
||||
!rawCompileTimeout ||
|
||||
Number.isNaN(compileTimeout)
|
||||
) {
|
||||
console.error(
|
||||
`Usage: node ${import.meta.filename} --user-id=5a9414f259776c7900b300e6 --timeout=90`
|
||||
)
|
||||
process.exit(101)
|
||||
}
|
||||
|
||||
if (compileTimeout < 1 || compileTimeout > 600) {
|
||||
console.error(
|
||||
`The compile timeout must be positive number of seconds, below 10 minutes (600).`
|
||||
)
|
||||
process.exit(101)
|
||||
}
|
||||
|
||||
await db.users.updateOne(
|
||||
{ _id: new ObjectId(userId) },
|
||||
{ $set: { 'features.compileTimeout': compileTimeout } }
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
console.error('Done.')
|
||||
process.exit(0)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
@@ -0,0 +1,64 @@
|
||||
import mongodb from 'mongodb-legacy'
|
||||
import {
|
||||
connectionPromise,
|
||||
db,
|
||||
} from '../../../app/src/infrastructure/mongodb.js'
|
||||
|
||||
const { ObjectId } = mongodb
|
||||
|
||||
const MIN_MONGO_VERSION = [5, 0]
|
||||
|
||||
async function main() {
|
||||
let mongoClient
|
||||
try {
|
||||
mongoClient = await connectionPromise
|
||||
} catch (err) {
|
||||
console.error('Cannot connect to mongodb')
|
||||
throw err
|
||||
}
|
||||
|
||||
await checkMongoVersion(mongoClient)
|
||||
|
||||
try {
|
||||
await testTransactions(mongoClient)
|
||||
} catch (err) {
|
||||
console.error("Mongo instance doesn't support transactions")
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function testTransactions(mongoClient) {
|
||||
const session = mongoClient.startSession()
|
||||
try {
|
||||
await session.withTransaction(async () => {
|
||||
await db.users.findOne({ _id: new ObjectId() }, { session })
|
||||
})
|
||||
} finally {
|
||||
await session.endSession()
|
||||
}
|
||||
}
|
||||
|
||||
async function checkMongoVersion(mongoClient) {
|
||||
const buildInfo = await mongoClient.db().admin().buildInfo()
|
||||
const [major, minor] = buildInfo.versionArray
|
||||
const [minMajor, minMinor] = MIN_MONGO_VERSION
|
||||
|
||||
if (major < minMajor || (major === minMajor && minor < minMinor)) {
|
||||
const version = buildInfo.version
|
||||
const minVersion = MIN_MONGO_VERSION.join('.')
|
||||
console.error(
|
||||
`The MongoDB server has version ${version}, but Overleaf requires at least version ${minVersion}. Aborting.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
console.error('Mongodb is up.')
|
||||
process.exit(0)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
@@ -0,0 +1,18 @@
|
||||
import RedisWrapper from '../../../app/src/infrastructure/RedisWrapper.js'
|
||||
const rclient = RedisWrapper.client('health_check')
|
||||
rclient.on('error', err => {
|
||||
console.error('Cannot connect to redis.')
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
rclient.healthCheck(err => {
|
||||
if (err) {
|
||||
console.error('Cannot connect to redis.')
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.error('Redis is up.')
|
||||
process.exit(0)
|
||||
}
|
||||
})
|
@@ -0,0 +1,101 @@
|
||||
import { db } from '../../../app/src/infrastructure/mongodb.js'
|
||||
|
||||
async function readImagesInUse() {
|
||||
const projectCount = await db.projects.countDocuments()
|
||||
if (projectCount === 0) {
|
||||
return []
|
||||
}
|
||||
const images = await db.projects.distinct('imageName')
|
||||
|
||||
if (!images || images.length === 0 || images.includes(null)) {
|
||||
console.error(`'project.imageName' is not set for some projects`)
|
||||
console.error(
|
||||
`Set SKIP_TEX_LIVE_CHECK=true in config/variables.env, restart the instance and run 'bin/run-script scripts/backfill_project_image_name.mjs' to initialise TexLive image in existing projects.`
|
||||
)
|
||||
console.error(
|
||||
`After running the script, remove SKIP_TEX_LIVE_CHECK from config/variables.env and restart the instance.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
function checkIsServerPro() {
|
||||
if (process.env.OVERLEAF_IS_SERVER_PRO !== 'true') {
|
||||
console.log('Running Overleaf Community Edition, skipping TexLive checks')
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
function checkSandboxedCompilesAreEnabled() {
|
||||
if (process.env.SANDBOXED_COMPILES !== 'true') {
|
||||
console.log('Sandboxed compiles disabled, skipping TexLive checks')
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
function checkTexLiveEnvVariablesAreProvided() {
|
||||
if (
|
||||
!process.env.TEX_LIVE_DOCKER_IMAGE ||
|
||||
!process.env.ALL_TEX_LIVE_DOCKER_IMAGES
|
||||
) {
|
||||
console.error(
|
||||
'Sandboxed compiles require TEX_LIVE_DOCKER_IMAGE and ALL_TEX_LIVE_DOCKER_IMAGES being set.'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.env.SKIP_TEX_LIVE_CHECK === 'true') {
|
||||
console.log(`SKIP_TEX_LIVE_CHECK=true, skipping TexLive images check`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
checkIsServerPro()
|
||||
checkSandboxedCompilesAreEnabled()
|
||||
checkTexLiveEnvVariablesAreProvided()
|
||||
|
||||
const allTexLiveImages = process.env.ALL_TEX_LIVE_DOCKER_IMAGES.split(',')
|
||||
|
||||
if (!allTexLiveImages.includes(process.env.TEX_LIVE_DOCKER_IMAGE)) {
|
||||
console.error(
|
||||
`TEX_LIVE_DOCKER_IMAGE must be included in ALL_TEX_LIVE_DOCKER_IMAGES`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const currentImages = await readImagesInUse()
|
||||
|
||||
const danglingImages = []
|
||||
for (const image of currentImages) {
|
||||
if (!allTexLiveImages.includes(image)) {
|
||||
danglingImages.push(image)
|
||||
}
|
||||
}
|
||||
if (danglingImages.length > 0) {
|
||||
danglingImages.forEach(image =>
|
||||
console.error(
|
||||
`${image} is currently in use but it's not included in ALL_TEX_LIVE_DOCKER_IMAGES`
|
||||
)
|
||||
)
|
||||
console.error(
|
||||
`Set SKIP_TEX_LIVE_CHECK=true in config/variables.env, restart the instance and run 'bin/run-script scripts/update_project_image_name.js <dangling_image> <new_image>' to update projects to a new image.`
|
||||
)
|
||||
console.error(
|
||||
`After running the script, remove SKIP_TEX_LIVE_CHECK from config/variables.env and restart the instance.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('Done.')
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
process.exit(0)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* WARNING
|
||||
* This file has been replaced by create-user.mjs. It is left in place for backwards compatibility with previous versions of Overleaf.
|
||||
* This will be used by the e2e tests that check the upgrade from the older versions, if these tests are updated or removed,
|
||||
* this file can be removed as well.
|
||||
*/
|
||||
|
||||
async function main() {
|
||||
const { default: createUser } = await import('./create-user.mjs')
|
||||
await createUser()
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
console.error('Done.')
|
||||
process.exit(0)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
@@ -0,0 +1,50 @@
|
||||
import minimist from 'minimist'
|
||||
import { db } from '../../../app/src/infrastructure/mongodb.js'
|
||||
import UserRegistrationHandler from '../../../app/src/Features/User/UserRegistrationHandler.js'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
|
||||
export default async function main() {
|
||||
const argv = minimist(process.argv.slice(2), {
|
||||
string: ['email'],
|
||||
boolean: ['admin'],
|
||||
})
|
||||
|
||||
const { admin, email } = argv
|
||||
if (!email) {
|
||||
console.error(`Usage: node ${filename} [--admin] --email=joe@example.com`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
UserRegistrationHandler.registerNewUserAndSendActivationEmail(
|
||||
email,
|
||||
(error, user, setNewPasswordUrl) => {
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
db.users.updateOne(
|
||||
{ _id: user._id },
|
||||
{ $set: { isAdmin: admin } },
|
||||
error => {
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
|
||||
console.log('')
|
||||
console.log(`\
|
||||
Successfully created ${email} as ${admin ? 'an admin' : 'a'} user.
|
||||
|
||||
Please visit the following URL to set a password for ${email} and log in:
|
||||
|
||||
${setNewPasswordUrl}
|
||||
|
||||
`)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
@@ -0,0 +1,47 @@
|
||||
import UserGetter from '../../../app/src/Features/User/UserGetter.js'
|
||||
import UserDeleter from '../../../app/src/Features/User/UserDeleter.js'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
|
||||
async function main() {
|
||||
const email = (process.argv.slice(2).pop() || '').replace(/^--email=/, '')
|
||||
if (!email) {
|
||||
console.error(`Usage: node ${filename} --email=joe@example.com`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
UserGetter.getUser({ email }, { _id: 1 }, function (error, user) {
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
if (!user) {
|
||||
console.log(
|
||||
`user ${email} not in database, potentially already deleted`
|
||||
)
|
||||
return resolve()
|
||||
}
|
||||
const options = {
|
||||
ipAddress: '0.0.0.0',
|
||||
force: true,
|
||||
}
|
||||
UserDeleter.deleteUser(user._id, options, function (err) {
|
||||
if (err) {
|
||||
return reject(err)
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
console.error('Done.')
|
||||
process.exit(0)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
@@ -0,0 +1,309 @@
|
||||
const minimist = require('minimist')
|
||||
const {
|
||||
mkdirSync,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
unlinkSync,
|
||||
renameSync,
|
||||
} = require('fs')
|
||||
const mongodb = require('../app/src/infrastructure/mongodb')
|
||||
const DocumentUpdaterHandler = require('../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js')
|
||||
const ProjectZipStreamManager = require('../app/src/Features/Downloads/ProjectZipStreamManager.js')
|
||||
const logger = require('logger-sharelatex')
|
||||
const { Project } = require('../app/src/models/Project.js')
|
||||
const { User } = require('../app/src/models/User.js')
|
||||
const readline = require('readline')
|
||||
|
||||
function parseArgs() {
|
||||
return minimist(process.argv.slice(2), {
|
||||
boolean: ['help', 'list', 'export-all'],
|
||||
string: ['user-id', 'output', 'project-id', 'output-dir', 'log-level'],
|
||||
alias: { help: 'h' },
|
||||
default: {
|
||||
'log-level': 'error',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function showUsage() {
|
||||
console.log(`
|
||||
Usage: node scripts/export-user-projects.mjs [options]
|
||||
--help, -h Show help
|
||||
--user-id The user ID (required unless using --export-all or --project-id)
|
||||
--project-id Export a single project (cannot be used with --user-id or --export-all)
|
||||
--list List user's projects (cannot be used with --output)
|
||||
--output Output zip file (for single export operations)
|
||||
--export-all Export all users' projects (requires --output-dir)
|
||||
--output-dir Directory for storing all users' export files
|
||||
--log-level Log level (trace|debug|info|warn|error|fatal) [default: error]
|
||||
`)
|
||||
}
|
||||
|
||||
function findAllUsers(callback) {
|
||||
User.find({}, 'email', callback)
|
||||
}
|
||||
|
||||
function findUserProjects(userId, callback) {
|
||||
Project.find({ owner_ref: userId }, 'name', callback)
|
||||
}
|
||||
|
||||
function listProjects(userId, callback) {
|
||||
findUserProjects(userId, function (err, projects) {
|
||||
if (err) return callback(err)
|
||||
projects.forEach(function (p) {
|
||||
console.log(`${p._id} - ${p.name}`)
|
||||
})
|
||||
callback()
|
||||
})
|
||||
}
|
||||
|
||||
function updateProgress(current, total) {
|
||||
if (!process.stdout.isTTY) return
|
||||
const width = 40
|
||||
const progress = Math.floor((current / total) * width)
|
||||
const SOLID_BLOCK = '\u2588' // Unicode "Full Block"
|
||||
const LIGHT_SHADE = '\u2591' // Unicode "Light Shade"
|
||||
const bar =
|
||||
SOLID_BLOCK.repeat(progress) + LIGHT_SHADE.repeat(width - progress)
|
||||
const percentage = Math.floor((current / total) * 100)
|
||||
readline.clearLine(process.stdout, 0)
|
||||
readline.cursorTo(process.stdout, 0)
|
||||
process.stdout.write(
|
||||
`Progress: [${bar}] ${percentage}% (${current}/${total} projects)`
|
||||
)
|
||||
}
|
||||
|
||||
function exportUserProjectsToZip(userId, output, callback) {
|
||||
findUserProjects(userId, function (err, projects) {
|
||||
if (err) return callback(err)
|
||||
const allIds = projects.map(p => p._id)
|
||||
if (allIds.length === 0) {
|
||||
console.log('No projects found for user')
|
||||
return callback()
|
||||
}
|
||||
|
||||
console.log('Flushing projects to MongoDB...')
|
||||
let completed = 0
|
||||
|
||||
function flushNext() {
|
||||
if (completed >= allIds.length) {
|
||||
createZip()
|
||||
return
|
||||
}
|
||||
|
||||
DocumentUpdaterHandler.flushProjectToMongoAndDelete(
|
||||
allIds[completed],
|
||||
function (err) {
|
||||
if (err) return callback(err)
|
||||
updateProgress(completed + 1, allIds.length)
|
||||
completed++
|
||||
flushNext()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function createZip() {
|
||||
console.log('\nAll projects flushed, creating zip...')
|
||||
console.log(
|
||||
`Exporting ${allIds.length} projects for user ${userId} to ${output}`
|
||||
)
|
||||
|
||||
ProjectZipStreamManager.createZipStreamForMultipleProjects(
|
||||
allIds,
|
||||
function (err, zipStream) {
|
||||
if (err) return callback(err)
|
||||
|
||||
zipStream.on('progress', progress => {
|
||||
updateProgress(progress.entries.total, allIds.length)
|
||||
})
|
||||
|
||||
writeStreamToFileAtomically(zipStream, output, function (err) {
|
||||
if (err) return callback(err)
|
||||
readline.clearLine(process.stdout, 0)
|
||||
readline.cursorTo(process.stdout, 0)
|
||||
console.log(
|
||||
`Successfully exported ${allIds.length} projects to ${output}`
|
||||
)
|
||||
callback()
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
flushNext()
|
||||
})
|
||||
}
|
||||
|
||||
function writeStreamToFileAtomically(stream, finalPath, callback) {
|
||||
const tmpPath = `${finalPath}-${Date.now()}.tmp`
|
||||
const outStream = createWriteStream(tmpPath, { flags: 'wx' })
|
||||
|
||||
stream.pipe(outStream)
|
||||
|
||||
outStream.on('error', function (err) {
|
||||
try {
|
||||
unlinkSync(tmpPath)
|
||||
} catch {
|
||||
console.log('Leaving behind tmp file, please cleanup manually:', tmpPath)
|
||||
}
|
||||
callback(err)
|
||||
})
|
||||
|
||||
outStream.on('finish', function () {
|
||||
try {
|
||||
renameSync(tmpPath, finalPath)
|
||||
callback()
|
||||
} catch (err) {
|
||||
try {
|
||||
unlinkSync(tmpPath)
|
||||
} catch {
|
||||
console.log(
|
||||
'Leaving behind tmp file, please cleanup manually:',
|
||||
tmpPath
|
||||
)
|
||||
}
|
||||
callback(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function exportSingleProject(projectId, output, callback) {
|
||||
console.log('Flushing project to MongoDB...')
|
||||
DocumentUpdaterHandler.flushProjectToMongoAndDelete(
|
||||
projectId,
|
||||
function (err) {
|
||||
if (err) return callback(err)
|
||||
|
||||
console.log(`Exporting project ${projectId} to ${output}`)
|
||||
ProjectZipStreamManager.createZipStreamForProject(
|
||||
projectId,
|
||||
function (err, zipStream) {
|
||||
if (err) return callback(err)
|
||||
writeStreamToFileAtomically(zipStream, output, function (err) {
|
||||
if (err) return callback(err)
|
||||
console.log('Exported project to', output)
|
||||
callback()
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function exportAllUsersProjects(outputDir, callback) {
|
||||
findAllUsers(function (err, users) {
|
||||
if (err) return callback(err)
|
||||
|
||||
console.log(`Found ${users.length} users to process`)
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
|
||||
let userIndex = 0
|
||||
function processNextUser() {
|
||||
if (userIndex >= users.length) {
|
||||
return callback()
|
||||
}
|
||||
|
||||
const user = users[userIndex]
|
||||
const safeEmail = user.email.toLowerCase().replace(/[^a-z0-9]/g, '_')
|
||||
const outputFile = `${outputDir}/${user._id}_${safeEmail}_projects.zip`
|
||||
|
||||
if (existsSync(outputFile)) {
|
||||
console.log(`Skipping ${user._id} - file already exists`)
|
||||
userIndex++
|
||||
return processNextUser()
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Processing user ${userIndex + 1}/${users.length} (${user._id})`
|
||||
)
|
||||
exportUserProjectsToZip(user._id, outputFile, function (err) {
|
||||
if (err) return callback(err)
|
||||
userIndex++
|
||||
processNextUser()
|
||||
})
|
||||
}
|
||||
|
||||
processNextUser()
|
||||
})
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = parseArgs()
|
||||
|
||||
if (argv.help) {
|
||||
showUsage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (argv['log-level']) {
|
||||
logger.logger.level(argv['log-level'])
|
||||
}
|
||||
|
||||
if (argv.list && argv.output) {
|
||||
console.error('Cannot use both --list and --output together')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (
|
||||
[argv['user-id'], argv['project-id'], argv['export-all']].filter(Boolean)
|
||||
.length > 1
|
||||
) {
|
||||
console.error('Can only use one of: --user-id, --project-id, --export-all')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function cleanup(err) {
|
||||
// Allow the script to finish gracefully then exit
|
||||
setTimeout(() => {
|
||||
if (err) {
|
||||
logger.error({ err }, 'Error in export-user-projects script')
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log('Done.')
|
||||
process.exit(0)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
if (argv.list) {
|
||||
if (!argv['user-id']) {
|
||||
console.error('--list requires --user-id')
|
||||
process.exit(1)
|
||||
}
|
||||
listProjects(argv['user-id'], cleanup)
|
||||
return
|
||||
}
|
||||
|
||||
if (argv['export-all']) {
|
||||
if (!argv['output-dir']) {
|
||||
console.error('--export-all requires --output-dir')
|
||||
process.exit(1)
|
||||
}
|
||||
exportAllUsersProjects(argv['output-dir'], cleanup)
|
||||
return
|
||||
}
|
||||
|
||||
if (!argv.output) {
|
||||
console.error('Please specify an --output zip file')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (argv['project-id']) {
|
||||
exportSingleProject(argv['project-id'], argv.output, cleanup)
|
||||
} else if (argv['user-id']) {
|
||||
exportUserProjectsToZip(argv['user-id'], argv.output, cleanup)
|
||||
} else {
|
||||
console.error(
|
||||
'Please specify either --user-id, --project-id, or --export-all'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
mongodb
|
||||
.waitForDb()
|
||||
.then(main)
|
||||
.catch(err => {
|
||||
console.error('Failed to connect to MongoDB:', err)
|
||||
process.exit(1)
|
||||
})
|
@@ -0,0 +1,232 @@
|
||||
import minimist from 'minimist'
|
||||
import {
|
||||
mkdirSync,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
unlinkSync,
|
||||
renameSync,
|
||||
} from 'fs'
|
||||
import { pipeline } from 'stream/promises'
|
||||
import DocumentUpdaterHandler from '../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js'
|
||||
import ProjectZipStreamManager from '../../../app/src/Features/Downloads/ProjectZipStreamManager.mjs'
|
||||
import logger from '@overleaf/logger'
|
||||
import { promisify } from '@overleaf/promise-utils'
|
||||
import { gracefulShutdown } from '../../../app/src/infrastructure/GracefulShutdown.js'
|
||||
import { Project } from '../../../app/src/models/Project.js'
|
||||
import { User } from '../../../app/src/models/User.js'
|
||||
import readline from 'readline'
|
||||
|
||||
function parseArgs() {
|
||||
return minimist(process.argv.slice(2), {
|
||||
boolean: ['help', 'list', 'export-all'],
|
||||
string: ['user-id', 'output', 'project-id', 'output-dir', 'log-level'],
|
||||
alias: { help: 'h' },
|
||||
default: {
|
||||
'log-level': 'error',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function showUsage() {
|
||||
console.log(`
|
||||
Usage: node scripts/export-user-projects.mjs [options]
|
||||
--help, -h Show help
|
||||
--user-id The user ID (required unless using --export-all or --project-id)
|
||||
--project-id Export a single project (cannot be used with --user-id or --export-all)
|
||||
--list List user's projects (cannot be used with --output)
|
||||
--output Output zip file (for single export operations)
|
||||
--export-all Export all users' projects (requires --output-dir)
|
||||
--output-dir Directory for storing all users' export files
|
||||
--log-level Log level (trace|debug|info|warn|error|fatal) [default: error]
|
||||
`)
|
||||
}
|
||||
|
||||
async function findAllUsers() {
|
||||
const users = await User.find({}, 'email').exec()
|
||||
return users
|
||||
}
|
||||
|
||||
async function findUserProjects(userId) {
|
||||
const ownedProjects = await Project.find({ owner_ref: userId }, 'name').exec()
|
||||
return ownedProjects
|
||||
}
|
||||
|
||||
async function listProjects(userId) {
|
||||
const projects = await findUserProjects(userId)
|
||||
for (const p of projects) {
|
||||
console.log(`${p._id} - ${p.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const createZipStreamForMultipleProjectsAsync = promisify(
|
||||
ProjectZipStreamManager.createZipStreamForMultipleProjects
|
||||
).bind(ProjectZipStreamManager)
|
||||
|
||||
function updateProgress(current, total) {
|
||||
if (!process.stdout.isTTY) return
|
||||
const width = 40
|
||||
const progress = Math.floor((current / total) * width)
|
||||
const SOLID_BLOCK = '\u2588' // Unicode "Full Block"
|
||||
const LIGHT_SHADE = '\u2591' // Unicode "Light Shade"
|
||||
const bar =
|
||||
SOLID_BLOCK.repeat(progress) + LIGHT_SHADE.repeat(width - progress)
|
||||
const percentage = Math.floor((current / total) * 100)
|
||||
readline.clearLine(process.stdout, 0)
|
||||
readline.cursorTo(process.stdout, 0)
|
||||
process.stdout.write(
|
||||
`Progress: [${bar}] ${percentage}% (${current}/${total} projects)`
|
||||
)
|
||||
}
|
||||
|
||||
async function exportUserProjectsToZip(userId, output) {
|
||||
const projects = await findUserProjects(userId)
|
||||
const allIds = projects.map(p => p._id)
|
||||
if (allIds.length === 0) {
|
||||
console.log('No projects found for user')
|
||||
return
|
||||
}
|
||||
console.log('Flushing projects to MongoDB...')
|
||||
for (const [index, id] of allIds.entries()) {
|
||||
await DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete(id)
|
||||
updateProgress(index + 1, allIds.length)
|
||||
}
|
||||
console.log('\nAll projects flushed, creating zip...')
|
||||
|
||||
console.log(
|
||||
`Exporting ${allIds.length} projects for user ${userId} to ${output}`
|
||||
)
|
||||
|
||||
const zipStream = await createZipStreamForMultipleProjectsAsync(allIds)
|
||||
|
||||
zipStream.on('progress', progress => {
|
||||
updateProgress(progress.entries.total, allIds.length)
|
||||
})
|
||||
|
||||
await writeStreamToFileAtomically(zipStream, output)
|
||||
readline.clearLine(process.stdout, 0)
|
||||
readline.cursorTo(process.stdout, 0)
|
||||
console.log(`Successfully exported ${allIds.length} projects to ${output}`)
|
||||
}
|
||||
|
||||
async function writeStreamToFileAtomically(stream, finalPath) {
|
||||
const tmpPath = `${finalPath}-${Date.now()}.tmp`
|
||||
const outStream = createWriteStream(tmpPath, { flags: 'wx' })
|
||||
try {
|
||||
await pipeline(stream, outStream)
|
||||
renameSync(tmpPath, finalPath)
|
||||
} catch (err) {
|
||||
try {
|
||||
unlinkSync(tmpPath)
|
||||
} catch {
|
||||
console.log('Leaving behind tmp file, please cleanup manually:', tmpPath)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const createZipStreamForProjectAsync = promisify(
|
||||
ProjectZipStreamManager.createZipStreamForProject
|
||||
).bind(ProjectZipStreamManager)
|
||||
|
||||
async function exportSingleProject(projectId, output) {
|
||||
console.log('Flushing project to MongoDB...')
|
||||
await DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete(projectId)
|
||||
console.log(`Exporting project ${projectId} to ${output}`)
|
||||
const zipStream = await createZipStreamForProjectAsync(projectId)
|
||||
await writeStreamToFileAtomically(zipStream, output)
|
||||
console.log('Exported project to', output)
|
||||
}
|
||||
|
||||
async function exportAllUsersProjects(outputDir) {
|
||||
const users = await findAllUsers()
|
||||
console.log(`Found ${users.length} users to process`)
|
||||
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
const user = users[i]
|
||||
const safeEmail = user.email.toLowerCase().replace(/[^a-z0-9]/g, '_')
|
||||
const outputFile = `${outputDir}/${user._id}_${safeEmail}_projects.zip`
|
||||
|
||||
if (existsSync(outputFile)) {
|
||||
console.log(`Skipping ${user._id} - file already exists`)
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(`Processing user ${i + 1}/${users.length} (${user._id})`)
|
||||
await exportUserProjectsToZip(user._id, outputFile)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argv = parseArgs()
|
||||
|
||||
if (argv.help) {
|
||||
showUsage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (argv['log-level']) {
|
||||
logger.logger.level(argv['log-level'])
|
||||
}
|
||||
|
||||
if (argv.list && argv.output) {
|
||||
console.error('Cannot use both --list and --output together')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (
|
||||
[argv['user-id'], argv['project-id'], argv['export-all']].filter(Boolean)
|
||||
.length > 1
|
||||
) {
|
||||
console.error('Can only use one of: --user-id, --project-id, --export-all')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
if (argv.list) {
|
||||
if (!argv['user-id']) {
|
||||
console.error('--list requires --user-id')
|
||||
process.exit(1)
|
||||
}
|
||||
await listProjects(argv['user-id'])
|
||||
return
|
||||
}
|
||||
|
||||
if (argv['export-all']) {
|
||||
if (!argv['output-dir']) {
|
||||
console.error('--export-all requires --output-dir')
|
||||
process.exit(1)
|
||||
}
|
||||
await exportAllUsersProjects(argv['output-dir'])
|
||||
return
|
||||
}
|
||||
|
||||
if (!argv.output) {
|
||||
console.error('Please specify an --output zip file')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (argv['project-id']) {
|
||||
await exportSingleProject(argv['project-id'], argv.output)
|
||||
} else if (argv['user-id']) {
|
||||
await exportUserProjectsToZip(argv['user-id'], argv.output)
|
||||
} else {
|
||||
console.error(
|
||||
'Please specify either --user-id, --project-id, or --export-all'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
} finally {
|
||||
await gracefulShutdown({ close: done => done() })
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
console.log('Done.')
|
||||
})
|
||||
.catch(async err => {
|
||||
logger.error({ err }, 'Error in export-user-projects script')
|
||||
process.exitCode = 1
|
||||
})
|
@@ -0,0 +1,197 @@
|
||||
// Script to migrate user emails using a CSV file with the following format:
|
||||
//
|
||||
// oldEmail,newEmail
|
||||
//
|
||||
// The script will iterate through the CSV file and update the user's email
|
||||
// address from oldEmail to newEmail, after checking all the email addresses
|
||||
// for duplicates.
|
||||
//
|
||||
// Intended for Server Pro customers migrating user emails from one domain to
|
||||
// another.
|
||||
|
||||
import minimist from 'minimist'
|
||||
|
||||
import os from 'os'
|
||||
import fs from 'fs'
|
||||
import * as csv from 'csv/sync'
|
||||
import { parseEmail } from '../../../app/src/Features/Helpers/EmailHelper.js'
|
||||
import UserGetter from '../../../app/src/Features/User/UserGetter.js'
|
||||
import UserUpdater from '../../../app/src/Features/User/UserUpdater.js'
|
||||
import UserSessionsManager from '../../../app/src/Features/User/UserSessionsManager.js'
|
||||
|
||||
const hostname = os.hostname()
|
||||
const scriptTimestamp = new Date().toISOString()
|
||||
|
||||
// support command line option of --commit to actually do the migration
|
||||
const argv = minimist(process.argv.slice(2), {
|
||||
boolean: ['commit', 'ignore-missing'],
|
||||
string: ['admin-id'],
|
||||
alias: {
|
||||
'ignore-missing': 'continue',
|
||||
},
|
||||
default: {
|
||||
commit: false,
|
||||
'ignore-missing': false,
|
||||
'admin-id': '000000000000000000000000', // use a dummy admin ID for script audit log entries
|
||||
},
|
||||
})
|
||||
|
||||
// display usage if no CSV file is provided
|
||||
if (argv._.length === 0) {
|
||||
console.log(
|
||||
'Usage: node migrate_user_emails.mjs [--commit] [--continue|--ignore-missing] [--admin-id=ADMIN_USER_ID] <csv_file>'
|
||||
)
|
||||
console.log(' --commit: actually do the migration (default: false)')
|
||||
console.log(
|
||||
' --continue|--ignore-missing: continue on missing or already-migrated users'
|
||||
)
|
||||
console.log(' --admin-id: admin user ID to use for audit log entries')
|
||||
console.log(' <csv_file>: CSV file with old and new email addresses')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function filterEmails(rows) {
|
||||
// check that emails have a valid format
|
||||
const result = []
|
||||
const seenOld = new Set()
|
||||
const seenNew = new Set()
|
||||
for (const [oldEmail, newEmail] of rows) {
|
||||
const parsedOld = parseEmail(oldEmail)
|
||||
const parsedNew = parseEmail(newEmail)
|
||||
if (!parsedOld) {
|
||||
throw new Error(`invalid old email "${oldEmail}"`)
|
||||
}
|
||||
if (!parsedNew) {
|
||||
throw new Error(`invalid new email "${newEmail}"`)
|
||||
}
|
||||
// Check for duplicates and overlaps
|
||||
if (seenOld.has(parsedOld)) {
|
||||
throw new Error(`Duplicate old emails found in CSV file ${oldEmail}.`)
|
||||
}
|
||||
if (seenNew.has(parsedNew)) {
|
||||
throw new Error(`Duplicate new emails found in CSV file ${newEmail}.`)
|
||||
}
|
||||
if (seenOld.has(parsedNew) || seenNew.has(parsedOld)) {
|
||||
throw new Error(
|
||||
`Old and new emails cannot overlap ${oldEmail} ${newEmail}`
|
||||
)
|
||||
}
|
||||
seenOld.add(parsedOld)
|
||||
seenNew.add(parsedNew)
|
||||
result.push([parsedOld, parsedNew])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function checkEmailsAgainstDb(emails) {
|
||||
const result = []
|
||||
for (const [oldEmail, newEmail] of emails) {
|
||||
const userWithEmail = await UserGetter.promises.getUserByMainEmail(
|
||||
oldEmail,
|
||||
{
|
||||
_id: 1,
|
||||
}
|
||||
)
|
||||
if (!userWithEmail) {
|
||||
if (argv['ignore-missing']) {
|
||||
console.log(
|
||||
`User with email "${oldEmail}" not found, skipping update to "${newEmail}"`
|
||||
)
|
||||
continue
|
||||
} else {
|
||||
throw new Error(`no user found with email "${oldEmail}"`)
|
||||
}
|
||||
}
|
||||
const userWithNewEmail = await UserGetter.promises.getUserByAnyEmail(
|
||||
newEmail,
|
||||
{
|
||||
_id: 1,
|
||||
}
|
||||
)
|
||||
if (userWithNewEmail) {
|
||||
throw new Error(
|
||||
`new email "${newEmail}" already exists for user ${userWithNewEmail._id}`
|
||||
)
|
||||
}
|
||||
result.push([oldEmail, newEmail])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function doMigration(emails) {
|
||||
let success = 0
|
||||
let failure = 0
|
||||
let skipped = 0
|
||||
for (const [oldEmail, newEmail] of emails) {
|
||||
const userWithEmail = await UserGetter.promises.getUserByMainEmail(
|
||||
oldEmail,
|
||||
{
|
||||
_id: 1,
|
||||
}
|
||||
)
|
||||
if (!userWithEmail) {
|
||||
if (argv['ignore-missing']) {
|
||||
continue
|
||||
} else {
|
||||
throw new Error(`no user found with email "${oldEmail}"`)
|
||||
}
|
||||
}
|
||||
if (argv.commit) {
|
||||
console.log(
|
||||
`Updating user ${userWithEmail._id} email "${oldEmail}" to "${newEmail}"\n`
|
||||
)
|
||||
try {
|
||||
// log out all the user's sessions before changing the email address
|
||||
await UserSessionsManager.promises.removeSessionsFromRedis(
|
||||
userWithEmail
|
||||
)
|
||||
|
||||
await UserUpdater.promises.migrateDefaultEmailAddress(
|
||||
userWithEmail._id,
|
||||
oldEmail,
|
||||
newEmail,
|
||||
{
|
||||
initiatorId: argv['admin-id'],
|
||||
ipAddress: hostname,
|
||||
extraInfo: {
|
||||
script: 'migrate_user_emails.js',
|
||||
runAt: scriptTimestamp,
|
||||
},
|
||||
}
|
||||
)
|
||||
success++
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
failure++
|
||||
}
|
||||
} else {
|
||||
console.log(`Dry run, skipping update from ${oldEmail} to ${newEmail}`)
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
console.log('Success: ', success, 'Failure: ', failure, 'Skipped: ', skipped)
|
||||
if (failure > 0) {
|
||||
throw new Error('Some email migrations failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateEmails() {
|
||||
console.log('Starting email migration script')
|
||||
const csvFilePath = argv._[0]
|
||||
const csvFile = fs.readFileSync(csvFilePath, 'utf8')
|
||||
const rows = csv.parse(csvFile)
|
||||
console.log('Number of users to migrate: ', rows.length)
|
||||
const emails = filterEmails(rows)
|
||||
const existingUserEmails = await checkEmailsAgainstDb(emails)
|
||||
await doMigration(existingUserEmails)
|
||||
}
|
||||
|
||||
migrateEmails()
|
||||
.then(() => {
|
||||
console.log('Done.')
|
||||
process.exit(0)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
@@ -0,0 +1,34 @@
|
||||
import minimist from 'minimist'
|
||||
import { db } from '../../../app/src/infrastructure/mongodb.js'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
|
||||
async function main() {
|
||||
const argv = minimist(process.argv.slice(2), {
|
||||
string: ['user-id', 'old-name', 'new_name'],
|
||||
})
|
||||
|
||||
const { 'user-id': userId, 'old-name': oldName, 'new-name': newName } = argv
|
||||
if (!userId || !oldName || !newName) {
|
||||
console.error(
|
||||
`Usage: node ${filename} --user-id=5a9414f259776c7900b300e6 --old-name=my-folder --new-name=my-folder-renamed`
|
||||
)
|
||||
process.exit(101)
|
||||
}
|
||||
|
||||
await db.tags.updateOne(
|
||||
{ name: oldName, user_id: userId },
|
||||
{ $set: { name: newName } }
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
console.error('Done.')
|
||||
process.exit(0)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
@@ -0,0 +1,61 @@
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import { db } from '../../../app/src/infrastructure/mongodb.js'
|
||||
import {
|
||||
mergeFeatures,
|
||||
compareFeatures,
|
||||
} from '../../../app/src/Features/Subscription/FeaturesHelper.js'
|
||||
import { fileURLToPath } from 'url'
|
||||
const DRY_RUN = !process.argv.includes('--dry-run=false')
|
||||
|
||||
async function main(DRY_RUN, defaultFeatures) {
|
||||
logger.info({ defaultFeatures }, 'default features')
|
||||
|
||||
const cursor = db.users.find(
|
||||
{},
|
||||
{ projection: { _id: 1, email: 1, features: 1 } }
|
||||
)
|
||||
for await (const user of cursor) {
|
||||
const newFeatures = mergeFeatures(user.features, defaultFeatures)
|
||||
const diff = compareFeatures(newFeatures, user.features)
|
||||
if (Object.keys(diff).length > 0) {
|
||||
logger.warn(
|
||||
{
|
||||
userId: user._id,
|
||||
email: user.email,
|
||||
oldFeatures: user.features,
|
||||
newFeatures,
|
||||
},
|
||||
'user features upgraded'
|
||||
)
|
||||
|
||||
if (!DRY_RUN) {
|
||||
await db.users.updateOne(
|
||||
{ _id: user._id },
|
||||
{ $set: { features: newFeatures } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default main
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
|
||||
if (filename === process.argv[1]) {
|
||||
if (DRY_RUN) {
|
||||
console.error('---')
|
||||
console.error('Dry-run enabled, use --dry-run=false to commit changes')
|
||||
console.error('---')
|
||||
}
|
||||
main(DRY_RUN, Settings.defaultFeatures)
|
||||
.then(() => {
|
||||
console.log('Done.')
|
||||
process.exit(0)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error({ error })
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
const base = require(process.env.BASE_CONFIG)
|
||||
|
||||
module.exports = base.mergeWith({
|
||||
test: {
|
||||
counterInit: 160000,
|
||||
},
|
||||
})
|
@@ -0,0 +1,14 @@
|
||||
import '../../../../../test/acceptance/src/helpers/InitApp.mjs'
|
||||
import MockProjectHistoryApi from '../../../../../test/acceptance/src/mocks/MockProjectHistoryApi.mjs'
|
||||
import MockDocstoreApi from '../../../../../test/acceptance/src/mocks/MockDocstoreApi.mjs'
|
||||
import MockDocUpdaterApi from '../../../../../test/acceptance/src/mocks/MockDocUpdaterApi.mjs'
|
||||
import MockV1Api from '../../../../admin-panel/test/acceptance/src/mocks/MockV1Api.mjs'
|
||||
|
||||
const mockOpts = {
|
||||
debug: ['1', 'true', 'TRUE'].includes(process.env.DEBUG_MOCKS),
|
||||
}
|
||||
|
||||
MockDocstoreApi.initialize(23016, mockOpts)
|
||||
MockDocUpdaterApi.initialize(23003, mockOpts)
|
||||
MockProjectHistoryApi.initialize(23054, mockOpts)
|
||||
MockV1Api.initialize(25000, mockOpts)
|
@@ -0,0 +1,713 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import Settings from '@overleaf/settings'
|
||||
import { expect } from 'chai'
|
||||
import { db } from '../../../../../app/src/infrastructure/mongodb.js'
|
||||
import UserHelper from '../../../../../test/acceptance/src/helpers/User.mjs'
|
||||
|
||||
const { promises: User } = UserHelper
|
||||
|
||||
/**
|
||||
* @param {string} cmd
|
||||
* @return {string}
|
||||
*/
|
||||
function run(cmd) {
|
||||
// https://nodejs.org/docs/latest-v12.x/api/child_process.html#child_process_child_process_execsync_command_options
|
||||
// > stderr by default will be output to the parent process' stderr
|
||||
// > unless stdio is specified.
|
||||
// https://nodejs.org/docs/latest-v12.x/api/child_process.html#child_process_options_stdio
|
||||
// Pipe stdin from /dev/null, store stdout, pipe stderr to /dev/null.
|
||||
return execSync(cmd, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
LOG_LEVEL: 'warn',
|
||||
},
|
||||
}).toString()
|
||||
}
|
||||
|
||||
function runAndExpectError(cmd, errorMessages) {
|
||||
try {
|
||||
run(cmd)
|
||||
} catch (error) {
|
||||
expect(error.status).to.equal(1)
|
||||
if (errorMessages) {
|
||||
errorMessages.forEach(errorMessage =>
|
||||
expect(error.stderr.toString()).to.include(errorMessage)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
expect.fail('command should have failed')
|
||||
}
|
||||
|
||||
async function getUser(email) {
|
||||
return db.users.findOne({ email }, { projection: { _id: 0, isAdmin: 1 } })
|
||||
}
|
||||
|
||||
describe('ServerCEScripts', function () {
|
||||
describe('check-mongodb', function () {
|
||||
it('should exit with code 0 on success', function () {
|
||||
run('node modules/server-ce-scripts/scripts/check-mongodb.mjs')
|
||||
})
|
||||
|
||||
it('should exit with code 1 on error', function () {
|
||||
try {
|
||||
run(
|
||||
'MONGO_SERVER_SELECTION_TIMEOUT=1' +
|
||||
'MONGO_CONNECTION_STRING=mongodb://127.0.0.1:4242 ' +
|
||||
'node modules/server-ce-scripts/scripts/check-mongodb.mjs'
|
||||
)
|
||||
} catch (e) {
|
||||
expect(e.status).to.equal(1)
|
||||
return
|
||||
}
|
||||
expect.fail('command should have failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('check-redis', function () {
|
||||
it('should exit with code 0 on success', function () {
|
||||
run('node modules/server-ce-scripts/scripts/check-redis.mjs')
|
||||
})
|
||||
|
||||
it('should exit with code 1 on error', function () {
|
||||
try {
|
||||
run(
|
||||
'REDIS_PORT=42 node modules/server-ce-scripts/scripts/check-redis.mjs'
|
||||
)
|
||||
} catch (e) {
|
||||
expect(e.status).to.equal(1)
|
||||
return
|
||||
}
|
||||
expect.fail('command should have failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('create-user', function () {
|
||||
it('should exit with code 0 on success', function () {
|
||||
const out = run(
|
||||
'node modules/server-ce-scripts/scripts/create-user.js --email=foo@bar.com'
|
||||
)
|
||||
expect(out).to.include('/user/activate?token=')
|
||||
})
|
||||
|
||||
it('should create a regular user by default', async function () {
|
||||
run(
|
||||
'node modules/server-ce-scripts/scripts/create-user.js --email=foo@bar.com'
|
||||
)
|
||||
expect(await getUser('foo@bar.com')).to.deep.equal({ isAdmin: false })
|
||||
})
|
||||
|
||||
it('should create an admin user with --admin flag', async function () {
|
||||
run(
|
||||
'node modules/server-ce-scripts/scripts/create-user.js --admin --email=foo@bar.com'
|
||||
)
|
||||
expect(await getUser('foo@bar.com')).to.deep.equal({ isAdmin: true })
|
||||
})
|
||||
|
||||
it('should exit with code 1 on missing email', function () {
|
||||
try {
|
||||
run('node modules/server-ce-scripts/scripts/create-user.js')
|
||||
} catch (e) {
|
||||
expect(e.status).to.equal(1)
|
||||
return
|
||||
}
|
||||
expect.fail('command should have failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete-user', function () {
|
||||
let user
|
||||
beforeEach(async function () {
|
||||
user = new User()
|
||||
await user.login()
|
||||
})
|
||||
|
||||
it('should log missing user', function () {
|
||||
const email = 'does-not-exist@example.com'
|
||||
const out = run(
|
||||
'node modules/server-ce-scripts/scripts/delete-user.mjs --email=' +
|
||||
email
|
||||
)
|
||||
expect(out).to.include('not in database, potentially already deleted')
|
||||
})
|
||||
|
||||
it('should exit with code 0 on success', function () {
|
||||
const email = user.email
|
||||
run(
|
||||
'node modules/server-ce-scripts/scripts/delete-user.mjs --email=' +
|
||||
email
|
||||
)
|
||||
})
|
||||
|
||||
it('should have deleted the user on success', async function () {
|
||||
const email = user.email
|
||||
run(
|
||||
'node modules/server-ce-scripts/scripts/delete-user.mjs --email=' +
|
||||
email
|
||||
)
|
||||
const dbEntry = await user.get()
|
||||
expect(dbEntry).to.not.exist
|
||||
const softDeletedEntry = await db.deletedUsers.findOne({
|
||||
'user.email': email,
|
||||
})
|
||||
expect(softDeletedEntry).to.exist
|
||||
expect(softDeletedEntry.deleterData.deleterIpAddress).to.equal('0.0.0.0')
|
||||
})
|
||||
|
||||
it('should exit with code 1 on missing email', function () {
|
||||
try {
|
||||
run('node modules/server-ce-scripts/scripts/delete-user.mjs')
|
||||
} catch (e) {
|
||||
expect(e.status).to.equal(1)
|
||||
return
|
||||
}
|
||||
expect.fail('command should have failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrate-user-emails', function () {
|
||||
let usersToMigrate
|
||||
let otherUsers
|
||||
let csv
|
||||
let csvfail
|
||||
beforeEach(async function () {
|
||||
// set up some users to migrate and others to leave alone
|
||||
usersToMigrate = []
|
||||
otherUsers = []
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const user = new User()
|
||||
await user.login()
|
||||
usersToMigrate.push(user)
|
||||
}
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const user = new User()
|
||||
await user.login()
|
||||
otherUsers.push(user)
|
||||
}
|
||||
// write the migration csv to a temporary file
|
||||
const id = usersToMigrate[0]._id
|
||||
csv = `/tmp/migration-${id}.csv`
|
||||
const rows = []
|
||||
for (const user of usersToMigrate) {
|
||||
rows.push(`${user.email},new-${user.email}`)
|
||||
}
|
||||
fs.writeFileSync(csv, rows.join('\n'))
|
||||
// also write a csv with a user that doesn't exist
|
||||
csvfail = `/tmp/migration-fail-${id}.csv`
|
||||
fs.writeFileSync(
|
||||
csvfail,
|
||||
[
|
||||
'nouser@example.com,nouser@other.example.com',
|
||||
...rows,
|
||||
'foo@example.com,bar@example.com',
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
// clean up the temporary files
|
||||
fs.unlinkSync(csv)
|
||||
fs.unlinkSync(csvfail)
|
||||
})
|
||||
|
||||
it('should do a dry run by default', async function () {
|
||||
run(
|
||||
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs ${csv}`
|
||||
)
|
||||
for (const user of usersToMigrate) {
|
||||
const dbEntry = await user.get()
|
||||
expect(dbEntry.email).to.equal(user.email)
|
||||
}
|
||||
for (const user of otherUsers) {
|
||||
const dbEntry = await user.get()
|
||||
expect(dbEntry.email).to.equal(user.email)
|
||||
}
|
||||
})
|
||||
|
||||
it('should exit with code 0 when successfully migrating user emails', function () {
|
||||
run(
|
||||
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit ${csv}`
|
||||
)
|
||||
})
|
||||
|
||||
it('should migrate the user emails with the --commit option', async function () {
|
||||
run(
|
||||
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit ${csv}`
|
||||
)
|
||||
for (const user of usersToMigrate) {
|
||||
const dbEntry = await user.get()
|
||||
expect(dbEntry.email).to.equal(`new-${user.email}`)
|
||||
expect(dbEntry.emails).to.have.lengthOf(1)
|
||||
expect(dbEntry.emails[0].email).to.equal(`new-${user.email}`)
|
||||
expect(dbEntry.emails[0].reversedHostname).to.equal('moc.elpmaxe')
|
||||
expect(dbEntry.emails[0].createdAt).to.eql(user.emails[0].createdAt)
|
||||
}
|
||||
})
|
||||
|
||||
it('should leave other user emails unchanged', async function () {
|
||||
run(
|
||||
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit ${csv}`
|
||||
)
|
||||
for (const user of otherUsers) {
|
||||
const dbEntry = await user.get()
|
||||
expect(dbEntry.email).to.equal(user.email)
|
||||
}
|
||||
})
|
||||
|
||||
it('should exit with code 1 when there are failures migrating user emails', function () {
|
||||
try {
|
||||
run(
|
||||
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit ${csvfail}`
|
||||
)
|
||||
} catch (e) {
|
||||
expect(e.status).to.equal(1)
|
||||
return
|
||||
}
|
||||
expect.fail('command should have failed')
|
||||
})
|
||||
|
||||
it('should migrate other users when there are failures with the --continue option', async function () {
|
||||
try {
|
||||
run(
|
||||
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit ${csvfail}`
|
||||
)
|
||||
} catch (e) {
|
||||
expect(e.status).to.equal(1)
|
||||
run(
|
||||
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit --continue ${csvfail}`
|
||||
)
|
||||
for (const user of usersToMigrate) {
|
||||
const dbEntry = await user.get()
|
||||
expect(dbEntry.email).to.equal(`new-${user.email}`)
|
||||
expect(dbEntry.emails).to.have.lengthOf(1)
|
||||
expect(dbEntry.emails[0].email).to.equal(`new-${user.email}`)
|
||||
expect(dbEntry.emails[0].reversedHostname).to.equal('moc.elpmaxe')
|
||||
expect(dbEntry.emails[0].createdAt).to.eql(user.emails[0].createdAt)
|
||||
}
|
||||
return
|
||||
}
|
||||
expect.fail('command should have failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename-tag', function () {
|
||||
let user
|
||||
beforeEach(async function () {
|
||||
user = new User()
|
||||
await user.login()
|
||||
})
|
||||
|
||||
async function createTag(name) {
|
||||
await user.doRequest('POST', { url: '/tag', json: { name } })
|
||||
}
|
||||
|
||||
async function getTagNames() {
|
||||
const { body } = await user.doRequest('GET', { url: '/tag', json: true })
|
||||
return body.map(tag => tag.name)
|
||||
}
|
||||
|
||||
it('should rename a tag', async function () {
|
||||
const oldName = 'before'
|
||||
const newName = 'after'
|
||||
await createTag(oldName)
|
||||
|
||||
expect(await getTagNames()).to.deep.equal([oldName])
|
||||
|
||||
run(
|
||||
`node modules/server-ce-scripts/scripts/rename-tag.mjs --user-id=${user.id} --old-name=${oldName} --new-name=${newName}`
|
||||
)
|
||||
|
||||
expect(await getTagNames()).to.deep.equal([newName])
|
||||
})
|
||||
})
|
||||
|
||||
describe('change-compile-timeout', function () {
|
||||
let userA, userB
|
||||
beforeEach('login', async function () {
|
||||
userA = new User()
|
||||
await userA.login()
|
||||
|
||||
userB = new User()
|
||||
await userB.login()
|
||||
})
|
||||
|
||||
async function getCompileTimeout(user) {
|
||||
const { compileTimeout } = await user.getFeatures()
|
||||
return compileTimeout
|
||||
}
|
||||
|
||||
let userATimeout, userBTimeout
|
||||
beforeEach('fetch current state', async function () {
|
||||
userATimeout = await getCompileTimeout(userA)
|
||||
userBTimeout = await getCompileTimeout(userB)
|
||||
})
|
||||
|
||||
describe('happy path', function () {
|
||||
let newUserATimeout
|
||||
beforeEach('run script on user a', function () {
|
||||
newUserATimeout = userATimeout - 1
|
||||
run(
|
||||
`node modules/server-ce-scripts/scripts/change-compile-timeout.mjs --user-id=${userA.id} --compile-timeout=${newUserATimeout}`
|
||||
)
|
||||
})
|
||||
|
||||
it('should change the timeout for user a', async function () {
|
||||
const actual = await getCompileTimeout(userA)
|
||||
expect(actual).to.not.equal(userATimeout)
|
||||
expect(actual).to.equal(newUserATimeout)
|
||||
})
|
||||
|
||||
it('should leave the timeout for user b as is', async function () {
|
||||
expect(await getCompileTimeout(userB)).to.equal(userBTimeout)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bad options', function () {
|
||||
it('should reject zero timeout', async function () {
|
||||
try {
|
||||
run(
|
||||
`node modules/server-ce-scripts/scripts/change-compile-timeout.mjs --user-id=${userA.id} --compile-timeout=0`
|
||||
)
|
||||
expect.fail('should error out')
|
||||
} catch (err) {
|
||||
expect(err.stderr.toString()).to.include('positive number of seconds')
|
||||
}
|
||||
expect(await getCompileTimeout(userA)).to.equal(userATimeout)
|
||||
expect(await getCompileTimeout(userB)).to.equal(userBTimeout)
|
||||
})
|
||||
|
||||
it('should reject a 20min timeout', async function () {
|
||||
try {
|
||||
run(
|
||||
`node modules/server-ce-scripts/scripts/change-compile-timeout.mjs --user-id=${userA.id} --compile-timeout=1200`
|
||||
)
|
||||
expect.fail('should error out')
|
||||
} catch (err) {
|
||||
expect(err.stderr.toString()).to.include('below 10 minutes')
|
||||
}
|
||||
expect(await getCompileTimeout(userA)).to.equal(userATimeout)
|
||||
expect(await getCompileTimeout(userB)).to.equal(userBTimeout)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('upgrade-user-features', function () {
|
||||
let userLatest, userSP1, userCustomTimeoutLower, userCustomTimeoutHigher
|
||||
beforeEach('create users', async function () {
|
||||
userLatest = new User()
|
||||
userSP1 = new User()
|
||||
userCustomTimeoutLower = new User()
|
||||
userCustomTimeoutHigher = new User()
|
||||
|
||||
await Promise.all([
|
||||
userLatest.ensureUserExists(),
|
||||
userSP1.ensureUserExists(),
|
||||
userCustomTimeoutLower.ensureUserExists(),
|
||||
userCustomTimeoutHigher.ensureUserExists(),
|
||||
])
|
||||
})
|
||||
|
||||
const serverPro1Features = {
|
||||
collaborators: -1,
|
||||
dropbox: true,
|
||||
versioning: true,
|
||||
compileTimeout: 180,
|
||||
compileGroup: 'standard',
|
||||
references: true,
|
||||
trackChanges: true,
|
||||
}
|
||||
|
||||
beforeEach('downgrade userSP1', async function () {
|
||||
await userSP1.mongoUpdate({ $set: { features: serverPro1Features } })
|
||||
})
|
||||
|
||||
beforeEach('downgrade userCustomTimeoutLower', async function () {
|
||||
run(
|
||||
`node modules/server-ce-scripts/scripts/change-compile-timeout.mjs --user-id=${userCustomTimeoutLower.id} --compile-timeout=42`
|
||||
)
|
||||
})
|
||||
|
||||
beforeEach('upgrade userCustomTimeoutHigher', async function () {
|
||||
run(
|
||||
`node modules/server-ce-scripts/scripts/change-compile-timeout.mjs --user-id=${userCustomTimeoutHigher.id} --compile-timeout=360`
|
||||
)
|
||||
})
|
||||
|
||||
async function getFeatures() {
|
||||
return [
|
||||
await userLatest.getFeatures(),
|
||||
await userSP1.getFeatures(),
|
||||
await userCustomTimeoutLower.getFeatures(),
|
||||
await userCustomTimeoutHigher.getFeatures(),
|
||||
]
|
||||
}
|
||||
|
||||
let initialFeatures
|
||||
beforeEach('collect initial features', async function () {
|
||||
initialFeatures = await getFeatures()
|
||||
})
|
||||
|
||||
it('should have prepared the right features', async function () {
|
||||
expect(initialFeatures).to.deep.equal([
|
||||
Settings.defaultFeatures,
|
||||
serverPro1Features,
|
||||
Object.assign({}, Settings.defaultFeatures, {
|
||||
compileTimeout: 42,
|
||||
}),
|
||||
Object.assign({}, Settings.defaultFeatures, {
|
||||
compileTimeout: 360,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
describe('dry-run', function () {
|
||||
let output
|
||||
beforeEach('run script', function () {
|
||||
output = run(
|
||||
`node modules/server-ce-scripts/scripts/upgrade-user-features.mjs`
|
||||
)
|
||||
})
|
||||
|
||||
it('should update SP1 features', function () {
|
||||
expect(output).to.include(userSP1.id)
|
||||
})
|
||||
|
||||
it('should update lowerTimeout features', function () {
|
||||
expect(output).to.include(userCustomTimeoutLower.id)
|
||||
})
|
||||
|
||||
it('should not update latest features', function () {
|
||||
expect(output).to.not.include(userLatest.id)
|
||||
})
|
||||
|
||||
it('should not update higherTimeout features', function () {
|
||||
expect(output).to.not.include(userCustomTimeoutHigher.id)
|
||||
})
|
||||
|
||||
it('should not change any features in the db', async function () {
|
||||
expect(await getFeatures()).to.deep.equal(initialFeatures)
|
||||
})
|
||||
})
|
||||
|
||||
describe('live run', function () {
|
||||
let output
|
||||
beforeEach('run script', function () {
|
||||
output = run(
|
||||
`node modules/server-ce-scripts/scripts/upgrade-user-features.mjs --dry-run=false`
|
||||
)
|
||||
})
|
||||
|
||||
it('should update SP1 features', function () {
|
||||
expect(output).to.include(userSP1.id)
|
||||
})
|
||||
|
||||
it('should update lowerTimeout features', function () {
|
||||
expect(output).to.include(userCustomTimeoutLower.id)
|
||||
})
|
||||
|
||||
it('should not update latest features', function () {
|
||||
expect(output).to.not.include(userLatest.id)
|
||||
})
|
||||
|
||||
it('should not update higherTimeout features', function () {
|
||||
expect(output).to.not.include(userCustomTimeoutHigher.id)
|
||||
})
|
||||
|
||||
it('should update features in the db', async function () {
|
||||
expect(await getFeatures()).to.deep.equal([
|
||||
Settings.defaultFeatures,
|
||||
Settings.defaultFeatures,
|
||||
Settings.defaultFeatures,
|
||||
Object.assign({}, Settings.defaultFeatures, {
|
||||
compileTimeout: 360,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('check-texlive-images', function () {
|
||||
const TEST_TL_IMAGE = 'sharelatex/texlive:2023'
|
||||
const TEST_TL_IMAGE_LIST =
|
||||
'sharelatex/texlive:2021,sharelatex/texlive:2022,sharelatex/texlive:2023'
|
||||
|
||||
let output
|
||||
|
||||
function buildCheckTexLiveCmd({
|
||||
SANDBOXED_COMPILES,
|
||||
TEX_LIVE_DOCKER_IMAGE,
|
||||
ALL_TEX_LIVE_DOCKER_IMAGES,
|
||||
OVERLEAF_IS_SERVER_PRO = true,
|
||||
}) {
|
||||
let cmd = `SANDBOXED_COMPILES=${SANDBOXED_COMPILES ? 'true' : 'false'}`
|
||||
if (TEX_LIVE_DOCKER_IMAGE) {
|
||||
cmd += ` TEX_LIVE_DOCKER_IMAGE='${TEX_LIVE_DOCKER_IMAGE}'`
|
||||
}
|
||||
if (ALL_TEX_LIVE_DOCKER_IMAGES) {
|
||||
cmd += ` ALL_TEX_LIVE_DOCKER_IMAGES='${ALL_TEX_LIVE_DOCKER_IMAGES}'`
|
||||
}
|
||||
if (OVERLEAF_IS_SERVER_PRO === true) {
|
||||
cmd += ` OVERLEAF_IS_SERVER_PRO=${OVERLEAF_IS_SERVER_PRO}`
|
||||
}
|
||||
return (
|
||||
cmd + ' node modules/server-ce-scripts/scripts/check-texlive-images.mjs'
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(async function () {
|
||||
const user = new User()
|
||||
await user.ensureUserExists()
|
||||
await user.login()
|
||||
await user.createProject('test-project')
|
||||
})
|
||||
|
||||
describe('when running in CE', function () {
|
||||
beforeEach('run script', function () {
|
||||
output = run(buildCheckTexLiveCmd({ OVERLEAF_IS_SERVER_PRO: false }))
|
||||
})
|
||||
|
||||
it('should skip checks', function () {
|
||||
expect(output).to.include(
|
||||
'Running Overleaf Community Edition, skipping TexLive checks'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when sandboxed compiles are disabled', function () {
|
||||
beforeEach('run script', function () {
|
||||
output = run(buildCheckTexLiveCmd({ SANDBOXED_COMPILES: false }))
|
||||
})
|
||||
|
||||
it('should skip checks', function () {
|
||||
expect(output).to.include(
|
||||
'Sandboxed compiles disabled, skipping TexLive checks'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when texlive configuration is incorrect', function () {
|
||||
it('should fail when TEX_LIVE_DOCKER_IMAGE is not set', function () {
|
||||
runAndExpectError(
|
||||
buildCheckTexLiveCmd({
|
||||
SANDBOXED_COMPILES: true,
|
||||
ALL_TEX_LIVE_DOCKER_IMAGES: TEST_TL_IMAGE_LIST,
|
||||
}),
|
||||
[
|
||||
'Sandboxed compiles require TEX_LIVE_DOCKER_IMAGE and ALL_TEX_LIVE_DOCKER_IMAGES being set',
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('should fail when ALL_TEX_LIVE_DOCKER_IMAGES is not set', function () {
|
||||
runAndExpectError(
|
||||
buildCheckTexLiveCmd({
|
||||
SANDBOXED_COMPILES: true,
|
||||
TEX_LIVE_DOCKER_IMAGE: TEST_TL_IMAGE,
|
||||
}),
|
||||
[
|
||||
'Sandboxed compiles require TEX_LIVE_DOCKER_IMAGE and ALL_TEX_LIVE_DOCKER_IMAGES being set',
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('should fail when TEX_LIVE_DOCKER_IMAGE is not defined in ALL_TEX_LIVE_DOCKER_IMAGES', function () {
|
||||
runAndExpectError(
|
||||
buildCheckTexLiveCmd({
|
||||
SANDBOXED_COMPILES: true,
|
||||
TEX_LIVE_DOCKER_IMAGE: 'tl-1',
|
||||
ALL_TEX_LIVE_DOCKER_IMAGES: 'tl-2,tl-3',
|
||||
}),
|
||||
[
|
||||
'TEX_LIVE_DOCKER_IMAGE must be included in ALL_TEX_LIVE_DOCKER_IMAGES',
|
||||
]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`when projects don't have 'imageName' set`, function () {
|
||||
beforeEach(async function () {
|
||||
await db.projects.updateMany({}, { $unset: { imageName: 1 } })
|
||||
})
|
||||
|
||||
it('should fail and suggest running backfilling scripts', function () {
|
||||
runAndExpectError(
|
||||
buildCheckTexLiveCmd({
|
||||
SANDBOXED_COMPILES: true,
|
||||
TEX_LIVE_DOCKER_IMAGE: TEST_TL_IMAGE,
|
||||
ALL_TEX_LIVE_DOCKER_IMAGES: TEST_TL_IMAGE_LIST,
|
||||
}),
|
||||
[
|
||||
`'project.imageName' is not set for some projects`,
|
||||
`Set SKIP_TEX_LIVE_CHECK=true in config/variables.env, restart the instance and run 'bin/run-script scripts/backfill_project_image_name.mjs' to initialise TexLive image in existing projects`,
|
||||
]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`when projects have a null 'imageName'`, function () {
|
||||
beforeEach(async function () {
|
||||
await db.projects.updateMany({}, { $set: { imageName: null } })
|
||||
})
|
||||
|
||||
it('should fail and suggest running backfilling scripts', function () {
|
||||
runAndExpectError(
|
||||
buildCheckTexLiveCmd({
|
||||
SANDBOXED_COMPILES: true,
|
||||
TEX_LIVE_DOCKER_IMAGE: TEST_TL_IMAGE,
|
||||
ALL_TEX_LIVE_DOCKER_IMAGES: TEST_TL_IMAGE_LIST,
|
||||
}),
|
||||
[
|
||||
`'project.imageName' is not set for some projects`,
|
||||
`Set SKIP_TEX_LIVE_CHECK=true in config/variables.env, restart the instance and run 'bin/run-script scripts/backfill_project_image_name.mjs' to initialise TexLive image in existing projects`,
|
||||
]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when TexLive ALL_TEX_LIVE_DOCKER_IMAGES are upgraded and used images are no longer available', function () {
|
||||
it('should suggest running a fixing script', async function () {
|
||||
await db.projects.updateMany({}, { $set: { imageName: TEST_TL_IMAGE } })
|
||||
runAndExpectError(
|
||||
buildCheckTexLiveCmd({
|
||||
SANDBOXED_COMPILES: true,
|
||||
TEX_LIVE_DOCKER_IMAGE: 'tl-1',
|
||||
ALL_TEX_LIVE_DOCKER_IMAGES: 'tl-1,tl-2',
|
||||
}),
|
||||
[
|
||||
`Set SKIP_TEX_LIVE_CHECK=true in config/variables.env, restart the instance and run 'bin/run-script scripts/update_project_image_name.js <dangling_image> <new_image>' to update projects to a new image`,
|
||||
]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('success scenarios', function () {
|
||||
beforeEach(async function () {
|
||||
await db.projects.updateMany({}, { $set: { imageName: TEST_TL_IMAGE } })
|
||||
})
|
||||
|
||||
it('should succeed when there are no changes to the TexLive images', function () {
|
||||
const output = run(
|
||||
buildCheckTexLiveCmd({
|
||||
SANDBOXED_COMPILES: true,
|
||||
TEX_LIVE_DOCKER_IMAGE: TEST_TL_IMAGE,
|
||||
ALL_TEX_LIVE_DOCKER_IMAGES: TEST_TL_IMAGE_LIST,
|
||||
})
|
||||
)
|
||||
expect(output).to.include('Done.')
|
||||
})
|
||||
|
||||
it('should succeed when there are valid changes to the TexLive images', function () {
|
||||
const output = run(
|
||||
buildCheckTexLiveCmd({
|
||||
SANDBOXED_COMPILES: true,
|
||||
TEX_LIVE_DOCKER_IMAGE: 'new-image',
|
||||
ALL_TEX_LIVE_DOCKER_IMAGES: TEST_TL_IMAGE_LIST + ',new-image',
|
||||
})
|
||||
)
|
||||
expect(output).to.include('Done.')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1 @@
|
||||
{ "extends": "../../../../tsconfig.backend.json" }
|
@@ -0,0 +1,69 @@
|
||||
import Path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
|
||||
import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.js'
|
||||
import ErrorController from '../../../../app/src/Features/Errors/ErrorController.js'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
|
||||
const __dirname = Path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function registerNewUser(req, res, next) {
|
||||
res.render(Path.resolve(__dirname, '../views/user/register'))
|
||||
}
|
||||
|
||||
async function register(req, res, next) {
|
||||
const { email } = req.body
|
||||
if (email == null || email === '') {
|
||||
return res.sendStatus(422) // Unprocessable Entity
|
||||
}
|
||||
const { user, setNewPasswordUrl } =
|
||||
await UserRegistrationHandler.promises.registerNewUserAndSendActivationEmail(
|
||||
email
|
||||
)
|
||||
res.json({
|
||||
email: user.email,
|
||||
setNewPasswordUrl,
|
||||
})
|
||||
}
|
||||
|
||||
async function activateAccountPage(req, res, next) {
|
||||
// An 'activation' is actually just a password reset on an account that
|
||||
// was set with a random password originally.
|
||||
if (req.query.user_id == null || req.query.token == null) {
|
||||
return ErrorController.notFound(req, res)
|
||||
}
|
||||
|
||||
if (typeof req.query.user_id !== 'string') {
|
||||
return ErrorController.forbidden(req, res)
|
||||
}
|
||||
|
||||
const user = await UserGetter.promises.getUser(req.query.user_id, {
|
||||
email: 1,
|
||||
loginCount: 1,
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return ErrorController.notFound(req, res)
|
||||
}
|
||||
|
||||
if (user.loginCount > 0) {
|
||||
// Already seen this user, so account must be activated.
|
||||
// This lets users keep clicking the 'activate' link in their email
|
||||
// as a way to log in which, if I know our users, they will.
|
||||
return res.redirect(`/login`)
|
||||
}
|
||||
|
||||
req.session.doLoginAfterPasswordReset = true
|
||||
|
||||
res.render(Path.resolve(__dirname, '../views/user/activate'), {
|
||||
title: 'activate_account',
|
||||
email: user.email,
|
||||
token: req.query.token,
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
registerNewUser,
|
||||
register: expressify(register),
|
||||
activateAccountPage: expressify(activateAccountPage),
|
||||
}
|
@@ -0,0 +1,30 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import UserActivateController from './UserActivateController.mjs'
|
||||
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
|
||||
import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.js'
|
||||
|
||||
export default {
|
||||
apply(webRouter) {
|
||||
logger.debug({}, 'Init UserActivate router')
|
||||
|
||||
webRouter.get(
|
||||
'/admin/user',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
(req, res) => res.redirect('/admin/register')
|
||||
)
|
||||
|
||||
webRouter.get('/user/activate', UserActivateController.activateAccountPage)
|
||||
AuthenticationController.addEndpointToLoginWhitelist('/user/activate')
|
||||
|
||||
webRouter.get(
|
||||
'/admin/register',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
UserActivateController.registerNewUser
|
||||
)
|
||||
webRouter.post(
|
||||
'/admin/register',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
UserActivateController.register
|
||||
)
|
||||
},
|
||||
}
|
1
services/web/modules/user-activate/app/src/tsconfig.json
Normal file
1
services/web/modules/user-activate/app/src/tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "extends": "../../../../tsconfig.backend.json" }
|
@@ -0,0 +1,72 @@
|
||||
extends ../../../../../app/views/layout-website-redesign-bootstrap-5
|
||||
|
||||
block content
|
||||
main.content.content-alt#main-content
|
||||
.container
|
||||
div.col-lg-6.col-xl-4.m-auto
|
||||
.notification-list
|
||||
.notification.notification-type-success(aria-live="off" role="alert")
|
||||
.notification-content-and-cta
|
||||
.notification-icon
|
||||
span.material-symbols(aria-hidden="true")
|
||||
| check_circle
|
||||
.notification-content
|
||||
p
|
||||
| #{translate("nearly_activated")}
|
||||
|
||||
h1.h3 #{translate("please_set_a_password")}
|
||||
|
||||
form(
|
||||
data-ol-async-form
|
||||
name="activationForm",
|
||||
action="/user/password/set",
|
||||
method="POST",
|
||||
)
|
||||
+formMessages()
|
||||
|
||||
+customFormMessage('token-expired', 'danger')
|
||||
| #{translate("activation_token_expired")}
|
||||
|
||||
+customFormMessage('invalid-password', 'danger')
|
||||
| #{translate('invalid_password')}
|
||||
|
||||
input(name='_csrf', type='hidden', value=csrfToken)
|
||||
input(
|
||||
type="hidden",
|
||||
name="passwordResetToken",
|
||||
value=token
|
||||
)
|
||||
|
||||
.form-group
|
||||
label(for='emailField') #{translate("email")}
|
||||
input.form-control#emailField(
|
||||
aria-label="email",
|
||||
type='email',
|
||||
name='email',
|
||||
placeholder="email@example.com",
|
||||
autocomplete="username"
|
||||
value=email
|
||||
required,
|
||||
disabled
|
||||
)
|
||||
.form-group
|
||||
label(for='passwordField') #{translate("password")}
|
||||
input.form-control#passwordField(
|
||||
type='password',
|
||||
name='password',
|
||||
placeholder="********",
|
||||
autocomplete="new-password",
|
||||
autofocus,
|
||||
required,
|
||||
minlength=settings.passwordStrengthOptions.length.min
|
||||
)
|
||||
.actions
|
||||
button.btn.btn-primary(
|
||||
type='submit',
|
||||
data-ol-disabled-inflight
|
||||
aria-label=translate('activate')
|
||||
)
|
||||
span(data-ol-inflight="idle")
|
||||
| #{translate('activate')}
|
||||
span(hidden data-ol-inflight="pending")
|
||||
| #{translate('activating')}…
|
@@ -0,0 +1,13 @@
|
||||
extends ../../../../../app/views/layout-marketing
|
||||
|
||||
block entrypointVar
|
||||
- entrypoint = 'modules/user-activate/pages/user-activate-page'
|
||||
|
||||
|
||||
block vars
|
||||
- bootstrap5PageStatus = 'disabled'
|
||||
|
||||
block content
|
||||
.content.content-alt#main-content
|
||||
.container
|
||||
#user-activate-register-container
|
@@ -0,0 +1,87 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { postJSON } from '../../../../../frontend/js/infrastructure/fetch-json'
|
||||
|
||||
function RegisterForm({
|
||||
setRegistrationSuccess,
|
||||
setEmails,
|
||||
setRegisterError,
|
||||
setFailedEmails,
|
||||
}) {
|
||||
function handleRegister(event) {
|
||||
event.preventDefault()
|
||||
const formData = new FormData(event.target)
|
||||
const formDataAsEntries = formData.entries()
|
||||
const formDataAsObject = Object.fromEntries(formDataAsEntries)
|
||||
const emailString = formDataAsObject.email
|
||||
setRegistrationSuccess(false)
|
||||
setRegisterError(false)
|
||||
setEmails([])
|
||||
registerGivenUsers(parseEmails(emailString))
|
||||
}
|
||||
|
||||
async function registerGivenUsers(emails) {
|
||||
const registeredEmails = []
|
||||
const failingEmails = []
|
||||
for (const email of emails) {
|
||||
try {
|
||||
const result = await registerUser(email)
|
||||
registeredEmails.push(result)
|
||||
} catch {
|
||||
failingEmails.push(email)
|
||||
}
|
||||
}
|
||||
if (registeredEmails.length > 0) setRegistrationSuccess(true)
|
||||
if (failingEmails.length > 0) {
|
||||
setRegisterError(true)
|
||||
setFailedEmails(failingEmails)
|
||||
}
|
||||
setEmails(registeredEmails)
|
||||
}
|
||||
|
||||
function registerUser(email) {
|
||||
const options = { email }
|
||||
const url = `/admin/register`
|
||||
return postJSON(url, { body: options })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleRegister}>
|
||||
<div className="row">
|
||||
<div className="col-md-4 col-xs-8">
|
||||
<input
|
||||
className="form-control"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder="jane@example.com, joe@example.com"
|
||||
aria-label="emails to register"
|
||||
aria-describedby="input-details"
|
||||
/>
|
||||
<p id="input-details" className="sr-only">
|
||||
Enter the emails you would like to register and separate them using
|
||||
commas
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-md-8 col-xs-4">
|
||||
<button className="btn btn-primary">Register</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function parseEmails(emailsText) {
|
||||
const regexBySpaceOrComma = /[\s,]+/
|
||||
let emails = emailsText.split(regexBySpaceOrComma)
|
||||
emails.map(email => email.trim())
|
||||
emails = emails.filter(email => email.indexOf('@') !== -1)
|
||||
return emails
|
||||
}
|
||||
|
||||
RegisterForm.propTypes = {
|
||||
setRegistrationSuccess: PropTypes.func,
|
||||
setEmails: PropTypes.func,
|
||||
setRegisterError: PropTypes.func,
|
||||
setFailedEmails: PropTypes.func,
|
||||
}
|
||||
|
||||
export default RegisterForm
|
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import RegisterForm from './register-form'
|
||||
function UserActivateRegister() {
|
||||
const [emails, setEmails] = useState([])
|
||||
const [failedEmails, setFailedEmails] = useState([])
|
||||
const [registerError, setRegisterError] = useState(false)
|
||||
const [registrationSuccess, setRegistrationSuccess] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-md-12">
|
||||
<div className="card">
|
||||
<div className="page-header">
|
||||
<h1> Register New Users</h1>
|
||||
</div>
|
||||
<RegisterForm
|
||||
setRegistrationSuccess={setRegistrationSuccess}
|
||||
setEmails={setEmails}
|
||||
setRegisterError={setRegisterError}
|
||||
setFailedEmails={setFailedEmails}
|
||||
/>
|
||||
{registerError ? (
|
||||
<UserActivateError failedEmails={failedEmails} />
|
||||
) : null}
|
||||
{registrationSuccess ? (
|
||||
<>
|
||||
<SuccessfulRegistrationMessage />
|
||||
<hr />
|
||||
<DisplayEmailsList emails={emails} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserActivateError({ failedEmails }) {
|
||||
return (
|
||||
<div className="row-spaced text-danger">
|
||||
<p>Sorry, an error occured, failed to register these emails.</p>
|
||||
{failedEmails.map(email => (
|
||||
<p key={email}>{email}</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SuccessfulRegistrationMessage() {
|
||||
return (
|
||||
<div className="row-spaced text-success">
|
||||
<p>We've sent out welcome emails to the registered users.</p>
|
||||
<p>
|
||||
You can also manually send them URLs below to allow them to reset their
|
||||
password and log in for the first time.
|
||||
</p>
|
||||
<p>
|
||||
(Password reset tokens will expire after one week and the user will need
|
||||
registering again).
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DisplayEmailsList({ emails }) {
|
||||
return (
|
||||
<table className="table table-striped ">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Set Password Url</th>
|
||||
</tr>
|
||||
{emails.map(user => (
|
||||
<tr key={user.email}>
|
||||
<td>{user.email}</td>
|
||||
<td style={{ wordBreak: 'break-all' }}>{user.setNewPasswordUrl}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
DisplayEmailsList.propTypes = {
|
||||
emails: PropTypes.array,
|
||||
}
|
||||
UserActivateError.propTypes = {
|
||||
failedEmails: PropTypes.array,
|
||||
}
|
||||
|
||||
export default UserActivateRegister
|
@@ -0,0 +1,9 @@
|
||||
import '@/marketing'
|
||||
|
||||
import ReactDOM from 'react-dom'
|
||||
import UserActivateRegister from '../components/user-activate-register'
|
||||
|
||||
ReactDOM.render(
|
||||
<UserActivateRegister />,
|
||||
document.getElementById('user-activate-register-container')
|
||||
)
|
12
services/web/modules/user-activate/index.mjs
Normal file
12
services/web/modules/user-activate/index.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import UserActivateRouter from './app/src/UserActivateRouter.mjs'
|
||||
|
||||
/**
|
||||
* @import { WebModule } from "../../types/web-module"
|
||||
*/
|
||||
|
||||
/** @type {WebModule} */
|
||||
const UserActivateModule = {
|
||||
router: UserActivateRouter,
|
||||
}
|
||||
|
||||
export default UserActivateModule
|
@@ -0,0 +1,62 @@
|
||||
import { expect } from 'chai'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import sinon from 'sinon'
|
||||
import RegisterForm from '../../../../frontend/js/components/register-form'
|
||||
|
||||
describe('RegisterForm', function () {
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
it('should render the register form', async function () {
|
||||
const setRegistrationSuccessStub = sinon.stub()
|
||||
const setEmailsStub = sinon.stub()
|
||||
const setRegisterErrorStub = sinon.stub()
|
||||
const setFailedEmailsStub = sinon.stub()
|
||||
|
||||
render(
|
||||
<RegisterForm
|
||||
setRegistrationSuccess={setRegistrationSuccessStub}
|
||||
setEmails={setEmailsStub}
|
||||
setRegisterError={setRegisterErrorStub}
|
||||
setFailedEmails={setFailedEmailsStub}
|
||||
/>
|
||||
)
|
||||
await screen.findByLabelText('emails to register')
|
||||
screen.getByRole('button', { name: /register/i })
|
||||
})
|
||||
|
||||
it('should call the fetch request when register button is pressed', async function () {
|
||||
const email = 'abc@gmail.com'
|
||||
const setRegistrationSuccessStub = sinon.stub()
|
||||
const setEmailsStub = sinon.stub()
|
||||
const setRegisterErrorStub = sinon.stub()
|
||||
const setFailedEmailsStub = sinon.stub()
|
||||
|
||||
const endPointResponse = {
|
||||
status: 200,
|
||||
body: {
|
||||
email,
|
||||
setNewPasswordUrl: 'SetNewPasswordURL',
|
||||
},
|
||||
}
|
||||
const registerMock = fetchMock.post('/admin/register', endPointResponse)
|
||||
|
||||
render(
|
||||
<RegisterForm
|
||||
setRegistrationSuccess={setRegistrationSuccessStub}
|
||||
setEmails={setEmailsStub}
|
||||
setRegisterError={setRegisterErrorStub}
|
||||
setFailedEmails={setFailedEmailsStub}
|
||||
/>
|
||||
)
|
||||
const registerInput = screen.getByLabelText('emails to register')
|
||||
const registerButton = screen.getByRole('button', { name: /register/i })
|
||||
fireEvent.change(registerInput, { target: { value: email } })
|
||||
fireEvent.click(registerButton)
|
||||
expect(registerMock.callHistory.called()).to.be.true
|
||||
})
|
||||
})
|
@@ -0,0 +1,140 @@
|
||||
import { expect } from 'chai'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import UserActivateRegister from '../../../../frontend/js/components/user-activate-register'
|
||||
|
||||
describe('UserActivateRegister', function () {
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
it('should display the error message', async function () {
|
||||
const email = 'abc@gmail.com'
|
||||
render(<UserActivateRegister />)
|
||||
const endPointResponse = {
|
||||
status: 500,
|
||||
}
|
||||
const registerMock = fetchMock.post('/admin/register', endPointResponse)
|
||||
const registerInput = screen.getByLabelText('emails to register')
|
||||
const registerButton = screen.getByRole('button', { name: /register/i })
|
||||
|
||||
fireEvent.change(registerInput, { target: { value: email } })
|
||||
fireEvent.click(registerButton)
|
||||
|
||||
expect(registerMock.callHistory.called()).to.be.true
|
||||
await screen.findByText('Sorry, an error occured', { exact: false })
|
||||
})
|
||||
|
||||
it('should display the success message', async function () {
|
||||
const email = 'abc@gmail.com'
|
||||
render(<UserActivateRegister />)
|
||||
const endPointResponse = {
|
||||
status: 200,
|
||||
body: {
|
||||
email,
|
||||
setNewPasswordUrl: 'SetNewPasswordURL',
|
||||
},
|
||||
}
|
||||
const registerMock = fetchMock.post('/admin/register', endPointResponse)
|
||||
const registerInput = screen.getByLabelText('emails to register')
|
||||
const registerButton = screen.getByRole('button', { name: /register/i })
|
||||
|
||||
fireEvent.change(registerInput, { target: { value: email } })
|
||||
fireEvent.click(registerButton)
|
||||
|
||||
expect(registerMock.callHistory.called()).to.be.true
|
||||
await screen.findByText(
|
||||
"We've sent out welcome emails to the registered users."
|
||||
)
|
||||
})
|
||||
|
||||
it('should display the registered emails', async function () {
|
||||
const email = 'abc@gmail.com, def@gmail.com'
|
||||
render(<UserActivateRegister />)
|
||||
const endPointResponse1 = {
|
||||
status: 200,
|
||||
body: {
|
||||
email: 'abc@gmail.com',
|
||||
setNewPasswordUrl: 'SetNewPasswordURL',
|
||||
},
|
||||
}
|
||||
const endPointResponse2 = {
|
||||
status: 200,
|
||||
body: {
|
||||
email: 'def@gmail.com',
|
||||
setNewPasswordUrl: 'SetNewPasswordURL',
|
||||
},
|
||||
}
|
||||
const registerMock = fetchMock.post('/admin/register', (path, req) => {
|
||||
const body = JSON.parse(req.body)
|
||||
if (body.email === 'abc@gmail.com') return endPointResponse1
|
||||
else if (body.email === 'def@gmail.com') return endPointResponse2
|
||||
})
|
||||
const registerInput = screen.getByLabelText('emails to register')
|
||||
const registerButton = screen.getByRole('button', { name: /register/i })
|
||||
|
||||
fireEvent.change(registerInput, { target: { value: email } })
|
||||
fireEvent.click(registerButton)
|
||||
|
||||
expect(registerMock.callHistory.called()).to.be.true
|
||||
await screen.findByText('abc@gmail.com')
|
||||
await screen.findByText('def@gmail.com')
|
||||
})
|
||||
|
||||
it('should display the failed emails', async function () {
|
||||
const email = 'abc@, def@'
|
||||
render(<UserActivateRegister />)
|
||||
const endPointResponse1 = {
|
||||
status: 500,
|
||||
}
|
||||
const endPointResponse2 = {
|
||||
status: 500,
|
||||
}
|
||||
const registerMock = fetchMock.post('/admin/register', (path, req) => {
|
||||
const body = JSON.parse(req.body)
|
||||
if (body.email === 'abc@') return endPointResponse1
|
||||
else if (body.email === 'def@') return endPointResponse2
|
||||
})
|
||||
const registerInput = screen.getByLabelText('emails to register')
|
||||
const registerButton = screen.getByRole('button', { name: /register/i })
|
||||
|
||||
fireEvent.change(registerInput, { target: { value: email } })
|
||||
fireEvent.click(registerButton)
|
||||
|
||||
expect(registerMock.callHistory.called()).to.be.true
|
||||
await screen.findByText('abc@')
|
||||
await screen.findByText('def@')
|
||||
})
|
||||
|
||||
it('should display the registered and failed emails together', async function () {
|
||||
const email = 'abc@gmail.com, def@'
|
||||
render(<UserActivateRegister />)
|
||||
const endPointResponse1 = {
|
||||
status: 200,
|
||||
body: {
|
||||
email: 'abc@gmail.com',
|
||||
setNewPasswordUrl: 'SetNewPasswordURL',
|
||||
},
|
||||
}
|
||||
const endPointResponse2 = {
|
||||
status: 500,
|
||||
}
|
||||
const registerMock = fetchMock.post('/admin/register', (path, req) => {
|
||||
const body = JSON.parse(req.body)
|
||||
if (body.email === 'abc@gmail.com') return endPointResponse1
|
||||
else if (body.email === 'def@gmail.com') return endPointResponse2
|
||||
else return 500
|
||||
})
|
||||
const registerInput = screen.getByLabelText('emails to register')
|
||||
const registerButton = screen.getByRole('button', { name: /register/i })
|
||||
|
||||
fireEvent.change(registerInput, { target: { value: email } })
|
||||
fireEvent.click(registerButton)
|
||||
|
||||
expect(registerMock.callHistory.called()).to.be.true
|
||||
await screen.findByText('abc@gmail.com')
|
||||
await screen.findByText('def@')
|
||||
})
|
||||
})
|
@@ -0,0 +1,134 @@
|
||||
import Path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { strict as esmock } from 'esmock'
|
||||
import sinon from 'sinon'
|
||||
|
||||
const __dirname = Path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const MODULE_PATH = '../../../app/src/UserActivateController.mjs'
|
||||
|
||||
const VIEW_PATH = Path.join(__dirname, '../../../app/views/user/activate')
|
||||
|
||||
describe('UserActivateController', function () {
|
||||
beforeEach(async function () {
|
||||
this.user = {
|
||||
_id: (this.user_id = 'kwjewkl'),
|
||||
features: {},
|
||||
email: 'joe@example.com',
|
||||
}
|
||||
|
||||
this.UserGetter = {
|
||||
promises: {
|
||||
getUser: sinon.stub(),
|
||||
},
|
||||
}
|
||||
this.UserRegistrationHandler = { promises: {} }
|
||||
this.ErrorController = { notFound: sinon.stub() }
|
||||
this.SplitTestHandler = {
|
||||
promises: {
|
||||
getAssignment: sinon.stub().resolves({ variant: 'default' }),
|
||||
},
|
||||
}
|
||||
this.UserActivateController = await esmock(MODULE_PATH, {
|
||||
'../../../../../app/src/Features/User/UserGetter.js': this.UserGetter,
|
||||
'../../../../../app/src/Features/User/UserRegistrationHandler.js':
|
||||
this.UserRegistrationHandler,
|
||||
'../../../../../app/src/Features/Errors/ErrorController.js':
|
||||
this.ErrorController,
|
||||
'../../../../../app/src/Features/SplitTests/SplitTestHandler':
|
||||
this.SplitTestHandler,
|
||||
})
|
||||
this.req = {
|
||||
body: {},
|
||||
query: {},
|
||||
session: {
|
||||
user: this.user,
|
||||
},
|
||||
}
|
||||
this.res = {
|
||||
json: sinon.stub(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('activateAccountPage', function () {
|
||||
beforeEach(function () {
|
||||
this.UserGetter.promises.getUser = sinon.stub().resolves(this.user)
|
||||
this.req.query.user_id = this.user_id
|
||||
this.req.query.token = this.token = 'mock-token-123'
|
||||
})
|
||||
|
||||
it('should 404 without a user_id', async function (done) {
|
||||
delete this.req.query.user_id
|
||||
this.ErrorController.notFound = () => done()
|
||||
this.UserActivateController.activateAccountPage(this.req, this.res)
|
||||
})
|
||||
|
||||
it('should 404 without a token', function (done) {
|
||||
delete this.req.query.token
|
||||
this.ErrorController.notFound = () => done()
|
||||
this.UserActivateController.activateAccountPage(this.req, this.res)
|
||||
})
|
||||
|
||||
it('should 404 without a valid user_id', function (done) {
|
||||
this.UserGetter.promises.getUser = sinon.stub().resolves(null)
|
||||
this.ErrorController.notFound = () => done()
|
||||
this.UserActivateController.activateAccountPage(this.req, this.res)
|
||||
})
|
||||
|
||||
it('should 403 for complex user_id', function (done) {
|
||||
this.ErrorController.forbidden = () => done()
|
||||
this.req.query.user_id = { first_name: 'X' }
|
||||
this.UserActivateController.activateAccountPage(this.req, this.res)
|
||||
})
|
||||
|
||||
it('should redirect activated users to login', function (done) {
|
||||
this.user.loginCount = 1
|
||||
this.res.redirect = url => {
|
||||
sinon.assert.calledWith(this.UserGetter.promises.getUser, this.user_id)
|
||||
url.should.equal('/login')
|
||||
return done()
|
||||
}
|
||||
this.UserActivateController.activateAccountPage(this.req, this.res)
|
||||
})
|
||||
|
||||
it('render the activation page if the user has not logged in before', function (done) {
|
||||
this.user.loginCount = 0
|
||||
this.res.render = (page, opts) => {
|
||||
page.should.equal(VIEW_PATH)
|
||||
opts.email.should.equal(this.user.email)
|
||||
opts.token.should.equal(this.token)
|
||||
return done()
|
||||
}
|
||||
this.UserActivateController.activateAccountPage(this.req, this.res)
|
||||
})
|
||||
})
|
||||
|
||||
describe('register', function () {
|
||||
beforeEach(async function () {
|
||||
this.UserRegistrationHandler.promises.registerNewUserAndSendActivationEmail =
|
||||
sinon.stub().resolves({
|
||||
user: this.user,
|
||||
setNewPasswordUrl: (this.url = 'mock/url'),
|
||||
})
|
||||
this.req.body.email = this.user.email = this.email = 'email@example.com'
|
||||
await this.UserActivateController.register(this.req, this.res)
|
||||
})
|
||||
|
||||
it('should register the user and send them an email', function () {
|
||||
sinon.assert.calledWith(
|
||||
this.UserRegistrationHandler.promises
|
||||
.registerNewUserAndSendActivationEmail,
|
||||
this.email
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the user and activation url', function () {
|
||||
this.res.json
|
||||
.calledWith({
|
||||
email: this.email,
|
||||
setNewPasswordUrl: this.url,
|
||||
})
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1 @@
|
||||
{ "extends": "../../../../tsconfig.backend.json" }
|
Reference in New Issue
Block a user