Add files via upload
This commit is contained in:
parent
cae3e30c32
commit
e8b0d12229
7 changed files with 2570 additions and 0 deletions
964
overleafserver/EmailBuilder.js
Normal file
964
overleafserver/EmailBuilder.js
Normal file
|
@ -0,0 +1,964 @@
|
||||||
|
const _ = require('lodash')
|
||||||
|
const settings = require('@overleaf/settings')
|
||||||
|
const moment = require('moment')
|
||||||
|
const EmailMessageHelper = require('./EmailMessageHelper')
|
||||||
|
const StringHelper = require('../Helpers/StringHelper')
|
||||||
|
const BaseWithHeaderEmailLayout = require('./Layouts/BaseWithHeaderEmailLayout')
|
||||||
|
const SpamSafe = require('./SpamSafe')
|
||||||
|
const ctaEmailBody = require('./Bodies/cta-email')
|
||||||
|
const NoCTAEmailBody = require('./Bodies/NoCTAEmailBody')
|
||||||
|
|
||||||
|
function _emailBodyPlainText(content, opts, ctaEmail) {
|
||||||
|
let emailBody = `${content.greeting(opts, true)}`
|
||||||
|
emailBody += `\r\n\r\n`
|
||||||
|
emailBody += `${content.message(opts, true).join('\r\n\r\n')}`
|
||||||
|
|
||||||
|
if (ctaEmail) {
|
||||||
|
emailBody += `\r\n\r\n`
|
||||||
|
emailBody += `${content.ctaText(opts, true)}: ${content.ctaURL(opts, true)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
content.secondaryMessage(opts, true) &&
|
||||||
|
content.secondaryMessage(opts, true).length > 0
|
||||||
|
) {
|
||||||
|
emailBody += `\r\n\r\n`
|
||||||
|
emailBody += `${content.secondaryMessage(opts, true).join('\r\n\r\n')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
emailBody += `\r\n\r\n`
|
||||||
|
emailBody += `Regards,\r\nThe ${settings.appName} Team - ${settings.siteUrl}`
|
||||||
|
|
||||||
|
if (
|
||||||
|
settings.email &&
|
||||||
|
settings.email.template &&
|
||||||
|
settings.email.template.customFooter
|
||||||
|
) {
|
||||||
|
emailBody += `\r\n\r\n`
|
||||||
|
emailBody += settings.email.template.customFooter
|
||||||
|
}
|
||||||
|
|
||||||
|
return emailBody
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctaTemplate(content) {
|
||||||
|
if (
|
||||||
|
!content.ctaURL ||
|
||||||
|
!content.ctaText ||
|
||||||
|
!content.message ||
|
||||||
|
!content.subject
|
||||||
|
) {
|
||||||
|
throw new Error('missing required CTA email content')
|
||||||
|
}
|
||||||
|
if (!content.title) {
|
||||||
|
content.title = () => {}
|
||||||
|
}
|
||||||
|
if (!content.greeting) {
|
||||||
|
content.greeting = () => 'Hi,'
|
||||||
|
}
|
||||||
|
if (!content.secondaryMessage) {
|
||||||
|
content.secondaryMessage = () => []
|
||||||
|
}
|
||||||
|
if (!content.gmailGoToAction) {
|
||||||
|
content.gmailGoToAction = () => {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
subject(opts) {
|
||||||
|
return content.subject(opts)
|
||||||
|
},
|
||||||
|
layout: BaseWithHeaderEmailLayout,
|
||||||
|
plainTextTemplate(opts) {
|
||||||
|
return _emailBodyPlainText(content, opts, true)
|
||||||
|
},
|
||||||
|
compiledTemplate(opts) {
|
||||||
|
return ctaEmailBody({
|
||||||
|
title: content.title(opts),
|
||||||
|
greeting: content.greeting(opts),
|
||||||
|
message: content.message(opts),
|
||||||
|
secondaryMessage: content.secondaryMessage(opts),
|
||||||
|
ctaText: content.ctaText(opts),
|
||||||
|
ctaURL: content.ctaURL(opts),
|
||||||
|
gmailGoToAction: content.gmailGoToAction(opts),
|
||||||
|
StringHelper,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoCTAEmailTemplate(content) {
|
||||||
|
if (content.greeting == null) {
|
||||||
|
content.greeting = () => 'Hi,'
|
||||||
|
}
|
||||||
|
if (!content.message) {
|
||||||
|
throw new Error('missing message')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
subject(opts) {
|
||||||
|
return content.subject(opts)
|
||||||
|
},
|
||||||
|
layout: BaseWithHeaderEmailLayout,
|
||||||
|
plainTextTemplate(opts) {
|
||||||
|
return `\
|
||||||
|
${content.greeting(opts)}
|
||||||
|
|
||||||
|
${content.message(opts, true).join('\r\n\r\n')}
|
||||||
|
|
||||||
|
Regards,
|
||||||
|
The ${settings.appName} Team - ${settings.siteUrl}\
|
||||||
|
`
|
||||||
|
},
|
||||||
|
compiledTemplate(opts) {
|
||||||
|
return NoCTAEmailBody({
|
||||||
|
title:
|
||||||
|
typeof content.title === 'function' ? content.title(opts) : undefined,
|
||||||
|
greeting: content.greeting(opts),
|
||||||
|
highlightedText:
|
||||||
|
typeof content.highlightedText === 'function'
|
||||||
|
? content.highlightedText(opts)
|
||||||
|
: undefined,
|
||||||
|
message: content.message(opts),
|
||||||
|
StringHelper,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmail(templateName, opts) {
|
||||||
|
const template = templates[templateName]
|
||||||
|
opts.siteUrl = settings.siteUrl
|
||||||
|
opts.body = template.compiledTemplate(opts)
|
||||||
|
return {
|
||||||
|
subject: template.subject(opts),
|
||||||
|
html: template.layout(opts),
|
||||||
|
text: template.plainTextTemplate && template.plainTextTemplate(opts),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = {}
|
||||||
|
|
||||||
|
templates.registered = ctaTemplate({
|
||||||
|
subject() {
|
||||||
|
return `Activate your ${settings.appName} Account`
|
||||||
|
},
|
||||||
|
message(opts) {
|
||||||
|
return [
|
||||||
|
`Congratulations, you've just had an account created for you on ${
|
||||||
|
settings.appName
|
||||||
|
} with the email address '${_.escape(opts.to)}'.`,
|
||||||
|
'Click here to set your password and log in:',
|
||||||
|
]
|
||||||
|
},
|
||||||
|
secondaryMessage() {
|
||||||
|
return [
|
||||||
|
`If you have any questions or problems, please contact ${settings.adminEmail}`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ctaText() {
|
||||||
|
return 'Set password'
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return opts.setNewPasswordUrl
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.canceledSubscription = ctaTemplate({
|
||||||
|
subject() {
|
||||||
|
return `${settings.appName} thoughts`
|
||||||
|
},
|
||||||
|
message() {
|
||||||
|
return [
|
||||||
|
`We are sorry to see you cancelled your ${settings.appName} premium subscription. Would you mind giving us some feedback on what the site is lacking at the moment via this quick survey?`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
secondaryMessage() {
|
||||||
|
return ['Thank you in advance!']
|
||||||
|
},
|
||||||
|
ctaText() {
|
||||||
|
return 'Leave Feedback'
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return 'https://docs.google.com/forms/d/e/1FAIpQLSfa7z_s-cucRRXm70N4jEcSbFsZeb0yuKThHGQL8ySEaQzF0Q/viewform?usp=sf_link'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.reactivatedSubscription = ctaTemplate({
|
||||||
|
subject() {
|
||||||
|
return `Subscription Reactivated - ${settings.appName}`
|
||||||
|
},
|
||||||
|
message(opts) {
|
||||||
|
return ['Your subscription was reactivated successfully.']
|
||||||
|
},
|
||||||
|
ctaText() {
|
||||||
|
return 'View Subscription Dashboard'
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return `${settings.siteUrl}/user/subscription`
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.passwordResetRequested = ctaTemplate({
|
||||||
|
subject() {
|
||||||
|
return `Password Reset - ${settings.appName}`
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return 'Password Reset'
|
||||||
|
},
|
||||||
|
message() {
|
||||||
|
return [`We got a request to reset your ${settings.appName} password.`]
|
||||||
|
},
|
||||||
|
secondaryMessage() {
|
||||||
|
return [
|
||||||
|
"If you ignore this message, your password won't be changed.",
|
||||||
|
"If you didn't request a password reset, let us know.",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ctaText() {
|
||||||
|
return 'Reset password'
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return opts.setNewPasswordUrl
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.confirmEmail = ctaTemplate({
|
||||||
|
subject() {
|
||||||
|
return `Confirm Email - ${settings.appName}`
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return 'Confirm Email'
|
||||||
|
},
|
||||||
|
message(opts) {
|
||||||
|
return [
|
||||||
|
`Please confirm that you have added a new email, ${opts.to}, to your ${settings.appName} account.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
secondaryMessage() {
|
||||||
|
return [
|
||||||
|
`If you did not request this, please let us know at <a href="mailto:${settings.adminEmail}">${settings.adminEmail}</a>.`,
|
||||||
|
`If you have any questions or trouble confirming your email address, please get in touch with our support team at ${settings.adminEmail}.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ctaText() {
|
||||||
|
return 'Confirm Email'
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return opts.confirmEmailUrl
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.confirmCode = NoCTAEmailTemplate({
|
||||||
|
greeting(opts) {
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
subject(opts) {
|
||||||
|
return `Confirm your email address on Overleaf (${opts.confirmCode})`
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
return 'Confirm your email address'
|
||||||
|
},
|
||||||
|
message(opts, isPlainText) {
|
||||||
|
const msg = opts.isSecondary
|
||||||
|
? ['Use this 6-digit code to confirm your email address.']
|
||||||
|
: [
|
||||||
|
`Welcome to Overleaf! We're so glad you joined us.`,
|
||||||
|
'Use this 6-digit confirmation code to finish your setup.',
|
||||||
|
]
|
||||||
|
|
||||||
|
if (isPlainText && opts.confirmCode) {
|
||||||
|
msg.push(opts.confirmCode)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
},
|
||||||
|
highlightedText(opts) {
|
||||||
|
return opts.confirmCode
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.projectInvite = ctaTemplate({
|
||||||
|
subject(opts) {
|
||||||
|
const safeName = SpamSafe.isSafeProjectName(opts.project.name)
|
||||||
|
const safeEmail = SpamSafe.isSafeEmail(opts.owner.email)
|
||||||
|
|
||||||
|
if (safeName && safeEmail) {
|
||||||
|
return `"${_.escape(opts.project.name)}" — shared by ${_.escape(
|
||||||
|
opts.owner.email
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
if (safeName) {
|
||||||
|
return `${settings.appName} project shared with you — "${_.escape(
|
||||||
|
opts.project.name
|
||||||
|
)}"`
|
||||||
|
}
|
||||||
|
if (safeEmail) {
|
||||||
|
return `${_.escape(opts.owner.email)} shared an ${
|
||||||
|
settings.appName
|
||||||
|
} project with you`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `An ${settings.appName} project has been shared with you`
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
return 'Project Invite'
|
||||||
|
},
|
||||||
|
greeting(opts) {
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
message(opts, isPlainText) {
|
||||||
|
// build message depending on spam-safe variables
|
||||||
|
const message = [`You have been invited to an ${settings.appName} project.`]
|
||||||
|
|
||||||
|
if (SpamSafe.isSafeProjectName(opts.project.name)) {
|
||||||
|
message.push('<br/> Project:')
|
||||||
|
message.push(`<b>${_.escape(opts.project.name)}</b>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SpamSafe.isSafeEmail(opts.owner.email)) {
|
||||||
|
message.push(`<br/> Shared by:`)
|
||||||
|
message.push(`<b>${_.escape(opts.owner.email)}</b>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.length === 1) {
|
||||||
|
message.push('<br/> Please view the project to find out more.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return message.map(m => {
|
||||||
|
return EmailMessageHelper.cleanHTML(m, isPlainText)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
ctaText() {
|
||||||
|
return 'View project'
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return opts.inviteUrl
|
||||||
|
},
|
||||||
|
gmailGoToAction(opts) {
|
||||||
|
return {
|
||||||
|
target: opts.inviteUrl,
|
||||||
|
name: 'View project',
|
||||||
|
description: `Join ${_.escape(
|
||||||
|
SpamSafe.safeProjectName(opts.project.name, 'project')
|
||||||
|
)} at ${settings.appName}`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.reconfirmEmail = ctaTemplate({
|
||||||
|
subject() {
|
||||||
|
return `Reconfirm Email - ${settings.appName}`
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return 'Reconfirm Email'
|
||||||
|
},
|
||||||
|
message(opts) {
|
||||||
|
return [
|
||||||
|
`Please reconfirm your email address, ${opts.to}, on your ${settings.appName} account.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
secondaryMessage() {
|
||||||
|
return [
|
||||||
|
'If you did not request this, you can simply ignore this message.',
|
||||||
|
`If you have any questions or trouble confirming your email address, please get in touch with our support team at ${settings.adminEmail}.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ctaText() {
|
||||||
|
return 'Reconfirm Email'
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return opts.confirmEmailUrl
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.verifyEmailToJoinTeam = ctaTemplate({
|
||||||
|
subject(opts) {
|
||||||
|
return `${opts.reminder ? 'Reminder: ' : ''}${_.escape(
|
||||||
|
_formatUserNameAndEmail(opts.inviter, 'A collaborator')
|
||||||
|
)} has invited you to join a group subscription on ${settings.appName}`
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
return `${opts.reminder ? 'Reminder: ' : ''}${_.escape(
|
||||||
|
_formatUserNameAndEmail(opts.inviter, 'A collaborator')
|
||||||
|
)} has invited you to join a group subscription on ${settings.appName}`
|
||||||
|
},
|
||||||
|
message(opts) {
|
||||||
|
return [
|
||||||
|
`Please click the button below to join the group subscription and enjoy the benefits of an upgraded ${settings.appName} account.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ctaText(opts) {
|
||||||
|
return 'Join now'
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return opts.acceptInviteUrl
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.verifyEmailToJoinManagedUsers = ctaTemplate({
|
||||||
|
subject(opts) {
|
||||||
|
return `${
|
||||||
|
opts.reminder ? 'Reminder: ' : ''
|
||||||
|
}You’ve been invited by ${_.escape(
|
||||||
|
_formatUserNameAndEmail(opts.inviter, 'a collaborator')
|
||||||
|
)} to join an ${settings.appName} group subscription.`
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
return `${
|
||||||
|
opts.reminder ? 'Reminder: ' : ''
|
||||||
|
}You’ve been invited by ${_.escape(
|
||||||
|
_formatUserNameAndEmail(opts.inviter, 'a collaborator')
|
||||||
|
)} to join an ${settings.appName} group subscription.`
|
||||||
|
},
|
||||||
|
message(opts) {
|
||||||
|
return [
|
||||||
|
`By joining this group, you'll have access to ${settings.appName} premium features such as additional collaborators, greater maximum compile time, and real-time track changes.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
secondaryMessage(opts, isPlainText) {
|
||||||
|
const changeProjectOwnerLink = EmailMessageHelper.displayLink(
|
||||||
|
'change project owner',
|
||||||
|
`${settings.siteUrl}/learn/how-to/How_to_Transfer_Project_Ownership`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
`<b>User accounts in this group are managed by ${_.escape(
|
||||||
|
_formatUserNameAndEmail(opts.admin, 'an admin')
|
||||||
|
)}</b>`,
|
||||||
|
`If you accept, you’ll transfer the management of your ${settings.appName} account to the owner of the group subscription, who will then have admin rights over your account and control over your stuff.`,
|
||||||
|
`If you have personal projects in your ${settings.appName} account that you want to keep separate, that’s not a problem. You can set up another account under a personal email address and change the ownership of your personal projects to the new account. Find out how to ${changeProjectOwnerLink}.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return opts.acceptInviteUrl
|
||||||
|
},
|
||||||
|
ctaText(opts) {
|
||||||
|
return 'Accept invitation'
|
||||||
|
},
|
||||||
|
greeting() {
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.inviteNewUserToJoinManagedUsers = ctaTemplate({
|
||||||
|
subject(opts) {
|
||||||
|
return `${
|
||||||
|
opts.reminder ? 'Reminder: ' : ''
|
||||||
|
}You’ve been invited by ${_.escape(
|
||||||
|
_formatUserNameAndEmail(opts.inviter, 'a collaborator')
|
||||||
|
)} to join an ${settings.appName} group subscription.`
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
return `${
|
||||||
|
opts.reminder ? 'Reminder: ' : ''
|
||||||
|
}You’ve been invited by ${_.escape(
|
||||||
|
_formatUserNameAndEmail(opts.inviter, 'a collaborator')
|
||||||
|
)} to join an ${settings.appName} group subscription.`
|
||||||
|
},
|
||||||
|
message(opts) {
|
||||||
|
return ['']
|
||||||
|
},
|
||||||
|
secondaryMessage(opts) {
|
||||||
|
return [
|
||||||
|
`<b>User accounts in this group are managed by ${_.escape(
|
||||||
|
_formatUserNameAndEmail(opts.admin, 'an admin')
|
||||||
|
)}.</b>`,
|
||||||
|
`If you accept, the owner of the group subscription will have admin rights over your account and control over your stuff.`,
|
||||||
|
`<b>What is ${settings.appName}?</b>`,
|
||||||
|
`${settings.appName} is the collaborative online LaTeX editor loved by researchers and technical writers. With thousands of ready-to-use templates and an array of LaTeX learning resources you’ll be up and running in no time.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return opts.acceptInviteUrl
|
||||||
|
},
|
||||||
|
ctaText(opts) {
|
||||||
|
return 'Accept invitation'
|
||||||
|
},
|
||||||
|
greeting() {
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.groupSSOLinkingInvite = ctaTemplate({
|
||||||
|
subject(opts) {
|
||||||
|
const subjectPrefix = opts.reminder ? 'Reminder: ' : 'Action required: '
|
||||||
|
return `${subjectPrefix}Authenticate your Overleaf account`
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
const titlePrefix = opts.reminder ? 'Reminder: ' : ''
|
||||||
|
return `${titlePrefix}Single sign-on enabled`
|
||||||
|
},
|
||||||
|
message(opts) {
|
||||||
|
return [
|
||||||
|
`Hi,
|
||||||
|
<div>
|
||||||
|
Your group administrator has enabled single sign-on for your group.
|
||||||
|
</div>
|
||||||
|
</br>
|
||||||
|
<div>
|
||||||
|
<strong>What does this mean for you?</strong>
|
||||||
|
</div>
|
||||||
|
</br>
|
||||||
|
<div>
|
||||||
|
You won't need to remember a separate email address and password to sign in to Overleaf.
|
||||||
|
All you need to do is authenticate your existing Overleaf account with your SSO provider.
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
secondaryMessage(opts) {
|
||||||
|
return [``]
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return opts.authenticateWithSSO
|
||||||
|
},
|
||||||
|
ctaText(opts) {
|
||||||
|
return 'Authenticate with SSO'
|
||||||
|
},
|
||||||
|
greeting() {
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.groupSSOReauthenticate = ctaTemplate({
|
||||||
|
subject(opts) {
|
||||||
|
return 'Action required: Reauthenticate your Overleaf account'
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
return 'Action required: Reauthenticate SSO'
|
||||||
|
},
|
||||||
|
message(opts) {
|
||||||
|
return [
|
||||||
|
`Hi,
|
||||||
|
<div>
|
||||||
|
Single sign-on for your Overleaf group has been updated.
|
||||||
|
This means you need to reauthenticate your Overleaf account with your group’s SSO provider.
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
secondaryMessage(opts) {
|
||||||
|
return [``]
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return opts.authenticateWithSSO
|
||||||
|
},
|
||||||
|
ctaText(opts) {
|
||||||
|
return 'Reauthenticate now'
|
||||||
|
},
|
||||||
|
greeting() {
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.groupSSODisabled = ctaTemplate({
|
||||||
|
subject(opts) {
|
||||||
|
if (opts.userIsManaged) {
|
||||||
|
return `Action required: Set your Overleaf password`
|
||||||
|
} else {
|
||||||
|
return 'A change to your Overleaf login options'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
return `Single sign-on disabled`
|
||||||
|
},
|
||||||
|
message(opts, isPlainText) {
|
||||||
|
const loginUrl = `${settings.siteUrl}/login`
|
||||||
|
let whatDoesThisMeanExplanation = [
|
||||||
|
`You can still log in to Overleaf using one of our other <a href="${loginUrl}" style="color: #0F7A06; text-decoration: none;">login options</a> or with your email address and password.`,
|
||||||
|
`If you don't have a password, you can set one now.`,
|
||||||
|
]
|
||||||
|
if (opts.userIsManaged) {
|
||||||
|
whatDoesThisMeanExplanation = [
|
||||||
|
'You now need an email address and password to sign in to your Overleaf account.',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
'Your group administrator has disabled single sign-on for your group.',
|
||||||
|
'<br/>',
|
||||||
|
'<b>What does this mean for you?</b>',
|
||||||
|
...whatDoesThisMeanExplanation,
|
||||||
|
]
|
||||||
|
|
||||||
|
return message.map(m => {
|
||||||
|
return EmailMessageHelper.cleanHTML(m, isPlainText)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
secondaryMessage(opts) {
|
||||||
|
return [``]
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return opts.setNewPasswordUrl
|
||||||
|
},
|
||||||
|
ctaText(opts) {
|
||||||
|
return 'Set your new password'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.surrenderAccountForManagedUsers = ctaTemplate({
|
||||||
|
subject(opts) {
|
||||||
|
const admin = _.escape(_formatUserNameAndEmail(opts.admin, 'an admin'))
|
||||||
|
|
||||||
|
const toGroupName = opts.groupName ? ` to ${opts.groupName}` : ''
|
||||||
|
|
||||||
|
return `${
|
||||||
|
opts.reminder ? 'Reminder: ' : ''
|
||||||
|
}You’ve been invited by ${admin} to transfer management of your ${
|
||||||
|
settings.appName
|
||||||
|
} account${toGroupName}`
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
const admin = _.escape(_formatUserNameAndEmail(opts.admin, 'an admin'))
|
||||||
|
|
||||||
|
const toGroupName = opts.groupName ? ` to ${opts.groupName}` : ''
|
||||||
|
|
||||||
|
return `${
|
||||||
|
opts.reminder ? 'Reminder: ' : ''
|
||||||
|
}You’ve been invited by ${admin} to transfer management of your ${
|
||||||
|
settings.appName
|
||||||
|
} account${toGroupName}`
|
||||||
|
},
|
||||||
|
message(opts, isPlainText) {
|
||||||
|
const admin = _.escape(_formatUserNameAndEmail(opts.admin, 'an admin'))
|
||||||
|
|
||||||
|
const managedUsersLink = EmailMessageHelper.displayLink(
|
||||||
|
'user account management',
|
||||||
|
`${settings.siteUrl}/learn/how-to/Understanding_Managed_Overleaf_Accounts`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
`Your ${settings.appName} account ${_.escape(
|
||||||
|
opts.to
|
||||||
|
)} is part of ${admin}'s group. They’ve now enabled ${managedUsersLink} for the group. This will ensure that projects aren’t lost when someone leaves the group.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
secondaryMessage(opts, isPlainText) {
|
||||||
|
const transferProjectOwnershipLink = EmailMessageHelper.displayLink(
|
||||||
|
'change project owner',
|
||||||
|
`${settings.siteUrl}/learn/how-to/How_to_Transfer_Project_Ownership`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
`<b>What does this mean for you?</b>`,
|
||||||
|
`If you accept, you’ll transfer the management of your ${settings.appName} account to the owner of the group subscription, who will then have admin rights over your account and control over your stuff.`,
|
||||||
|
`If you have personal projects in your ${settings.appName} account that you want to keep separate, that’s not a problem. You can set up another account under a personal email address and change the ownership of your personal projects to the new account. Find out how to ${transferProjectOwnershipLink}.`,
|
||||||
|
`If you think this invitation has been sent in error please contact your group administrator.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return opts.acceptInviteUrl
|
||||||
|
},
|
||||||
|
ctaText(opts) {
|
||||||
|
return 'Accept invitation'
|
||||||
|
},
|
||||||
|
greeting() {
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.testEmail = ctaTemplate({
|
||||||
|
subject() {
|
||||||
|
return `A Test Email from ${settings.appName}`
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return `A Test Email from ${settings.appName}`
|
||||||
|
},
|
||||||
|
greeting() {
|
||||||
|
return 'Hi,'
|
||||||
|
},
|
||||||
|
message() {
|
||||||
|
return [`This is a test Email from ${settings.appName}`]
|
||||||
|
},
|
||||||
|
ctaText() {
|
||||||
|
return `Open ${settings.appName}`
|
||||||
|
},
|
||||||
|
ctaURL() {
|
||||||
|
return settings.siteUrl
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.ownershipTransferConfirmationPreviousOwner = NoCTAEmailTemplate({
|
||||||
|
subject(opts) {
|
||||||
|
return `Project ownership transfer - ${settings.appName}`
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
const projectName = _.escape(
|
||||||
|
SpamSafe.safeProjectName(opts.project.name, 'Your project')
|
||||||
|
)
|
||||||
|
return `${projectName} - Owner change`
|
||||||
|
},
|
||||||
|
message(opts, isPlainText) {
|
||||||
|
const nameAndEmail = _.escape(
|
||||||
|
_formatUserNameAndEmail(opts.newOwner, 'a collaborator')
|
||||||
|
)
|
||||||
|
const projectName = _.escape(
|
||||||
|
SpamSafe.safeProjectName(opts.project.name, 'your project')
|
||||||
|
)
|
||||||
|
const projectNameDisplay = isPlainText
|
||||||
|
? projectName
|
||||||
|
: `<b>${projectName}</b>`
|
||||||
|
return [
|
||||||
|
`As per your request, we have made ${nameAndEmail} the owner of ${projectNameDisplay}.`,
|
||||||
|
`If you haven't asked to change the owner of ${projectNameDisplay}, please get in touch with us via ${settings.adminEmail}.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.ownershipTransferConfirmationNewOwner = ctaTemplate({
|
||||||
|
subject(opts) {
|
||||||
|
return `Project ownership transfer - ${settings.appName}`
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
const projectName = _.escape(
|
||||||
|
SpamSafe.safeProjectName(opts.project.name, 'Your project')
|
||||||
|
)
|
||||||
|
return `${projectName} - Owner change`
|
||||||
|
},
|
||||||
|
message(opts, isPlainText) {
|
||||||
|
const nameAndEmail = _.escape(
|
||||||
|
_formatUserNameAndEmail(opts.previousOwner, 'A collaborator')
|
||||||
|
)
|
||||||
|
const projectName = _.escape(
|
||||||
|
SpamSafe.safeProjectName(opts.project.name, 'a project')
|
||||||
|
)
|
||||||
|
const projectNameEmphasized = isPlainText
|
||||||
|
? projectName
|
||||||
|
: `<b>${projectName}</b>`
|
||||||
|
return [
|
||||||
|
`${nameAndEmail} has made you the owner of ${projectNameEmphasized}. You can now manage ${projectName} sharing settings.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ctaText(opts) {
|
||||||
|
return 'View project'
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
const projectUrl = `${
|
||||||
|
settings.siteUrl
|
||||||
|
}/project/${opts.project._id.toString()}`
|
||||||
|
return projectUrl
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.userOnboardingEmail = NoCTAEmailTemplate({
|
||||||
|
subject(opts) {
|
||||||
|
return `Getting more out of ${settings.appName}`
|
||||||
|
},
|
||||||
|
greeting(opts) {
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
return `Getting more out of ${settings.appName}`
|
||||||
|
},
|
||||||
|
message(opts, isPlainText) {
|
||||||
|
const learnLatexLink = EmailMessageHelper.displayLink(
|
||||||
|
'Learn LaTeX in 30 minutes',
|
||||||
|
`${settings.siteUrl}/learn/latex/Learn_LaTeX_in_30_minutes?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
const templatesLinks = EmailMessageHelper.displayLink(
|
||||||
|
'Find a beautiful template',
|
||||||
|
`${settings.siteUrl}/latex/templates?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
const collaboratorsLink = EmailMessageHelper.displayLink(
|
||||||
|
'Work with your collaborators',
|
||||||
|
`${settings.siteUrl}/learn/how-to/Sharing_a_project?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
const siteLink = EmailMessageHelper.displayLink(
|
||||||
|
'www.overleaf.com',
|
||||||
|
settings.siteUrl,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
const userSettingsLink = EmailMessageHelper.displayLink(
|
||||||
|
'here',
|
||||||
|
`${settings.siteUrl}/user/email-preferences`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
const onboardingSurveyLink = EmailMessageHelper.displayLink(
|
||||||
|
'Join our user feedback program',
|
||||||
|
'https://forms.gle/DB7pdk2B1VFQqVVB9',
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
`Thanks for signing up for ${settings.appName} recently. We hope you've been finding it useful! Here are some key features to help you get the most out of the service:`,
|
||||||
|
`${learnLatexLink}: In this tutorial we provide a quick and easy first introduction to LaTeX with no prior knowledge required. By the time you are finished, you will have written your first LaTeX document!`,
|
||||||
|
`${templatesLinks}: If you're looking for a template or example to get started, we've a large selection available in our template gallery, including CVs, project reports, journal articles and more.`,
|
||||||
|
`${collaboratorsLink}: One of the key features of Overleaf is the ability to share projects and collaborate on them with other users. Find out how to share your projects with your colleagues in this quick how-to guide.`,
|
||||||
|
`${onboardingSurveyLink} to help us make Overleaf even better!`,
|
||||||
|
'Thanks again for using Overleaf :)',
|
||||||
|
`Lee`,
|
||||||
|
`Lee Shalit<br />CEO<br />${siteLink}<hr>`,
|
||||||
|
`You're receiving this email because you've recently signed up for an Overleaf account. If you've previously subscribed to emails about product offers and company news and events, you can unsubscribe ${userSettingsLink}.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.securityAlert = NoCTAEmailTemplate({
|
||||||
|
subject(opts) {
|
||||||
|
return `Overleaf security note: ${opts.action}`
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
return opts.action.charAt(0).toUpperCase() + opts.action.slice(1)
|
||||||
|
},
|
||||||
|
message(opts, isPlainText) {
|
||||||
|
const dateFormatted = moment().format('dddd D MMMM YYYY')
|
||||||
|
const timeFormatted = moment().format('HH:mm')
|
||||||
|
const helpLink = EmailMessageHelper.displayLink(
|
||||||
|
'quick guide',
|
||||||
|
`${settings.siteUrl}/learn/how-to/Keeping_your_account_secure`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
|
||||||
|
const actionDescribed = EmailMessageHelper.cleanHTML(
|
||||||
|
opts.actionDescribed,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!opts.message) {
|
||||||
|
opts.message = []
|
||||||
|
}
|
||||||
|
const message = opts.message.map(m => {
|
||||||
|
return EmailMessageHelper.cleanHTML(m, isPlainText)
|
||||||
|
})
|
||||||
|
|
||||||
|
return [
|
||||||
|
`We are writing to let you know that ${actionDescribed} on ${dateFormatted} at ${timeFormatted} GMT.`,
|
||||||
|
...message,
|
||||||
|
`If this was you, you can ignore this email.`,
|
||||||
|
`If this was not you, we recommend getting in touch with our support team at ${settings.adminEmail} to report this as potentially suspicious activity on your account.`,
|
||||||
|
`We also encourage you to read our ${helpLink} to keeping your ${settings.appName} account safe.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.SAMLDataCleared = ctaTemplate({
|
||||||
|
subject(opts) {
|
||||||
|
return `Institutional Login No Longer Linked - ${settings.appName}`
|
||||||
|
},
|
||||||
|
title(opts) {
|
||||||
|
return 'Institutional Login No Longer Linked'
|
||||||
|
},
|
||||||
|
message(opts, isPlainText) {
|
||||||
|
return [
|
||||||
|
`We're writing to let you know that due to a bug on our end, we've had to temporarily disable logging into your ${settings.appName} through your institution.`,
|
||||||
|
`To get it going again, you'll need to relink your institutional email address to your ${settings.appName} account via your settings.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
secondaryMessage() {
|
||||||
|
return [
|
||||||
|
`If you ordinarily log in to your ${settings.appName} account through your institution, you may need to set or reset your password to regain access to your account first.`,
|
||||||
|
'This bug did not affect the security of any accounts, but it may have affected license entitlements for a small number of users. We are sorry for any inconvenience that this may cause for you.',
|
||||||
|
`If you have any questions, please get in touch with our support team at ${settings.adminEmail} or by replying to this email.`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ctaText(opts) {
|
||||||
|
return 'Update my Emails and Affiliations'
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return `${settings.siteUrl}/user/settings`
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.welcome = ctaTemplate({
|
||||||
|
subject() {
|
||||||
|
return `Welcome to ${settings.appName}`
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return `Welcome to ${settings.appName}`
|
||||||
|
},
|
||||||
|
greeting() {
|
||||||
|
return 'Hi,'
|
||||||
|
},
|
||||||
|
message(opts, isPlainText) {
|
||||||
|
const logInAgainDisplay = EmailMessageHelper.displayLink(
|
||||||
|
'log in again',
|
||||||
|
`${settings.siteUrl}/login`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
const helpGuidesDisplay = EmailMessageHelper.displayLink(
|
||||||
|
'Help Guides',
|
||||||
|
`${settings.siteUrl}/learn`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
const templatesDisplay = EmailMessageHelper.displayLink(
|
||||||
|
'Templates',
|
||||||
|
`${settings.siteUrl}/templates`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
`Thanks for signing up to ${settings.appName}! If you ever get lost, you can ${logInAgainDisplay} with the email address '${opts.to}'.`,
|
||||||
|
`If you're new to LaTeX, take a look at our ${helpGuidesDisplay} and ${templatesDisplay}.`,
|
||||||
|
`Please also take a moment to confirm your email address for ${settings.appName}:`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
secondaryMessage() {
|
||||||
|
return [
|
||||||
|
`PS. We love talking to our users about ${settings.appName}. Reply to this email to get in touch with us directly, whatever the reason. Questions, comments, problems, suggestions, all welcome!`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ctaText() {
|
||||||
|
return 'Confirm Email'
|
||||||
|
},
|
||||||
|
ctaURL(opts) {
|
||||||
|
return opts.confirmEmailUrl
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
templates.welcomeWithoutCTA = NoCTAEmailTemplate({
|
||||||
|
subject() {
|
||||||
|
return `Welcome to ${settings.appName}`
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return `Welcome to ${settings.appName}`
|
||||||
|
},
|
||||||
|
greeting() {
|
||||||
|
return 'Hi,'
|
||||||
|
},
|
||||||
|
message(opts, isPlainText) {
|
||||||
|
const logInAgainDisplay = EmailMessageHelper.displayLink(
|
||||||
|
'log in again',
|
||||||
|
`${settings.siteUrl}/login`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
const helpGuidesDisplay = EmailMessageHelper.displayLink(
|
||||||
|
'Help Guides',
|
||||||
|
`${settings.siteUrl}/learn`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
const templatesDisplay = EmailMessageHelper.displayLink(
|
||||||
|
'Templates',
|
||||||
|
`${settings.siteUrl}/templates`,
|
||||||
|
isPlainText
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
`Thanks for signing up to ${settings.appName}! If you ever get lost, you can ${logInAgainDisplay} with the email address '${opts.to}'.`,
|
||||||
|
`If you're new to LaTeX, take a look at our ${helpGuidesDisplay} and ${templatesDisplay}.`,
|
||||||
|
`PS. We love talking to our users about ${settings.appName}. Reply to this email to get in touch with us directly, whatever the reason. Questions, comments, problems, suggestions, all welcome!`,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function _formatUserNameAndEmail(user, placeholder) {
|
||||||
|
if (user.first_name && user.last_name) {
|
||||||
|
const fullName = `${user.first_name} ${user.last_name}`
|
||||||
|
if (SpamSafe.isSafeUserName(fullName)) {
|
||||||
|
if (SpamSafe.isSafeEmail(user.email)) {
|
||||||
|
return `${fullName} (${user.email})`
|
||||||
|
} else {
|
||||||
|
return fullName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SpamSafe.safeEmail(user.email, placeholder)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
templates,
|
||||||
|
ctaTemplate,
|
||||||
|
NoCTAEmailTemplate,
|
||||||
|
buildEmail,
|
||||||
|
}
|
136
overleafserver/UserRegistrationHandler.js
Normal file
136
overleafserver/UserRegistrationHandler.js
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
const { User } = require('../../models/User')
|
||||||
|
const UserCreator = require('./UserCreator')
|
||||||
|
const UserGetter = require('./UserGetter')
|
||||||
|
const AuthenticationManager = require('../Authentication/AuthenticationManager')
|
||||||
|
const NewsletterManager = require('../Newsletter/NewsletterManager')
|
||||||
|
const logger = require('@overleaf/logger')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const EmailHandler = require('../Email/EmailHandler')
|
||||||
|
const OneTimeTokenHandler = require('../Security/OneTimeTokenHandler')
|
||||||
|
const settings = require('@overleaf/settings')
|
||||||
|
const EmailHelper = require('../Helpers/EmailHelper')
|
||||||
|
const {
|
||||||
|
callbackify,
|
||||||
|
callbackifyMultiResult,
|
||||||
|
} = require('@overleaf/promise-utils')
|
||||||
|
const OError = require('@overleaf/o-error')
|
||||||
|
|
||||||
|
const UserRegistrationHandler = {
|
||||||
|
_registrationRequestIsValid(body) {
|
||||||
|
const invalidEmail = AuthenticationManager.validateEmail(body.email || '')
|
||||||
|
const invalidPassword = AuthenticationManager.validatePassword(
|
||||||
|
body.password || '',
|
||||||
|
body.email
|
||||||
|
)
|
||||||
|
return !(invalidEmail || invalidPassword)
|
||||||
|
},
|
||||||
|
|
||||||
|
async _createNewUserIfRequired(user, userDetails) {
|
||||||
|
if (!user) {
|
||||||
|
userDetails.holdingAccount = false
|
||||||
|
return await UserCreator.promises.createNewUser(
|
||||||
|
{
|
||||||
|
holdingAccount: false,
|
||||||
|
email: userDetails.email,
|
||||||
|
first_name: userDetails.first_name,
|
||||||
|
last_name: userDetails.last_name,
|
||||||
|
analyticsId: userDetails.analyticsId,
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
|
||||||
|
async registerNewUser(userDetails) {
|
||||||
|
const requestIsValid =
|
||||||
|
UserRegistrationHandler._registrationRequestIsValid(userDetails)
|
||||||
|
|
||||||
|
if (!requestIsValid) {
|
||||||
|
throw new Error('request is not valid')
|
||||||
|
}
|
||||||
|
userDetails.email = EmailHelper.parseEmail(userDetails.email)
|
||||||
|
|
||||||
|
let user = await UserGetter.promises.getUserByAnyEmail(userDetails.email)
|
||||||
|
if (user && user.holdingAccount === false) {
|
||||||
|
// We add userId to the error object so that the calling function can access
|
||||||
|
// the id of the already existing user account.
|
||||||
|
throw new OError('EmailAlreadyRegistered', { userId: user._id })
|
||||||
|
}
|
||||||
|
|
||||||
|
user = await UserRegistrationHandler._createNewUserIfRequired(
|
||||||
|
user,
|
||||||
|
userDetails
|
||||||
|
)
|
||||||
|
|
||||||
|
await User.updateOne(
|
||||||
|
{ _id: user._id },
|
||||||
|
{ $set: { holdingAccount: false } }
|
||||||
|
).exec()
|
||||||
|
|
||||||
|
await AuthenticationManager.promises.setUserPassword(
|
||||||
|
user,
|
||||||
|
userDetails.password
|
||||||
|
)
|
||||||
|
|
||||||
|
if (userDetails.subscribeToNewsletter === 'true') {
|
||||||
|
try {
|
||||||
|
NewsletterManager.subscribe(user)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ err: error, user },
|
||||||
|
'Failed to subscribe user to newsletter'
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
|
||||||
|
async registerNewUserAndSendActivationEmail(email) {
|
||||||
|
let user
|
||||||
|
try {
|
||||||
|
user = await UserRegistrationHandler.registerNewUser({
|
||||||
|
email,
|
||||||
|
password: crypto.randomBytes(32).toString('hex'),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message === 'EmailAlreadyRegistered') {
|
||||||
|
logger.debug({ email }, 'user already exists, resending welcome email')
|
||||||
|
user = await UserGetter.promises.getUserByAnyEmail(email)
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ONE_WEEK = 7 * 24 * 60 * 60 // seconds
|
||||||
|
const token = await OneTimeTokenHandler.promises.getNewToken(
|
||||||
|
'password',
|
||||||
|
{ user_id: user._id.toString(), email: user.email },
|
||||||
|
{ expiresIn: ONE_WEEK }
|
||||||
|
)
|
||||||
|
|
||||||
|
const setNewPasswordUrl = `${settings.siteUrl}/user/activate?token=${token}&user_id=${user._id}`
|
||||||
|
|
||||||
|
await EmailHandler.promises
|
||||||
|
.sendEmail('registered', {
|
||||||
|
to: user.email,
|
||||||
|
setNewPasswordUrl,
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
logger.warn({ err: error }, 'failed to send activation email')
|
||||||
|
})
|
||||||
|
|
||||||
|
return { user, setNewPasswordUrl }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
registerNewUser: callbackify(UserRegistrationHandler.registerNewUser),
|
||||||
|
registerNewUserAndSendActivationEmail: callbackifyMultiResult(
|
||||||
|
UserRegistrationHandler.registerNewUserAndSendActivationEmail,
|
||||||
|
['user', 'setNewPasswordUrl']
|
||||||
|
),
|
||||||
|
promises: UserRegistrationHandler,
|
||||||
|
}
|
|
@ -0,0 +1,156 @@
|
||||||
|
let ProjectEditorHandler
|
||||||
|
const _ = require('lodash')
|
||||||
|
const Path = require('path')
|
||||||
|
|
||||||
|
function mergeDeletedDocs(a, b) {
|
||||||
|
const docIdsInA = new Set(a.map(doc => doc._id.toString()))
|
||||||
|
return a.concat(b.filter(doc => !docIdsInA.has(doc._id.toString())))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ProjectEditorHandler = {
|
||||||
|
trackChangesAvailable: true,
|
||||||
|
|
||||||
|
buildProjectModelView(project, members, invites, deletedDocsFromDocstore) {
|
||||||
|
let owner, ownerFeatures
|
||||||
|
if (!Array.isArray(project.deletedDocs)) {
|
||||||
|
project.deletedDocs = []
|
||||||
|
}
|
||||||
|
project.deletedDocs.forEach(doc => {
|
||||||
|
// The frontend does not use this field.
|
||||||
|
delete doc.deletedAt
|
||||||
|
})
|
||||||
|
const result = {
|
||||||
|
_id: project._id,
|
||||||
|
name: project.name,
|
||||||
|
rootDoc_id: project.rootDoc_id,
|
||||||
|
rootFolder: [this.buildFolderModelView(project.rootFolder[0])],
|
||||||
|
publicAccesLevel: project.publicAccesLevel,
|
||||||
|
dropboxEnabled: !!project.existsInDropbox,
|
||||||
|
compiler: project.compiler,
|
||||||
|
description: project.description,
|
||||||
|
spellCheckLanguage: project.spellCheckLanguage,
|
||||||
|
deletedByExternalDataSource: project.deletedByExternalDataSource || false,
|
||||||
|
deletedDocs: mergeDeletedDocs(
|
||||||
|
project.deletedDocs,
|
||||||
|
deletedDocsFromDocstore
|
||||||
|
),
|
||||||
|
members: [],
|
||||||
|
invites: this.buildInvitesView(invites),
|
||||||
|
imageName:
|
||||||
|
project.imageName != null
|
||||||
|
? Path.basename(project.imageName)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
;({ owner, ownerFeatures, members } =
|
||||||
|
this.buildOwnerAndMembersViews(members))
|
||||||
|
result.owner = owner
|
||||||
|
result.members = members
|
||||||
|
|
||||||
|
result.features = _.defaults(ownerFeatures || {}, {
|
||||||
|
collaborators: -1, // Infinite
|
||||||
|
versioning: false,
|
||||||
|
dropbox: false,
|
||||||
|
compileTimeout: 60,
|
||||||
|
compileGroup: 'standard',
|
||||||
|
templates: false,
|
||||||
|
references: false,
|
||||||
|
referencesSearch: false,
|
||||||
|
mendeley: false,
|
||||||
|
trackChanges: true,
|
||||||
|
trackChangesVisible: ProjectEditorHandler.trackChangesAvailable,
|
||||||
|
symbolPalette: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.features.trackChanges) {
|
||||||
|
result.trackChangesState = project.track_changes || false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Originally these two feature flags were both signalled by the now-deprecated `references` flag.
|
||||||
|
// For older users, the presence of the `references` feature flag should still turn on these features.
|
||||||
|
result.features.referencesSearch =
|
||||||
|
result.features.referencesSearch || result.features.references
|
||||||
|
result.features.mendeley =
|
||||||
|
result.features.mendeley || result.features.references
|
||||||
|
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
|
||||||
|
buildOwnerAndMembersViews(members) {
|
||||||
|
let owner = null
|
||||||
|
let ownerFeatures = null
|
||||||
|
const filteredMembers = []
|
||||||
|
for (const member of members || []) {
|
||||||
|
if (member.privilegeLevel === 'owner') {
|
||||||
|
ownerFeatures = member.user.features
|
||||||
|
owner = this.buildUserModelView(member.user, 'owner')
|
||||||
|
} else {
|
||||||
|
filteredMembers.push(
|
||||||
|
this.buildUserModelView(member.user, member.privilegeLevel)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
owner,
|
||||||
|
ownerFeatures,
|
||||||
|
members: filteredMembers,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
buildUserModelView(user, privileges) {
|
||||||
|
return {
|
||||||
|
_id: user._id,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
email: user.email,
|
||||||
|
privileges,
|
||||||
|
signUpDate: user.signUpDate,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
buildFolderModelView(folder) {
|
||||||
|
const fileRefs = _.filter(folder.fileRefs || [], file => file != null)
|
||||||
|
return {
|
||||||
|
_id: folder._id,
|
||||||
|
name: folder.name,
|
||||||
|
folders: (folder.folders || []).map(childFolder =>
|
||||||
|
this.buildFolderModelView(childFolder)
|
||||||
|
),
|
||||||
|
fileRefs: fileRefs.map(file => this.buildFileModelView(file)),
|
||||||
|
docs: (folder.docs || []).map(doc => this.buildDocModelView(doc)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
buildFileModelView(file) {
|
||||||
|
return {
|
||||||
|
_id: file._id,
|
||||||
|
name: file.name,
|
||||||
|
linkedFileData: file.linkedFileData,
|
||||||
|
created: file.created,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
buildDocModelView(doc) {
|
||||||
|
return {
|
||||||
|
_id: doc._id,
|
||||||
|
name: doc.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
buildInvitesView(invites) {
|
||||||
|
if (invites == null) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return invites.map(invite =>
|
||||||
|
_.pick(invite, [
|
||||||
|
'_id',
|
||||||
|
'createdAt',
|
||||||
|
'email',
|
||||||
|
'expires',
|
||||||
|
'privileges',
|
||||||
|
'projectId',
|
||||||
|
'sendingUserId',
|
||||||
|
])
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
932
overleafserver/services/web/config/settings.defaults.js
Normal file
932
overleafserver/services/web/config/settings.defaults.js
Normal file
|
@ -0,0 +1,932 @@
|
||||||
|
const Path = require('path')
|
||||||
|
const { merge } = require('@overleaf/settings/merge')
|
||||||
|
|
||||||
|
let defaultFeatures, siteUrl
|
||||||
|
|
||||||
|
// Make time interval config easier.
|
||||||
|
const seconds = 1000
|
||||||
|
const minutes = 60 * seconds
|
||||||
|
|
||||||
|
// These credentials are used for authenticating api requests
|
||||||
|
// between services that may need to go over public channels
|
||||||
|
const httpAuthUser = process.env.WEB_API_USER
|
||||||
|
const httpAuthPass = process.env.WEB_API_PASSWORD
|
||||||
|
const httpAuthUsers = {}
|
||||||
|
if (httpAuthUser && httpAuthPass) {
|
||||||
|
httpAuthUsers[httpAuthUser] = httpAuthPass
|
||||||
|
}
|
||||||
|
|
||||||
|
const intFromEnv = function (name, defaultValue) {
|
||||||
|
if (
|
||||||
|
[null, undefined].includes(defaultValue) ||
|
||||||
|
typeof defaultValue !== 'number'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Bad default integer value for setting: ${name}, ${defaultValue}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return parseInt(process.env[name], 10) || defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTextExtensions = [
|
||||||
|
'tex',
|
||||||
|
'latex',
|
||||||
|
'sty',
|
||||||
|
'cls',
|
||||||
|
'bst',
|
||||||
|
'bib',
|
||||||
|
'bibtex',
|
||||||
|
'txt',
|
||||||
|
'tikz',
|
||||||
|
'mtx',
|
||||||
|
'rtex',
|
||||||
|
'md',
|
||||||
|
'asy',
|
||||||
|
'lbx',
|
||||||
|
'bbx',
|
||||||
|
'cbx',
|
||||||
|
'm',
|
||||||
|
'lco',
|
||||||
|
'dtx',
|
||||||
|
'ins',
|
||||||
|
'ist',
|
||||||
|
'def',
|
||||||
|
'clo',
|
||||||
|
'ldf',
|
||||||
|
'rmd',
|
||||||
|
'lua',
|
||||||
|
'gv',
|
||||||
|
'mf',
|
||||||
|
'yml',
|
||||||
|
'yaml',
|
||||||
|
'lhs',
|
||||||
|
'mk',
|
||||||
|
'xmpdata',
|
||||||
|
'cfg',
|
||||||
|
'rnw',
|
||||||
|
'ltx',
|
||||||
|
'inc',
|
||||||
|
]
|
||||||
|
|
||||||
|
const parseTextExtensions = function (extensions) {
|
||||||
|
if (extensions) {
|
||||||
|
return extensions.split(',').map(ext => ext.trim())
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpPermissionsPolicy = {
|
||||||
|
blocked: [
|
||||||
|
'accelerometer',
|
||||||
|
'attribution-reporting',
|
||||||
|
'browsing-topics',
|
||||||
|
'camera',
|
||||||
|
'display-capture',
|
||||||
|
'encrypted-media',
|
||||||
|
'gamepad',
|
||||||
|
'geolocation',
|
||||||
|
'gyroscope',
|
||||||
|
'hid',
|
||||||
|
'identity-credentials-get',
|
||||||
|
'idle-detection',
|
||||||
|
'local-fonts',
|
||||||
|
'magnetometer',
|
||||||
|
'microphone',
|
||||||
|
'midi',
|
||||||
|
'otp-credentials',
|
||||||
|
'payment',
|
||||||
|
'picture-in-picture',
|
||||||
|
'screen-wake-lock',
|
||||||
|
'serial',
|
||||||
|
'storage-access',
|
||||||
|
'usb',
|
||||||
|
'window-management',
|
||||||
|
'xr-spatial-tracking',
|
||||||
|
],
|
||||||
|
allowed: {
|
||||||
|
autoplay: 'self "https://videos.ctfassets.net"',
|
||||||
|
fullscreen: 'self',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
env: 'server-ce',
|
||||||
|
|
||||||
|
limits: {
|
||||||
|
httpGlobalAgentMaxSockets: 300,
|
||||||
|
httpsGlobalAgentMaxSockets: 300,
|
||||||
|
},
|
||||||
|
|
||||||
|
allowAnonymousReadAndWriteSharing:
|
||||||
|
process.env.OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING === 'true',
|
||||||
|
|
||||||
|
// Databases
|
||||||
|
// ---------
|
||||||
|
mongo: {
|
||||||
|
options: {
|
||||||
|
appname: 'web',
|
||||||
|
maxPoolSize: parseInt(process.env.MONGO_POOL_SIZE, 10) || 100,
|
||||||
|
serverSelectionTimeoutMS:
|
||||||
|
parseInt(process.env.MONGO_SERVER_SELECTION_TIMEOUT, 10) || 60000,
|
||||||
|
// Setting socketTimeoutMS to 0 means no timeout
|
||||||
|
socketTimeoutMS: parseInt(
|
||||||
|
process.env.MONGO_SOCKET_TIMEOUT ?? '60000',
|
||||||
|
10
|
||||||
|
),
|
||||||
|
monitorCommands: true,
|
||||||
|
},
|
||||||
|
url:
|
||||||
|
process.env.MONGO_CONNECTION_STRING ||
|
||||||
|
process.env.MONGO_URL ||
|
||||||
|
`mongodb://${process.env.MONGO_HOST || '127.0.0.1'}/sharelatex`,
|
||||||
|
hasSecondaries: process.env.MONGO_HAS_SECONDARIES === 'true',
|
||||||
|
},
|
||||||
|
|
||||||
|
redis: {
|
||||||
|
web: {
|
||||||
|
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||||
|
port: process.env.REDIS_PORT || '6379',
|
||||||
|
password: process.env.REDIS_PASSWORD || '',
|
||||||
|
db: process.env.REDIS_DB,
|
||||||
|
maxRetriesPerRequest: parseInt(
|
||||||
|
process.env.REDIS_MAX_RETRIES_PER_REQUEST || '20'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
// websessions:
|
||||||
|
// cluster: [
|
||||||
|
// {host: '127.0.0.1', port: 7000}
|
||||||
|
// {host: '127.0.0.1', port: 7001}
|
||||||
|
// {host: '127.0.0.1', port: 7002}
|
||||||
|
// {host: '127.0.0.1', port: 7003}
|
||||||
|
// {host: '127.0.0.1', port: 7004}
|
||||||
|
// {host: '127.0.0.1', port: 7005}
|
||||||
|
// ]
|
||||||
|
|
||||||
|
// ratelimiter:
|
||||||
|
// cluster: [
|
||||||
|
// {host: '127.0.0.1', port: 7000}
|
||||||
|
// {host: '127.0.0.1', port: 7001}
|
||||||
|
// {host: '127.0.0.1', port: 7002}
|
||||||
|
// {host: '127.0.0.1', port: 7003}
|
||||||
|
// {host: '127.0.0.1', port: 7004}
|
||||||
|
// {host: '127.0.0.1', port: 7005}
|
||||||
|
// ]
|
||||||
|
|
||||||
|
// cooldown:
|
||||||
|
// cluster: [
|
||||||
|
// {host: '127.0.0.1', port: 7000}
|
||||||
|
// {host: '127.0.0.1', port: 7001}
|
||||||
|
// {host: '127.0.0.1', port: 7002}
|
||||||
|
// {host: '127.0.0.1', port: 7003}
|
||||||
|
// {host: '127.0.0.1', port: 7004}
|
||||||
|
// {host: '127.0.0.1', port: 7005}
|
||||||
|
// ]
|
||||||
|
|
||||||
|
api: {
|
||||||
|
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||||
|
port: process.env.REDIS_PORT || '6379',
|
||||||
|
password: process.env.REDIS_PASSWORD || '',
|
||||||
|
maxRetriesPerRequest: parseInt(
|
||||||
|
process.env.REDIS_MAX_RETRIES_PER_REQUEST || '20'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Service locations
|
||||||
|
// -----------------
|
||||||
|
|
||||||
|
// Configure which ports to run each service on. Generally you
|
||||||
|
// can leave these as they are unless you have some other services
|
||||||
|
// running which conflict, or want to run the web process on port 80.
|
||||||
|
internal: {
|
||||||
|
web: {
|
||||||
|
port: process.env.WEB_PORT || 3000,
|
||||||
|
host: process.env.LISTEN_ADDRESS || '127.0.0.1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tell each service where to find the other services. If everything
|
||||||
|
// is running locally then this is easy, but they exist as separate config
|
||||||
|
// options incase you want to run some services on remote hosts.
|
||||||
|
apis: {
|
||||||
|
web: {
|
||||||
|
url: `http://${
|
||||||
|
process.env.WEB_API_HOST || process.env.WEB_HOST || '127.0.0.1'
|
||||||
|
}:${process.env.WEB_API_PORT || process.env.WEB_PORT || 3000}`,
|
||||||
|
user: httpAuthUser,
|
||||||
|
pass: httpAuthPass,
|
||||||
|
},
|
||||||
|
documentupdater: {
|
||||||
|
url: `http://${
|
||||||
|
process.env.DOCUPDATER_HOST ||
|
||||||
|
process.env.DOCUMENT_UPDATER_HOST ||
|
||||||
|
'127.0.0.1'
|
||||||
|
}:3003`,
|
||||||
|
},
|
||||||
|
spelling: {
|
||||||
|
url: `http://${process.env.SPELLING_HOST || '127.0.0.1'}:3005`,
|
||||||
|
host: process.env.SPELLING_HOST,
|
||||||
|
},
|
||||||
|
docstore: {
|
||||||
|
url: `http://${process.env.DOCSTORE_HOST || '127.0.0.1'}:3016`,
|
||||||
|
pubUrl: `http://${process.env.DOCSTORE_HOST || '127.0.0.1'}:3016`,
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
internal_url: `http://${process.env.CHAT_HOST || '127.0.0.1'}:3010`,
|
||||||
|
},
|
||||||
|
filestore: {
|
||||||
|
url: `http://${process.env.FILESTORE_HOST || '127.0.0.1'}:3009`,
|
||||||
|
},
|
||||||
|
clsi: {
|
||||||
|
url: `http://${process.env.CLSI_HOST || '127.0.0.1'}:3013`,
|
||||||
|
// url: "http://#{process.env['CLSI_LB_HOST']}:3014"
|
||||||
|
backendGroupName: undefined,
|
||||||
|
submissionBackendClass:
|
||||||
|
process.env.CLSI_SUBMISSION_BACKEND_CLASS || 'n2d',
|
||||||
|
},
|
||||||
|
project_history: {
|
||||||
|
sendProjectStructureOps: true,
|
||||||
|
url: `http://${process.env.PROJECT_HISTORY_HOST || '127.0.0.1'}:3054`,
|
||||||
|
},
|
||||||
|
realTime: {
|
||||||
|
url: `http://${process.env.REALTIME_HOST || '127.0.0.1'}:3026`,
|
||||||
|
},
|
||||||
|
contacts: {
|
||||||
|
url: `http://${process.env.CONTACTS_HOST || '127.0.0.1'}:3036`,
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
url: `http://${process.env.NOTIFICATIONS_HOST || '127.0.0.1'}:3042`,
|
||||||
|
},
|
||||||
|
webpack: {
|
||||||
|
url: `http://${process.env.WEBPACK_HOST || '127.0.0.1'}:3808`,
|
||||||
|
},
|
||||||
|
wiki: {
|
||||||
|
url: process.env.WIKI_URL || 'https://learn.sharelatex.com',
|
||||||
|
maxCacheAge: parseInt(process.env.WIKI_MAX_CACHE_AGE || 5 * minutes, 10),
|
||||||
|
},
|
||||||
|
|
||||||
|
haveIBeenPwned: {
|
||||||
|
enabled: process.env.HAVE_I_BEEN_PWNED_ENABLED === 'true',
|
||||||
|
url:
|
||||||
|
process.env.HAVE_I_BEEN_PWNED_URL || 'https://api.pwnedpasswords.com',
|
||||||
|
timeout: parseInt(process.env.HAVE_I_BEEN_PWNED_TIMEOUT, 10) || 5 * 1000,
|
||||||
|
},
|
||||||
|
|
||||||
|
// For legacy reasons, we need to populate the below objects.
|
||||||
|
v1: {},
|
||||||
|
recurly: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Defines which features are allowed in the
|
||||||
|
// Permissions-Policy HTTP header
|
||||||
|
httpPermissions: httpPermissionsPolicy,
|
||||||
|
useHttpPermissionsPolicy: true,
|
||||||
|
|
||||||
|
jwt: {
|
||||||
|
key: process.env.OT_JWT_AUTH_KEY,
|
||||||
|
algorithm: process.env.OT_JWT_AUTH_ALG || 'HS256',
|
||||||
|
},
|
||||||
|
|
||||||
|
devToolbar: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
splitTests: [],
|
||||||
|
|
||||||
|
// Where your instance of Overleaf Community Edition/Server Pro can be found publicly. Used in emails
|
||||||
|
// that are sent out, generated links, etc.
|
||||||
|
siteUrl: (siteUrl = process.env.PUBLIC_URL || 'http://127.0.0.1:3000'),
|
||||||
|
|
||||||
|
lockManager: {
|
||||||
|
lockTestInterval: intFromEnv('LOCK_MANAGER_LOCK_TEST_INTERVAL', 50),
|
||||||
|
maxTestInterval: intFromEnv('LOCK_MANAGER_MAX_TEST_INTERVAL', 1000),
|
||||||
|
maxLockWaitTime: intFromEnv('LOCK_MANAGER_MAX_LOCK_WAIT_TIME', 10000),
|
||||||
|
redisLockExpiry: intFromEnv('LOCK_MANAGER_REDIS_LOCK_EXPIRY', 30),
|
||||||
|
slowExecutionThreshold: intFromEnv(
|
||||||
|
'LOCK_MANAGER_SLOW_EXECUTION_THRESHOLD',
|
||||||
|
5000
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Optional separate location for websocket connections, if unset defaults to siteUrl.
|
||||||
|
wsUrl: process.env.WEBSOCKET_URL,
|
||||||
|
wsUrlV2: process.env.WEBSOCKET_URL_V2,
|
||||||
|
wsUrlBeta: process.env.WEBSOCKET_URL_BETA,
|
||||||
|
|
||||||
|
wsUrlV2Percentage: parseInt(
|
||||||
|
process.env.WEBSOCKET_URL_V2_PERCENTAGE || '0',
|
||||||
|
10
|
||||||
|
),
|
||||||
|
wsRetryHandshake: parseInt(process.env.WEBSOCKET_RETRY_HANDSHAKE || '5', 10),
|
||||||
|
|
||||||
|
// cookie domain
|
||||||
|
// use full domain for cookies to only be accessible from that domain,
|
||||||
|
// replace subdomain with dot to have them accessible on all subdomains
|
||||||
|
cookieDomain: process.env.COOKIE_DOMAIN,
|
||||||
|
cookieName: process.env.COOKIE_NAME || 'overleaf.sid',
|
||||||
|
cookieRollingSession: true,
|
||||||
|
|
||||||
|
// this is only used if cookies are used for clsi backend
|
||||||
|
// clsiCookieKey: "clsiserver"
|
||||||
|
|
||||||
|
robotsNoindex: process.env.ROBOTS_NOINDEX === 'true' || false,
|
||||||
|
|
||||||
|
maxEntitiesPerProject: parseInt(
|
||||||
|
process.env.MAX_ENTITIES_PER_PROJECT || '2000',
|
||||||
|
10
|
||||||
|
),
|
||||||
|
|
||||||
|
projectUploadTimeout: parseInt(
|
||||||
|
process.env.PROJECT_UPLOAD_TIMEOUT || '120000',
|
||||||
|
10
|
||||||
|
),
|
||||||
|
maxUploadSize: 50 * 1024 * 1024, // 50 MB
|
||||||
|
multerOptions: {
|
||||||
|
preservePath: process.env.MULTER_PRESERVE_PATH,
|
||||||
|
},
|
||||||
|
|
||||||
|
// start failing the health check if active handles exceeds this limit
|
||||||
|
maxActiveHandles: process.env.MAX_ACTIVE_HANDLES
|
||||||
|
? parseInt(process.env.MAX_ACTIVE_HANDLES, 10)
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
// Security
|
||||||
|
// --------
|
||||||
|
security: {
|
||||||
|
sessionSecret: process.env.SESSION_SECRET,
|
||||||
|
sessionSecretUpcoming: process.env.SESSION_SECRET_UPCOMING,
|
||||||
|
sessionSecretFallback: process.env.SESSION_SECRET_FALLBACK,
|
||||||
|
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS, 10) || 12,
|
||||||
|
}, // number of rounds used to hash user passwords (raised to power 2)
|
||||||
|
|
||||||
|
adminUrl: process.env.ADMIN_URL,
|
||||||
|
adminOnlyLogin: process.env.ADMIN_ONLY_LOGIN === 'true',
|
||||||
|
adminPrivilegeAvailable: process.env.ADMIN_PRIVILEGE_AVAILABLE === 'true',
|
||||||
|
blockCrossOriginRequests: process.env.BLOCK_CROSS_ORIGIN_REQUESTS === 'true',
|
||||||
|
allowedOrigins: (process.env.ALLOWED_ORIGINS || siteUrl).split(','),
|
||||||
|
|
||||||
|
httpAuthUsers,
|
||||||
|
|
||||||
|
// Default features
|
||||||
|
// ----------------
|
||||||
|
//
|
||||||
|
// You can select the features that are enabled by default for new
|
||||||
|
// new users.
|
||||||
|
defaultFeatures: (defaultFeatures = {
|
||||||
|
collaborators: -1,
|
||||||
|
dropbox: true,
|
||||||
|
github: true,
|
||||||
|
gitBridge: true,
|
||||||
|
versioning: true,
|
||||||
|
compileTimeout: 180,
|
||||||
|
compileGroup: 'standard',
|
||||||
|
references: true,
|
||||||
|
trackChanges: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// featuresEpoch: 'YYYY-MM-DD',
|
||||||
|
|
||||||
|
features: {
|
||||||
|
personal: defaultFeatures,
|
||||||
|
},
|
||||||
|
|
||||||
|
groupPlanModalOptions: {
|
||||||
|
plan_codes: [],
|
||||||
|
currencies: [],
|
||||||
|
sizes: [],
|
||||||
|
usages: [],
|
||||||
|
},
|
||||||
|
plans: [
|
||||||
|
{
|
||||||
|
planCode: 'personal',
|
||||||
|
name: 'Personal',
|
||||||
|
price_in_cents: 0,
|
||||||
|
features: defaultFeatures,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
enableSubscriptions: false,
|
||||||
|
restrictedCountries: [],
|
||||||
|
enableOnboardingEmails: process.env.ENABLE_ONBOARDING_EMAILS === 'true',
|
||||||
|
|
||||||
|
enabledLinkedFileTypes: (process.env.ENABLED_LINKED_FILE_TYPES || '').split(
|
||||||
|
','
|
||||||
|
),
|
||||||
|
|
||||||
|
// i18n
|
||||||
|
// ------
|
||||||
|
//
|
||||||
|
i18n: {
|
||||||
|
checkForHTMLInVars: process.env.I18N_CHECK_FOR_HTML_IN_VARS === 'true',
|
||||||
|
escapeHTMLInVars: process.env.I18N_ESCAPE_HTML_IN_VARS === 'true',
|
||||||
|
subdomainLang: {
|
||||||
|
www: { lngCode: 'en', url: siteUrl },
|
||||||
|
},
|
||||||
|
defaultLng: 'en',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Spelling languages
|
||||||
|
// ------------------
|
||||||
|
//
|
||||||
|
// You must have the corresponding aspell package installed to
|
||||||
|
// be able to use a language.
|
||||||
|
languages: [
|
||||||
|
{ code: 'en', name: 'English' },
|
||||||
|
{ code: 'en_US', name: 'English (American)' },
|
||||||
|
{ code: 'en_GB', name: 'English (British)' },
|
||||||
|
{ code: 'en_CA', name: 'English (Canadian)' },
|
||||||
|
{ code: 'af', name: 'Afrikaans' },
|
||||||
|
{ code: 'ar', name: 'Arabic' },
|
||||||
|
{ code: 'gl', name: 'Galician' },
|
||||||
|
{ code: 'eu', name: 'Basque' },
|
||||||
|
{ code: 'br', name: 'Breton' },
|
||||||
|
{ code: 'bg', name: 'Bulgarian' },
|
||||||
|
{ code: 'ca', name: 'Catalan' },
|
||||||
|
{ code: 'hr', name: 'Croatian' },
|
||||||
|
{ code: 'cs', name: 'Czech' },
|
||||||
|
{ code: 'da', name: 'Danish' },
|
||||||
|
{ code: 'nl', name: 'Dutch' },
|
||||||
|
{ code: 'eo', name: 'Esperanto' },
|
||||||
|
{ code: 'et', name: 'Estonian' },
|
||||||
|
{ code: 'fo', name: 'Faroese' },
|
||||||
|
{ code: 'fr', name: 'French' },
|
||||||
|
{ code: 'de', name: 'German' },
|
||||||
|
{ code: 'el', name: 'Greek' },
|
||||||
|
{ code: 'id', name: 'Indonesian' },
|
||||||
|
{ code: 'ga', name: 'Irish' },
|
||||||
|
{ code: 'it', name: 'Italian' },
|
||||||
|
{ code: 'kk', name: 'Kazakh' },
|
||||||
|
{ code: 'ku', name: 'Kurdish' },
|
||||||
|
{ code: 'lv', name: 'Latvian' },
|
||||||
|
{ code: 'lt', name: 'Lithuanian' },
|
||||||
|
{ code: 'nr', name: 'Ndebele' },
|
||||||
|
{ code: 'ns', name: 'Northern Sotho' },
|
||||||
|
{ code: 'no', name: 'Norwegian' },
|
||||||
|
{ code: 'fa', name: 'Persian' },
|
||||||
|
{ code: 'pl', name: 'Polish' },
|
||||||
|
{ code: 'pt_BR', name: 'Portuguese (Brazilian)' },
|
||||||
|
{ code: 'pt_PT', name: 'Portuguese (European)' },
|
||||||
|
{ code: 'pa', name: 'Punjabi' },
|
||||||
|
{ code: 'ro', name: 'Romanian' },
|
||||||
|
{ code: 'ru', name: 'Russian' },
|
||||||
|
{ code: 'sk', name: 'Slovak' },
|
||||||
|
{ code: 'sl', name: 'Slovenian' },
|
||||||
|
{ code: 'st', name: 'Southern Sotho' },
|
||||||
|
{ code: 'es', name: 'Spanish' },
|
||||||
|
{ code: 'sv', name: 'Swedish' },
|
||||||
|
{ code: 'tl', name: 'Tagalog' },
|
||||||
|
{ code: 'ts', name: 'Tsonga' },
|
||||||
|
{ code: 'tn', name: 'Tswana' },
|
||||||
|
{ code: 'hsb', name: 'Upper Sorbian' },
|
||||||
|
{ code: 'cy', name: 'Welsh' },
|
||||||
|
{ code: 'xh', name: 'Xhosa' },
|
||||||
|
],
|
||||||
|
|
||||||
|
translatedLanguages: {
|
||||||
|
cn: '简体中文',
|
||||||
|
cs: 'Čeština',
|
||||||
|
da: 'Dansk',
|
||||||
|
de: 'Deutsch',
|
||||||
|
en: 'English',
|
||||||
|
es: 'Español',
|
||||||
|
fi: 'Suomi',
|
||||||
|
fr: 'Français',
|
||||||
|
it: 'Italiano',
|
||||||
|
ja: '日本語',
|
||||||
|
ko: '한국어',
|
||||||
|
nl: 'Nederlands',
|
||||||
|
no: 'Norsk',
|
||||||
|
pl: 'Polski',
|
||||||
|
pt: 'Português',
|
||||||
|
ro: 'Română',
|
||||||
|
ru: 'Русский',
|
||||||
|
sv: 'Svenska',
|
||||||
|
tr: 'Türkçe',
|
||||||
|
uk: 'Українська',
|
||||||
|
'zh-CN': '简体中文',
|
||||||
|
},
|
||||||
|
|
||||||
|
maxDictionarySize: 1024 * 1024, // 1 MB
|
||||||
|
|
||||||
|
// Password Settings
|
||||||
|
// -----------
|
||||||
|
// These restrict the passwords users can use when registering
|
||||||
|
// opts are from http://antelle.github.io/passfield
|
||||||
|
passwordStrengthOptions: {
|
||||||
|
length: {
|
||||||
|
min: 8,
|
||||||
|
// Bcrypt does not support longer passwords than that.
|
||||||
|
max: 72,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
elevateAccountSecurityAfterFailedLogin:
|
||||||
|
parseInt(process.env.ELEVATED_ACCOUNT_SECURITY_AFTER_FAILED_LOGIN_MS, 10) ||
|
||||||
|
24 * 60 * 60 * 1000,
|
||||||
|
|
||||||
|
deviceHistory: {
|
||||||
|
cookieName: process.env.DEVICE_HISTORY_COOKIE_NAME || 'deviceHistory',
|
||||||
|
entryExpiry:
|
||||||
|
parseInt(process.env.DEVICE_HISTORY_ENTRY_EXPIRY_MS, 10) ||
|
||||||
|
90 * 24 * 60 * 60 * 1000,
|
||||||
|
maxEntries: parseInt(process.env.DEVICE_HISTORY_MAX_ENTRIES, 10) || 10,
|
||||||
|
secret: process.env.DEVICE_HISTORY_SECRET,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Email support
|
||||||
|
// -------------
|
||||||
|
//
|
||||||
|
// Overleaf uses nodemailer (http://www.nodemailer.com/) to send transactional emails.
|
||||||
|
// To see the range of transport and options they support, see http://www.nodemailer.com/docs/transports
|
||||||
|
// email:
|
||||||
|
// fromAddress: ""
|
||||||
|
// replyTo: ""
|
||||||
|
// lifecycle: false
|
||||||
|
// # Example transport and parameter settings for Amazon SES
|
||||||
|
// transport: "SES"
|
||||||
|
// parameters:
|
||||||
|
// AWSAccessKeyID: ""
|
||||||
|
// AWSSecretKey: ""
|
||||||
|
|
||||||
|
// For legacy reasons, we need to populate this object.
|
||||||
|
sentry: {},
|
||||||
|
|
||||||
|
// Production Settings
|
||||||
|
// -------------------
|
||||||
|
debugPugTemplates: process.env.DEBUG_PUG_TEMPLATES === 'true',
|
||||||
|
precompilePugTemplatesAtBootTime: process.env
|
||||||
|
.PRECOMPILE_PUG_TEMPLATES_AT_BOOT_TIME
|
||||||
|
? process.env.PRECOMPILE_PUG_TEMPLATES_AT_BOOT_TIME === 'true'
|
||||||
|
: process.env.NODE_ENV === 'production',
|
||||||
|
|
||||||
|
// Should javascript assets be served minified or not.
|
||||||
|
useMinifiedJs: process.env.MINIFIED_JS === 'true' || false,
|
||||||
|
|
||||||
|
// Should static assets be sent with a header to tell the browser to cache
|
||||||
|
// them.
|
||||||
|
cacheStaticAssets: false,
|
||||||
|
|
||||||
|
// If you are running Overleaf over https, set this to true to send the
|
||||||
|
// cookie with a secure flag (recommended).
|
||||||
|
secureCookie: false,
|
||||||
|
|
||||||
|
// 'SameSite' cookie setting. Can be set to 'lax', 'none' or 'strict'
|
||||||
|
// 'lax' is recommended, as 'strict' will prevent people linking to projects
|
||||||
|
// https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7
|
||||||
|
sameSiteCookie: 'lax',
|
||||||
|
|
||||||
|
// If you are running Overleaf behind a proxy (like Apache, Nginx, etc)
|
||||||
|
// then set this to true to allow it to correctly detect the forwarded IP
|
||||||
|
// address and http/https protocol information.
|
||||||
|
behindProxy: false,
|
||||||
|
|
||||||
|
// Delay before closing the http server upon receiving a SIGTERM process signal.
|
||||||
|
gracefulShutdownDelayInMs:
|
||||||
|
parseInt(process.env.GRACEFUL_SHUTDOWN_DELAY_SECONDS ?? '5', 10) * seconds,
|
||||||
|
|
||||||
|
// Expose the hostname in the `X-Served-By` response header
|
||||||
|
exposeHostname: process.env.EXPOSE_HOSTNAME === 'true',
|
||||||
|
|
||||||
|
// Cookie max age (in milliseconds). Set to false for a browser session.
|
||||||
|
cookieSessionLength: 5 * 24 * 60 * 60 * 1000, // 5 days
|
||||||
|
|
||||||
|
// When true, only allow invites to be sent to email addresses that
|
||||||
|
// already have user accounts
|
||||||
|
restrictInvitesToExistingAccounts: false,
|
||||||
|
|
||||||
|
// Should we allow access to any page without logging in? This includes
|
||||||
|
// public projects, /learn, /templates, about pages, etc.
|
||||||
|
allowPublicAccess: process.env.OVERLEAF_ALLOW_PUBLIC_ACCESS === 'true',
|
||||||
|
|
||||||
|
// editor should be open by default
|
||||||
|
editorIsOpen: process.env.EDITOR_OPEN !== 'false',
|
||||||
|
|
||||||
|
// site should be open by default
|
||||||
|
siteIsOpen: process.env.SITE_OPEN !== 'false',
|
||||||
|
// status file for closing/opening the site at run-time, polled every 5s
|
||||||
|
siteMaintenanceFile: process.env.SITE_MAINTENANCE_FILE,
|
||||||
|
|
||||||
|
// Use a single compile directory for all users in a project
|
||||||
|
// (otherwise each user has their own directory)
|
||||||
|
// disablePerUserCompiles: true
|
||||||
|
|
||||||
|
// Domain the client (pdfjs) should download the compiled pdf from
|
||||||
|
pdfDownloadDomain: process.env.COMPILES_USER_CONTENT_DOMAIN, // "http://clsi-lb:3014"
|
||||||
|
|
||||||
|
// By default turn on feature flag, can be overridden per request.
|
||||||
|
enablePdfCaching: process.env.ENABLE_PDF_CACHING === 'true',
|
||||||
|
|
||||||
|
// Maximum size of text documents in the real-time editing system.
|
||||||
|
max_doc_length: 2 * 1024 * 1024, // 2mb
|
||||||
|
|
||||||
|
primary_email_check_expiration: 1000 * 60 * 60 * 24 * 90, // 90 days
|
||||||
|
|
||||||
|
// Maximum JSON size in HTTP requests
|
||||||
|
// We should be able to process twice the max doc length, to allow for
|
||||||
|
// - the doc content
|
||||||
|
// - text ranges spanning the whole doc
|
||||||
|
//
|
||||||
|
// There's also overhead required for the JSON encoding and the UTF-8 encoding,
|
||||||
|
// theoretically up to 3 times the max doc length. On the other hand, we don't
|
||||||
|
// want to block the event loop with JSON parsing, so we try to find a
|
||||||
|
// practical compromise.
|
||||||
|
max_json_request_size:
|
||||||
|
parseInt(process.env.MAX_JSON_REQUEST_SIZE) || 6 * 1024 * 1024, // 6 MB
|
||||||
|
|
||||||
|
// Internal configs
|
||||||
|
// ----------------
|
||||||
|
path: {
|
||||||
|
// If we ever need to write something to disk (e.g. incoming requests
|
||||||
|
// that need processing but may be too big for memory, then write
|
||||||
|
// them to disk here).
|
||||||
|
dumpFolder: Path.resolve(__dirname, '../data/dumpFolder'),
|
||||||
|
uploadFolder: Path.resolve(__dirname, '../data/uploads'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Automatic Snapshots
|
||||||
|
// -------------------
|
||||||
|
automaticSnapshots: {
|
||||||
|
// How long should we wait after the user last edited to
|
||||||
|
// take a snapshot?
|
||||||
|
waitTimeAfterLastEdit: 5 * minutes,
|
||||||
|
// Even if edits are still taking place, this is maximum
|
||||||
|
// time to wait before taking another snapshot.
|
||||||
|
maxTimeBetweenSnapshots: 30 * minutes,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Smoke test
|
||||||
|
// ----------
|
||||||
|
// Provide log in credentials and a project to be able to run
|
||||||
|
// some basic smoke tests to check the core functionality.
|
||||||
|
//
|
||||||
|
smokeTest: {
|
||||||
|
user: process.env.SMOKE_TEST_USER,
|
||||||
|
userId: process.env.SMOKE_TEST_USER_ID,
|
||||||
|
password: process.env.SMOKE_TEST_PASSWORD,
|
||||||
|
projectId: process.env.SMOKE_TEST_PROJECT_ID,
|
||||||
|
rateLimitSubject: process.env.SMOKE_TEST_RATE_LIMIT_SUBJECT || '127.0.0.1',
|
||||||
|
stepTimeout: parseInt(process.env.SMOKE_TEST_STEP_TIMEOUT || '10000', 10),
|
||||||
|
},
|
||||||
|
|
||||||
|
appName: process.env.APP_NAME || 'Overleaf (Community Edition)',
|
||||||
|
|
||||||
|
adminEmail: process.env.ADMIN_EMAIL || 'placeholder@example.com',
|
||||||
|
adminDomains: process.env.ADMIN_DOMAINS
|
||||||
|
? JSON.parse(process.env.ADMIN_DOMAINS)
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
nav: {
|
||||||
|
title: process.env.APP_NAME || 'Overleaf Community Edition',
|
||||||
|
|
||||||
|
hide_powered_by: process.env.NAV_HIDE_POWERED_BY === 'true',
|
||||||
|
left_footer: [],
|
||||||
|
|
||||||
|
right_footer: [
|
||||||
|
{
|
||||||
|
text: "<i class='fa fa-github-square'></i> Fork on GitHub!",
|
||||||
|
url: 'https://github.com/overleaf/overleaf',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
showSubscriptionLink: false,
|
||||||
|
|
||||||
|
header_extras: [],
|
||||||
|
},
|
||||||
|
// Example:
|
||||||
|
// header_extras: [{text: "Some Page", url: "http://example.com/some/page", class: "subdued"}]
|
||||||
|
|
||||||
|
recaptcha: {
|
||||||
|
endpoint:
|
||||||
|
process.env.RECAPTCHA_ENDPOINT ||
|
||||||
|
'https://www.google.com/recaptcha/api/siteverify',
|
||||||
|
trustedUsers: (process.env.CAPTCHA_TRUSTED_USERS || '')
|
||||||
|
.split(',')
|
||||||
|
.map(x => x.trim())
|
||||||
|
.filter(x => x !== ''),
|
||||||
|
disabled: {
|
||||||
|
invite: true,
|
||||||
|
login: true,
|
||||||
|
passwordReset: true,
|
||||||
|
register: true,
|
||||||
|
addEmail: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
customisation: {},
|
||||||
|
|
||||||
|
redirects: {
|
||||||
|
'/templates/index': '/templates/',
|
||||||
|
},
|
||||||
|
|
||||||
|
reloadModuleViewsOnEachRequest: process.env.NODE_ENV === 'development',
|
||||||
|
|
||||||
|
rateLimit: {
|
||||||
|
autoCompile: {
|
||||||
|
everyone: process.env.RATE_LIMIT_AUTO_COMPILE_EVERYONE || 100,
|
||||||
|
standard: process.env.RATE_LIMIT_AUTO_COMPILE_STANDARD || 25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
analytics: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
compileBodySizeLimitMb: process.env.COMPILE_BODY_SIZE_LIMIT_MB || 7,
|
||||||
|
|
||||||
|
textExtensions: defaultTextExtensions.concat(
|
||||||
|
parseTextExtensions(process.env.ADDITIONAL_TEXT_EXTENSIONS)
|
||||||
|
),
|
||||||
|
|
||||||
|
// case-insensitive file names that is editable (doc) in the editor
|
||||||
|
editableFilenames: ['latexmkrc', '.latexmkrc', 'makefile', 'gnumakefile'],
|
||||||
|
|
||||||
|
fileIgnorePattern:
|
||||||
|
process.env.FILE_IGNORE_PATTERN ||
|
||||||
|
'**/{{__MACOSX,.git,.texpadtmp,.R}{,/**},.!(latexmkrc),*.{dvi,aux,log,toc,out,pdfsync,synctex,synctex(busy),fdb_latexmk,fls,nlo,ind,glo,gls,glg,bbl,blg,doc,docx,gz,swp}}',
|
||||||
|
|
||||||
|
validRootDocExtensions: ['tex', 'Rtex', 'ltx', 'Rnw'],
|
||||||
|
|
||||||
|
emailConfirmationDisabled:
|
||||||
|
process.env.EMAIL_CONFIRMATION_DISABLED === 'true' || false,
|
||||||
|
|
||||||
|
emailAddressLimit: intFromEnv('EMAIL_ADDRESS_LIMIT', 10),
|
||||||
|
|
||||||
|
enabledServices: (process.env.ENABLED_SERVICES || 'web,api')
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim()),
|
||||||
|
|
||||||
|
// module options
|
||||||
|
// ----------
|
||||||
|
modules: {
|
||||||
|
sanitize: {
|
||||||
|
options: {
|
||||||
|
allowedTags: [
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'blockquote',
|
||||||
|
'p',
|
||||||
|
'a',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'nl',
|
||||||
|
'li',
|
||||||
|
'b',
|
||||||
|
'i',
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'strike',
|
||||||
|
'code',
|
||||||
|
'hr',
|
||||||
|
'br',
|
||||||
|
'div',
|
||||||
|
'table',
|
||||||
|
'thead',
|
||||||
|
'col',
|
||||||
|
'caption',
|
||||||
|
'tbody',
|
||||||
|
'tr',
|
||||||
|
'th',
|
||||||
|
'td',
|
||||||
|
'tfoot',
|
||||||
|
'pre',
|
||||||
|
'iframe',
|
||||||
|
'img',
|
||||||
|
'figure',
|
||||||
|
'figcaption',
|
||||||
|
'span',
|
||||||
|
'source',
|
||||||
|
'video',
|
||||||
|
'del',
|
||||||
|
],
|
||||||
|
allowedAttributes: {
|
||||||
|
a: [
|
||||||
|
'href',
|
||||||
|
'name',
|
||||||
|
'target',
|
||||||
|
'class',
|
||||||
|
'event-tracking',
|
||||||
|
'event-tracking-ga',
|
||||||
|
'event-tracking-label',
|
||||||
|
'event-tracking-trigger',
|
||||||
|
],
|
||||||
|
div: ['class', 'id', 'style'],
|
||||||
|
h1: ['class', 'id'],
|
||||||
|
h2: ['class', 'id'],
|
||||||
|
h3: ['class', 'id'],
|
||||||
|
h4: ['class', 'id'],
|
||||||
|
h5: ['class', 'id'],
|
||||||
|
h6: ['class', 'id'],
|
||||||
|
p: ['class'],
|
||||||
|
col: ['width'],
|
||||||
|
figure: ['class', 'id', 'style'],
|
||||||
|
figcaption: ['class', 'id', 'style'],
|
||||||
|
i: ['aria-hidden', 'aria-label', 'class', 'id'],
|
||||||
|
iframe: [
|
||||||
|
'allowfullscreen',
|
||||||
|
'frameborder',
|
||||||
|
'height',
|
||||||
|
'src',
|
||||||
|
'style',
|
||||||
|
'width',
|
||||||
|
],
|
||||||
|
img: ['alt', 'class', 'src', 'style'],
|
||||||
|
source: ['src', 'type'],
|
||||||
|
span: ['class', 'id', 'style'],
|
||||||
|
strong: ['style'],
|
||||||
|
table: ['border', 'class', 'id', 'style'],
|
||||||
|
td: ['colspan', 'rowspan', 'headers', 'style'],
|
||||||
|
th: [
|
||||||
|
'abbr',
|
||||||
|
'headers',
|
||||||
|
'colspan',
|
||||||
|
'rowspan',
|
||||||
|
'scope',
|
||||||
|
'sorted',
|
||||||
|
'style',
|
||||||
|
],
|
||||||
|
tr: ['class'],
|
||||||
|
video: ['alt', 'class', 'controls', 'height', 'width'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
overleafModuleImports: {
|
||||||
|
// modules to import (an empty array for each set of modules)
|
||||||
|
//
|
||||||
|
// Restart webpack after making changes.
|
||||||
|
//
|
||||||
|
createFileModes: [],
|
||||||
|
devToolbar: [],
|
||||||
|
gitBridge: [],
|
||||||
|
publishModal: [],
|
||||||
|
tprFileViewInfo: [],
|
||||||
|
tprFileViewRefreshError: [],
|
||||||
|
tprFileViewRefreshButton: [],
|
||||||
|
tprFileViewNotOriginalImporter: [],
|
||||||
|
newFilePromotions: [],
|
||||||
|
contactUsModal: [],
|
||||||
|
editorToolbarButtons: [],
|
||||||
|
sourceEditorExtensions: [],
|
||||||
|
sourceEditorComponents: [],
|
||||||
|
pdfLogEntryComponents: [],
|
||||||
|
pdfLogEntriesComponents: [],
|
||||||
|
diagnosticActions: [],
|
||||||
|
sourceEditorCompletionSources: [],
|
||||||
|
sourceEditorSymbolPalette: [],
|
||||||
|
sourceEditorToolbarComponents: [],
|
||||||
|
editorPromotions: [],
|
||||||
|
langFeedbackLinkingWidgets: [],
|
||||||
|
labsExperiments: [],
|
||||||
|
integrationLinkingWidgets: [],
|
||||||
|
referenceLinkingWidgets: [],
|
||||||
|
importProjectFromGithubModalWrapper: [],
|
||||||
|
importProjectFromGithubMenu: [],
|
||||||
|
editorLeftMenuSync: [],
|
||||||
|
editorLeftMenuManageTemplate: [],
|
||||||
|
oauth2Server: [],
|
||||||
|
managedGroupSubscriptionEnrollmentNotification: [],
|
||||||
|
userNotifications: [],
|
||||||
|
managedGroupEnrollmentInvite: [],
|
||||||
|
ssoCertificateInfo: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
moduleImportSequence: [
|
||||||
|
'history-v1',
|
||||||
|
'launchpad',
|
||||||
|
'server-ce-scripts',
|
||||||
|
'user-activate',
|
||||||
|
'track-changes',
|
||||||
|
],
|
||||||
|
viewIncludes: {},
|
||||||
|
|
||||||
|
csp: {
|
||||||
|
enabled: process.env.CSP_ENABLED === 'true',
|
||||||
|
reportOnly: process.env.CSP_REPORT_ONLY === 'true',
|
||||||
|
reportPercentage: parseFloat(process.env.CSP_REPORT_PERCENTAGE) || 0,
|
||||||
|
reportUri: process.env.CSP_REPORT_URI,
|
||||||
|
exclude: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
unsupportedBrowsers: {
|
||||||
|
ie: '<=11',
|
||||||
|
safari: '<=13',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ID of the IEEE brand in the rails app
|
||||||
|
ieeeBrandId: intFromEnv('IEEE_BRAND_ID', 15),
|
||||||
|
|
||||||
|
managedUsers: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.mergeWith = function (overrides) {
|
||||||
|
return merge(overrides, module.exports)
|
||||||
|
}
|
|
@ -0,0 +1,308 @@
|
||||||
|
const ChatApiHandler = require('../../../../app/src/Features/Chat/ChatApiHandler')
|
||||||
|
const ChatManager = require('../../../../app/src/Features/Chat/ChatManager')
|
||||||
|
const EditorRealTimeController = require('../../../../app/src/Features/Editor/EditorRealTimeController')
|
||||||
|
const SessionManager = require('../../../../app/src/Features/Authentication/SessionManager')
|
||||||
|
const UserInfoManager = require('../../../../app/src/Features/User/UserInfoManager')
|
||||||
|
const DocstoreManager = require('../../../../app/src/Features/Docstore/DocstoreManager')
|
||||||
|
const DocumentUpdaterHandler = require('../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler')
|
||||||
|
const CollaboratorsGetter = require('../../../../app/src/Features/Collaborators/CollaboratorsGetter')
|
||||||
|
const { Project } = require('../../../../app/src/models/Project')
|
||||||
|
const pLimit = require('p-limit')
|
||||||
|
|
||||||
|
async function _updateTCState (projectId, state, callback) {
|
||||||
|
await Project.updateOne({_id: projectId}, {track_changes: state}).exec()
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
function _transformId(doc) {
|
||||||
|
if (doc._id) {
|
||||||
|
doc.id = doc._id;
|
||||||
|
delete doc._id;
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TrackChangesController = {
|
||||||
|
trackChanges(req, res, next) {
|
||||||
|
const { project_id } = req.params
|
||||||
|
let state = req.body.on || req.body.on_for
|
||||||
|
if ( req.body.on_for_guests && !req.body.on ) state.__guests__ = true
|
||||||
|
|
||||||
|
return _updateTCState(project_id, state,
|
||||||
|
function (err, message) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
EditorRealTimeController.emitToRoom(
|
||||||
|
project_id,
|
||||||
|
'toggle-track-changes',
|
||||||
|
state
|
||||||
|
)
|
||||||
|
return res.sendStatus(204)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
acceptChanges(req, res, next) {
|
||||||
|
const { project_id, doc_id } = req.params
|
||||||
|
const change_ids = req.body.change_ids
|
||||||
|
return DocumentUpdaterHandler.acceptChanges(
|
||||||
|
project_id,
|
||||||
|
doc_id,
|
||||||
|
change_ids,
|
||||||
|
function (err, message) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
EditorRealTimeController.emitToRoom(
|
||||||
|
project_id,
|
||||||
|
'accept-changes',
|
||||||
|
doc_id,
|
||||||
|
change_ids,
|
||||||
|
)
|
||||||
|
return res.sendStatus(204)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
async getAllRanges(req, res, next) {
|
||||||
|
const { project_id } = req.params
|
||||||
|
// FIXME: ranges are from mongodb, probably already outdated
|
||||||
|
const ranges = await DocstoreManager.promises.getAllRanges(project_id)
|
||||||
|
// frontend expects 'id', not '_id'
|
||||||
|
return res.json(ranges.map(_transformId))
|
||||||
|
},
|
||||||
|
async getChangesUsers(req, res, next) {
|
||||||
|
const { project_id } = req.params
|
||||||
|
const memberIds = await CollaboratorsGetter.promises.getMemberIds(project_id)
|
||||||
|
// FIXME: Does not work properly if the user is no longer a member of the project
|
||||||
|
// memberIds from DocstoreManager.getAllRanges(project_id) is not a remedy
|
||||||
|
// because ranges are not updated in real-time
|
||||||
|
const limit = pLimit(3)
|
||||||
|
const users = await Promise.all(
|
||||||
|
memberIds.map(memberId =>
|
||||||
|
limit(async () => {
|
||||||
|
const user = await UserInfoManager.promises.getPersonalInfo(memberId)
|
||||||
|
return user
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
users.push({_id: null}) // An anonymous user won't cause any harm
|
||||||
|
// frontend expects 'id', not '_id'
|
||||||
|
return res.json(users.map(_transformId))
|
||||||
|
},
|
||||||
|
getThreads(req, res, next) {
|
||||||
|
const { project_id } = req.params
|
||||||
|
return ChatApiHandler.getThreads(
|
||||||
|
project_id,
|
||||||
|
function (err, messages) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
return ChatManager.injectUserInfoIntoThreads(
|
||||||
|
messages,
|
||||||
|
function (err) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
return res.json(messages)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
sendComment(req, res, next) {
|
||||||
|
const { project_id, thread_id } = req.params
|
||||||
|
const { content } = req.body
|
||||||
|
const user_id = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
if (user_id == null) {
|
||||||
|
const err = new Error('no logged-in user')
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
return ChatApiHandler.sendComment(
|
||||||
|
project_id,
|
||||||
|
thread_id,
|
||||||
|
user_id,
|
||||||
|
content,
|
||||||
|
function (err, message) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
return UserInfoManager.getPersonalInfo(
|
||||||
|
user_id,
|
||||||
|
function (err, user) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
message.user = user
|
||||||
|
EditorRealTimeController.emitToRoom(
|
||||||
|
project_id,
|
||||||
|
'new-comment',
|
||||||
|
thread_id, message
|
||||||
|
)
|
||||||
|
return res.sendStatus(204)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
editMessage(req, res, next) {
|
||||||
|
const { project_id, thread_id, message_id } = req.params
|
||||||
|
const { content } = req.body
|
||||||
|
const user_id = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
if (user_id == null) {
|
||||||
|
const err = new Error('no logged-in user')
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
return ChatApiHandler.editMessage(
|
||||||
|
project_id,
|
||||||
|
thread_id,
|
||||||
|
message_id,
|
||||||
|
user_id,
|
||||||
|
content,
|
||||||
|
function (err, message) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
EditorRealTimeController.emitToRoom(
|
||||||
|
project_id,
|
||||||
|
'edit-message',
|
||||||
|
thread_id,
|
||||||
|
message_id,
|
||||||
|
content
|
||||||
|
)
|
||||||
|
return res.sendStatus(204)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
deleteMessage(req, res, next) {
|
||||||
|
const { project_id, thread_id, message_id } = req.params
|
||||||
|
return ChatApiHandler.deleteMessage(
|
||||||
|
project_id,
|
||||||
|
thread_id,
|
||||||
|
message_id,
|
||||||
|
function (err, message) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
EditorRealTimeController.emitToRoom(
|
||||||
|
project_id,
|
||||||
|
'delete-message',
|
||||||
|
thread_id,
|
||||||
|
message_id
|
||||||
|
)
|
||||||
|
return res.sendStatus(204)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
resolveThread(req, res, next) {
|
||||||
|
const { project_id, doc_id, thread_id } = req.params
|
||||||
|
const user_id = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
if (user_id == null) {
|
||||||
|
const err = new Error('no logged-in user')
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
DocumentUpdaterHandler.resolveThread(
|
||||||
|
project_id,
|
||||||
|
doc_id,
|
||||||
|
thread_id,
|
||||||
|
user_id,
|
||||||
|
function (err, message) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return ChatApiHandler.resolveThread(
|
||||||
|
project_id,
|
||||||
|
thread_id,
|
||||||
|
user_id,
|
||||||
|
function (err, message) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
return UserInfoManager.getPersonalInfo(
|
||||||
|
user_id,
|
||||||
|
function (err, user) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
EditorRealTimeController.emitToRoom(
|
||||||
|
project_id,
|
||||||
|
'resolve-thread',
|
||||||
|
thread_id,
|
||||||
|
user
|
||||||
|
)
|
||||||
|
return res.sendStatus(204)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
reopenThread(req, res, next) {
|
||||||
|
const { project_id, doc_id, thread_id } = req.params
|
||||||
|
const user_id = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
if (user_id == null) {
|
||||||
|
const err = new Error('no logged-in user')
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
DocumentUpdaterHandler.reopenThread(
|
||||||
|
project_id,
|
||||||
|
doc_id,
|
||||||
|
thread_id,
|
||||||
|
user_id,
|
||||||
|
function (err, message) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return ChatApiHandler.reopenThread(
|
||||||
|
project_id,
|
||||||
|
thread_id,
|
||||||
|
function (err, message) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
EditorRealTimeController.emitToRoom(
|
||||||
|
project_id,
|
||||||
|
'reopen-thread',
|
||||||
|
thread_id
|
||||||
|
)
|
||||||
|
return res.sendStatus(204)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
deleteThread(req, res, next) {
|
||||||
|
const { project_id, doc_id, thread_id } = req.params
|
||||||
|
const user_id = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
if (user_id == null) {
|
||||||
|
const err = new Error('no logged-in user')
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
return DocumentUpdaterHandler.deleteThread(
|
||||||
|
project_id,
|
||||||
|
doc_id,
|
||||||
|
thread_id,
|
||||||
|
user_id,
|
||||||
|
function (err, message) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
ChatApiHandler.deleteThread(
|
||||||
|
project_id,
|
||||||
|
thread_id,
|
||||||
|
function (err, message) {
|
||||||
|
if (err != null) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
EditorRealTimeController.emitToRoom(
|
||||||
|
project_id,
|
||||||
|
'delete-thread',
|
||||||
|
thread_id
|
||||||
|
)
|
||||||
|
return res.sendStatus(204)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
module.exports = TrackChangesController
|
|
@ -0,0 +1,72 @@
|
||||||
|
const logger = require('@overleaf/logger')
|
||||||
|
const AuthorizationMiddleware = require('../../../../app/src/Features/Authorization/AuthorizationMiddleware')
|
||||||
|
const TrackChangesController = require('./TrackChangesController')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apply(webRouter) {
|
||||||
|
logger.debug({}, 'Init track-changes router')
|
||||||
|
|
||||||
|
webRouter.post('/project/:project_id/track_changes',
|
||||||
|
AuthorizationMiddleware.blockRestrictedUserFromProject,
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
|
TrackChangesController.trackChanges
|
||||||
|
)
|
||||||
|
webRouter.post('/project/:project_id/doc/:doc_id/changes/accept',
|
||||||
|
AuthorizationMiddleware.blockRestrictedUserFromProject,
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
|
TrackChangesController.acceptChanges
|
||||||
|
)
|
||||||
|
webRouter.get('/project/:project_id/ranges',
|
||||||
|
AuthorizationMiddleware.blockRestrictedUserFromProject,
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
|
TrackChangesController.getAllRanges
|
||||||
|
)
|
||||||
|
webRouter.get('/project/:project_id/changes/users',
|
||||||
|
AuthorizationMiddleware.blockRestrictedUserFromProject,
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
|
TrackChangesController.getChangesUsers
|
||||||
|
)
|
||||||
|
webRouter.get(
|
||||||
|
'/project/:project_id/threads',
|
||||||
|
AuthorizationMiddleware.blockRestrictedUserFromProject,
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
|
TrackChangesController.getThreads
|
||||||
|
)
|
||||||
|
webRouter.post(
|
||||||
|
'/project/:project_id/thread/:thread_id/messages',
|
||||||
|
AuthorizationMiddleware.blockRestrictedUserFromProject,
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
|
TrackChangesController.sendComment
|
||||||
|
)
|
||||||
|
webRouter.post(
|
||||||
|
'/project/:project_id/thread/:thread_id/messages/:message_id/edit',
|
||||||
|
AuthorizationMiddleware.blockRestrictedUserFromProject,
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
|
TrackChangesController.editMessage
|
||||||
|
)
|
||||||
|
webRouter.delete(
|
||||||
|
'/project/:project_id/thread/:thread_id/messages/:message_id',
|
||||||
|
AuthorizationMiddleware.blockRestrictedUserFromProject,
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
|
TrackChangesController.deleteMessage
|
||||||
|
)
|
||||||
|
webRouter.post(
|
||||||
|
'/project/:project_id/doc/:doc_id/thread/:thread_id/resolve',
|
||||||
|
AuthorizationMiddleware.blockRestrictedUserFromProject,
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
|
TrackChangesController.resolveThread
|
||||||
|
)
|
||||||
|
webRouter.post(
|
||||||
|
'/project/:project_id/doc/:doc_id/thread/:thread_id/reopen',
|
||||||
|
AuthorizationMiddleware.blockRestrictedUserFromProject,
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
|
TrackChangesController.reopenThread
|
||||||
|
)
|
||||||
|
webRouter.delete(
|
||||||
|
'/project/:project_id/doc/:doc_id/thread/:thread_id',
|
||||||
|
AuthorizationMiddleware.blockRestrictedUserFromProject,
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
|
TrackChangesController.deleteThread
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
const TrackChangesRouter = require('./app/src/TrackChangesRouter')
|
||||||
|
module.exports = { router : TrackChangesRouter }
|
Loading…
Reference in a new issue