From 24fb73810d7e8852e5ab4d28d533177e11606b54 Mon Sep 17 00:00:00 2001
From: yu-i-i
Date: Tue, 3 Dec 2024 16:40:27 +0100
Subject: [PATCH 01/11] Redirect non-existing links to Overleaf page
---
services/web/app/src/router.mjs | 4 ++++
.../hotkeys-modal/components/hotkeys-modal-bottom-text.jsx | 2 +-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs
index 2ec36f55ee..8e39d2bc0b 100644
--- a/services/web/app/src/router.mjs
+++ b/services/web/app/src/router.mjs
@@ -1400,6 +1400,10 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
TokenAccessController.grantTokenAccessReadOnly
)
+ webRouter.get(['/learn*', '/templates*', '/blog*', '/latex*', '/for/*', '/contact*'], (req, res) => {
+ res.redirect(301, `https://www.overleaf.com${req.originalUrl}`)
+ })
+
webRouter.get('/unsupported-browser', renderUnsupportedBrowserPage)
webRouter.get('*', ErrorController.notFound)
diff --git a/services/web/frontend/js/features/hotkeys-modal/components/hotkeys-modal-bottom-text.jsx b/services/web/frontend/js/features/hotkeys-modal/components/hotkeys-modal-bottom-text.jsx
index db6507e823..0a840931bc 100644
--- a/services/web/frontend/js/features/hotkeys-modal/components/hotkeys-modal-bottom-text.jsx
+++ b/services/web/frontend/js/features/hotkeys-modal/components/hotkeys-modal-bottom-text.jsx
@@ -10,7 +10,7 @@ export default function HotkeysModalBottomText() {
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
eventTracking.sendMB('left-menu-hotkeys-template')}
- href="/articles/overleaf-keyboard-shortcuts/qykqfvmxdnjf"
+ href="https://www.overleaf.com/latex/templates/overleaf-keyboard-shortcuts/pphdnzrwmttk"
target="_blank"
/>,
]}
From a7ddd99339fd0a94aa4ce2610e7af3a88087254a Mon Sep 17 00:00:00 2001
From: yu-i-i
Date: Tue, 3 Dec 2024 01:07:49 +0100
Subject: [PATCH 02/11] Enable track changes and comments feature
---
.../Features/Project/ProjectEditorHandler.js | 2 +-
services/web/config/settings.defaults.js | 1 +
.../hooks/use-review-panel-state.ts | 19 +-
.../hooks/use-codemirror-scope.ts | 2 +-
.../app/src/TrackChangesController.js | 194 ++++++++++++++++++
.../app/src/TrackChangesRouter.js | 72 +++++++
services/web/modules/track-changes/index.js | 2 +
7 files changed, 283 insertions(+), 9 deletions(-)
create mode 100644 services/web/modules/track-changes/app/src/TrackChangesController.js
create mode 100644 services/web/modules/track-changes/app/src/TrackChangesRouter.js
create mode 100644 services/web/modules/track-changes/index.js
diff --git a/services/web/app/src/Features/Project/ProjectEditorHandler.js b/services/web/app/src/Features/Project/ProjectEditorHandler.js
index 84c0a5831a..c0d6fa4f4f 100644
--- a/services/web/app/src/Features/Project/ProjectEditorHandler.js
+++ b/services/web/app/src/Features/Project/ProjectEditorHandler.js
@@ -9,7 +9,7 @@ function mergeDeletedDocs(a, b) {
}
module.exports = ProjectEditorHandler = {
- trackChangesAvailable: false,
+ trackChangesAvailable: true,
buildProjectModelView(project, members, invites, deletedDocsFromDocstore) {
let owner, ownerFeatures
diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js
index ddd0d4cd23..d606310542 100644
--- a/services/web/config/settings.defaults.js
+++ b/services/web/config/settings.defaults.js
@@ -990,6 +990,7 @@ module.exports = {
'launchpad',
'server-ce-scripts',
'user-activate',
+ 'track-changes',
],
viewIncludes: {},
diff --git a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts b/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts
index 082f466824..c2d6a31998 100644
--- a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts
+++ b/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts
@@ -83,6 +83,7 @@ const formatUser = (user: any): any => {
if (id == null) {
return {
+ id: 'anonymous-user',
email: null,
name: 'Anonymous',
isSelf: false,
@@ -280,16 +281,20 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
const tempUsers = {} as ReviewPanel.Value<'users'>
// Always include ourself, since if we submit an op, we might need to display info
// about it locally before it has been flushed through the server
- if (user?.id) {
- tempUsers[user.id] = formatUser(user)
+ if (user) {
+ if (user.id) {
+ tempUsers[user.id] = formatUser(user)
+ } else {
+ tempUsers['anonymous-user'] = formatUser(user)
+ }
}
-
for (const user of usersResponse) {
if (user.id) {
tempUsers[user.id] = formatUser(user)
+ } else {
+ tempUsers['anonymous-user'] = formatUser(user)
}
}
-
setUsers(tempUsers)
})
.catch(error => {
@@ -526,9 +531,9 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
}
}, [currentDocument, regenerateTrackChangesId, resolvedThreadIds])
- const currentUserType = useCallback((): 'member' | 'guest' | 'anonymous' => {
+ const currentUserType = useCallback((): 'member' | 'guest' | 'anonymous-user' => {
if (!user) {
- return 'anonymous'
+ return 'anonymous-user'
}
if (project.owner._id === user.id) {
return 'member'
@@ -581,7 +586,7 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
const setGuestsTCState = useCallback(
(newValue: boolean) => {
setTrackChangesOnForGuests(newValue)
- if (currentUserType() === 'guest' || currentUserType() === 'anonymous') {
+ if (currentUserType() === 'guest' || currentUserType() === 'anonymous-user') {
setWantTrackChanges(newValue)
}
},
diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
index eef644a813..f2a79a88cb 100644
--- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
+++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
@@ -210,7 +210,7 @@ function useCodeMirrorScope(view: EditorView) {
if (currentDoc) {
if (trackChanges) {
- currentDoc.track_changes_as = userId || 'anonymous'
+ currentDoc.track_changes_as = userId || 'anonymous-user'
} else {
currentDoc.track_changes_as = null
}
diff --git a/services/web/modules/track-changes/app/src/TrackChangesController.js b/services/web/modules/track-changes/app/src/TrackChangesController.js
new file mode 100644
index 0000000000..12cbb57da4
--- /dev/null
+++ b/services/web/modules/track-changes/app/src/TrackChangesController.js
@@ -0,0 +1,194 @@
+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')
+
+function _transformId(doc) {
+ if (doc._id) {
+ doc.id = doc._id;
+ delete doc._id;
+ }
+ return doc;
+}
+
+const TrackChangesController = {
+ async trackChanges(req, res, next) {
+ try {
+ 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
+ await Project.updateOne({_id: project_id}, {track_changes: state}).exec() //do not wait?
+ EditorRealTimeController.emitToRoom(project_id, 'toggle-track-changes', state)
+ res.sendStatus(204)
+ } catch (err) {
+ next(err)
+ }
+ },
+ async acceptChanges(req, res, next) {
+ try {
+ const { project_id, doc_id } = req.params
+ const change_ids = req.body.change_ids
+ EditorRealTimeController.emitToRoom(project_id, 'accept-changes', doc_id, change_ids)
+ await DocumentUpdaterHandler.promises.acceptChanges(project_id, doc_id, change_ids)
+ res.sendStatus(204)
+ } catch (err) {
+ next(err)
+ }
+ },
+ async getAllRanges(req, res, next) {
+ try {
+ const { project_id } = req.params
+ // Flushing the project to mongo is not ideal. Is it possible to fetch the ranges from redis?
+ await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id)
+ const ranges = await DocstoreManager.promises.getAllRanges(project_id)
+ res.json(ranges.map(_transformId))
+ } catch (err) {
+ next(err)
+ }
+ },
+ async getChangesUsers(req, res, next) {
+ try {
+ const { project_id } = req.params
+ const memberIds = await CollaboratorsGetter.promises.getMemberIds(project_id)
+ // FIXME: Fails to display names in changes made by former project collaborators.
+ // See the alternative below. However, it requires flushing the project to mongo, which is not ideal.
+ 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
+ res.json(users.map(_transformId))
+ } catch (err) {
+ next(err)
+ }
+ },
+/*
+ async getChangesUsers(req, res, next) {
+ try {
+ const { project_id } = req.params
+ await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id)
+ const memberIds = new Set()
+ const ranges = await DocstoreManager.promises.getAllRanges(project_id)
+ ranges.forEach(range => {
+ ;[...range.ranges?.changes || [], ...range.ranges?.comments || []].forEach(item => {
+ memberIds.add(item.metadata?.user_id)
+ })
+ })
+ const limit = pLimit(3)
+ const users = await Promise.all(
+ [...memberIds].map(memberId =>
+ limit(async () => {
+ if( memberId !== "anonymous-user") {
+ return await UserInfoManager.promises.getPersonalInfo(memberId)
+ } else {
+ return {_id: null}
+ }
+ })
+ )
+ )
+ res.json(users.map(_transformId))
+ } catch (err) {
+ next(err)
+ }
+ },
+*/
+ async getThreads(req, res, next) {
+ try {
+ const { project_id } = req.params
+ const messages = await ChatApiHandler.promises.getThreads(project_id)
+ await ChatManager.promises.injectUserInfoIntoThreads(messages)
+ res.json(messages)
+ } catch (err) {
+ next(err)
+ }
+ },
+ async sendComment(req, res, next) {
+ try {
+ const { project_id, thread_id } = req.params
+ const { content } = req.body
+ const user_id = SessionManager.getLoggedInUserId(req.session)
+ if (!user_id) throw new Error('no logged-in user')
+ const message = await ChatApiHandler.promises.sendComment(project_id, thread_id, user_id, content)
+ message.user = await UserInfoManager.promises.getPersonalInfo(user_id)
+ EditorRealTimeController.emitToRoom(project_id, 'new-comment', thread_id, message)
+ res.sendStatus(204)
+ } catch (err) {
+ next(err);
+ }
+ },
+ async editMessage(req, res, next) {
+ try {
+ const { project_id, thread_id, message_id } = req.params
+ const { content } = req.body
+ const user_id = SessionManager.getLoggedInUserId(req.session)
+ if (!user_id) throw new Error('no logged-in user')
+ await ChatApiHandler.promises.editMessage(project_id, thread_id, message_id, user_id, content)
+ EditorRealTimeController.emitToRoom(project_id, 'edit-message', thread_id, message_id, content)
+ res.sendStatus(204)
+ } catch (err) {
+ next(err)
+ }
+ },
+ async deleteMessage(req, res, next) {
+ try {
+ const { project_id, thread_id, message_id } = req.params
+ await ChatApiHandler.promises.deleteMessage(project_id, thread_id, message_id)
+ EditorRealTimeController.emitToRoom(project_id, 'delete-message', thread_id, message_id)
+ res.sendStatus(204)
+ } catch (err) {
+ next(err)
+ }
+ },
+ async resolveThread(req, res, next) {
+ try {
+ const { project_id, doc_id, thread_id } = req.params
+ const user_id = SessionManager.getLoggedInUserId(req.session)
+ if (!user_id) throw new Error('no logged-in user')
+ const user = await UserInfoManager.promises.getPersonalInfo(user_id)
+ await ChatApiHandler.promises.resolveThread(project_id, thread_id, user_id)
+ EditorRealTimeController.emitToRoom(project_id, 'resolve-thread', thread_id, user)
+ await DocumentUpdaterHandler.promises.resolveThread(project_id, doc_id, thread_id, user_id)
+ res.sendStatus(204);
+ } catch (err) {
+ next(err);
+ }
+ },
+ async reopenThread(req, res, next) {
+ try {
+ const { project_id, doc_id, thread_id } = req.params
+ const user_id = SessionManager.getLoggedInUserId(req.session)
+ if (!user_id) throw new Error('no logged-in user')
+ await ChatApiHandler.promises.reopenThread(project_id, thread_id)
+ EditorRealTimeController.emitToRoom(project_id, 'reopen-thread', thread_id)
+ await DocumentUpdaterHandler.promises.reopenThread(project_id, doc_id, thread_id, user_id)
+ res.sendStatus(204)
+ } catch (err) {
+ next(err)
+ }
+ },
+ async deleteThread(req, res, next) {
+ try {
+ const { project_id, doc_id, thread_id } = req.params
+ const user_id = SessionManager.getLoggedInUserId(req.session)
+ if (!user_id) throw new Error('no logged-in user')
+ await ChatApiHandler.promises.deleteThread(project_id, thread_id)
+ EditorRealTimeController.emitToRoom(project_id, 'delete-thread', thread_id)
+ await DocumentUpdaterHandler.promises.deleteThread(project_id, doc_id, thread_id, user_id)
+ res.sendStatus(204)
+ } catch (err) {
+ next(err)
+ }
+ },
+}
+module.exports = TrackChangesController
diff --git a/services/web/modules/track-changes/app/src/TrackChangesRouter.js b/services/web/modules/track-changes/app/src/TrackChangesRouter.js
new file mode 100644
index 0000000000..3791e251a1
--- /dev/null
+++ b/services/web/modules/track-changes/app/src/TrackChangesRouter.js
@@ -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
+ )
+ },
+}
diff --git a/services/web/modules/track-changes/index.js b/services/web/modules/track-changes/index.js
new file mode 100644
index 0000000000..aa9e6a73da
--- /dev/null
+++ b/services/web/modules/track-changes/index.js
@@ -0,0 +1,2 @@
+const TrackChangesRouter = require('./app/src/TrackChangesRouter')
+module.exports = { router : TrackChangesRouter }
From b748a664a814a507ae1e40dd16af4bd71e9b7dab Mon Sep 17 00:00:00 2001
From: yu-i-i
Date: Tue, 3 Dec 2024 01:14:41 +0100
Subject: [PATCH 03/11] Enable autocomplete of reference keys feature
---
develop/dev.env | 1 +
develop/docker-compose.dev.yml | 11 +++++
develop/docker-compose.yml | 8 ++++
server-ce/config/env.sh | 1 +
server-ce/runit/references-overleaf/run | 12 ++++++
services/references/README.md | 10 +++++
services/references/app.js | 40 ++++++++++++++++++
.../app/js/ReferencesAPIController.js | 42 +++++++++++++++++++
.../references/config/settings.defaults.cjs | 9 ++++
services/references/package.json | 26 ++++++++++++
services/web/config/settings.defaults.js | 3 ++
11 files changed, 163 insertions(+)
create mode 100755 server-ce/runit/references-overleaf/run
create mode 100644 services/references/README.md
create mode 100644 services/references/app.js
create mode 100644 services/references/app/js/ReferencesAPIController.js
create mode 100644 services/references/config/settings.defaults.cjs
create mode 100644 services/references/package.json
diff --git a/develop/dev.env b/develop/dev.env
index aae91497db..2ccfef7052 100644
--- a/develop/dev.env
+++ b/develop/dev.env
@@ -13,6 +13,7 @@ NOTIFICATIONS_HOST=notifications
PROJECT_HISTORY_HOST=project-history
REALTIME_HOST=real-time
REDIS_HOST=redis
+REFERENCES_HOST=references
SESSION_SECRET=foo
WEBPACK_HOST=webpack
WEB_API_PASSWORD=overleaf
diff --git a/develop/docker-compose.dev.yml b/develop/docker-compose.dev.yml
index 4432a24162..cc1e094a6c 100644
--- a/develop/docker-compose.dev.yml
+++ b/develop/docker-compose.dev.yml
@@ -112,6 +112,17 @@ services:
- ../services/real-time/app.js:/overleaf/services/real-time/app.js
- ../services/real-time/config:/overleaf/services/real-time/config
+ references:
+ command: ["node", "--watch", "app.js"]
+ environment:
+ - NODE_OPTIONS=--inspect=0.0.0.0:9229
+ ports:
+ - "127.0.0.1:9236:9229"
+ volumes:
+ - ../services/references/app:/overleaf/services/references/app
+ - ../services/references/config:/overleaf/services/references/config
+ - ../services/references/app.js:/overleaf/services/references/app.js
+
web:
command: ["node", "--watch", "app.js", "--watch-locales"]
environment:
diff --git a/develop/docker-compose.yml b/develop/docker-compose.yml
index adaf05348b..d4191ad578 100644
--- a/develop/docker-compose.yml
+++ b/develop/docker-compose.yml
@@ -123,6 +123,13 @@ services:
volumes:
- redis-data:/data
+ references:
+ build:
+ context: ..
+ dockerfile: services/references/Dockerfile
+ env_file:
+ - dev.env
+
web:
build:
context: ..
@@ -153,6 +160,7 @@ services:
- notifications
- project-history
- real-time
+ - references
webpack:
build:
diff --git a/server-ce/config/env.sh b/server-ce/config/env.sh
index b12ca242d3..81cebe4caa 100644
--- a/server-ce/config/env.sh
+++ b/server-ce/config/env.sh
@@ -9,5 +9,6 @@ export HISTORY_V1_HOST=127.0.0.1
export NOTIFICATIONS_HOST=127.0.0.1
export PROJECT_HISTORY_HOST=127.0.0.1
export REALTIME_HOST=127.0.0.1
+export REFERENCES_HOST=127.0.0.1
export WEB_HOST=127.0.0.1
export WEB_API_HOST=127.0.0.1
diff --git a/server-ce/runit/references-overleaf/run b/server-ce/runit/references-overleaf/run
new file mode 100755
index 0000000000..875023df9f
--- /dev/null
+++ b/server-ce/runit/references-overleaf/run
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+NODE_PARAMS=""
+if [ "$DEBUG_NODE" == "true" ]; then
+ echo "running debug - references"
+ NODE_PARAMS="--inspect=0.0.0.0:30560"
+fi
+
+source /etc/overleaf/env.sh
+export LISTEN_ADDRESS=127.0.0.1
+
+exec /sbin/setuser www-data /usr/bin/node $NODE_PARAMS /overleaf/services/references/app.js >> /var/log/overleaf/references.log 2>&1
diff --git a/services/references/README.md b/services/references/README.md
new file mode 100644
index 0000000000..41844d259a
--- /dev/null
+++ b/services/references/README.md
@@ -0,0 +1,10 @@
+overleaf/references
+===============
+
+An API for providing citation-keys from user bib-files
+
+License
+=======
+The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3.
+
+Based on https://github.com/overleaf/overleaf/commit/9964aebc794f9fd7ce1373ab3484f6b33b061af3
diff --git a/services/references/app.js b/services/references/app.js
new file mode 100644
index 0000000000..a7da8720ed
--- /dev/null
+++ b/services/references/app.js
@@ -0,0 +1,40 @@
+import '@overleaf/metrics/initialize.js'
+
+import express from 'express'
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import metrics from '@overleaf/metrics'
+import ReferencesAPIController from './app/js/ReferencesAPIController.js'
+import bodyParser from 'body-parser'
+
+const app = express()
+metrics.injectMetricsRoute(app)
+
+app.use(bodyParser.json({ limit: '2mb' }))
+app.use(metrics.http.monitor(logger))
+
+app.post('/project/:project_id/index', ReferencesAPIController.index)
+app.get('/status', (req, res) => res.send({ status: 'references api is up' }))
+
+const settings =
+ Settings.internal && Settings.internal.references
+ ? Settings.internal.references
+ : undefined
+const host = settings && settings.host ? settings.host : 'localhost'
+const port = settings && settings.port ? settings.port : 3056
+
+logger.debug('Listening at', { host, port })
+
+const server = app.listen(port, host, function (error) {
+ if (error) {
+ throw error
+ }
+ logger.info({ host, port }, 'references HTTP server starting up')
+})
+
+process.on('SIGTERM', () => {
+ server.close(() => {
+ logger.info({ host, port }, 'references HTTP server closed')
+ metrics.close()
+ })
+})
diff --git a/services/references/app/js/ReferencesAPIController.js b/services/references/app/js/ReferencesAPIController.js
new file mode 100644
index 0000000000..68565c1b52
--- /dev/null
+++ b/services/references/app/js/ReferencesAPIController.js
@@ -0,0 +1,42 @@
+import logger from '@overleaf/logger'
+import BibtexParser from '../../../web/app/src/util/bib2json.js'
+
+export default {
+ async index(req, res) {
+ const { docUrls, fullIndex } = req.body
+ try {
+ const responses = await Promise.all(
+ docUrls.map(async (docUrl) => {
+ try {
+ const response = await fetch(docUrl)
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`)
+ }
+ return response.text()
+ } catch (error) {
+ logger.error({ error }, "Failed to fetch document from URL: " + docUrl)
+ return null
+ }
+ })
+ )
+ const keys = []
+ for (const body of responses) {
+ if (!body) continue
+
+ try {
+ const parsedEntries = BibtexParser(body).entries
+ const ks = parsedEntries
+ .filter(entry => entry.EntryKey)
+ .map(entry => entry.EntryKey)
+ keys.push(...ks)
+ } catch (error) {
+ logger.error({ error }, "bib file skipped.")
+ }
+ }
+ res.status(200).json({ keys })
+ } catch (error) {
+ logger.error({ error }, "Unexpected error during indexing process.")
+ res.status(500).json({ error: "Failed to process bib files." })
+ }
+ }
+}
diff --git a/services/references/config/settings.defaults.cjs b/services/references/config/settings.defaults.cjs
new file mode 100644
index 0000000000..2551f99f09
--- /dev/null
+++ b/services/references/config/settings.defaults.cjs
@@ -0,0 +1,9 @@
+module.exports = {
+ internal: {
+ references: {
+ port: 3056,
+ host: process.env.REFERENCES_HOST || '127.0.0.1',
+ },
+ },
+}
+
diff --git a/services/references/package.json b/services/references/package.json
new file mode 100644
index 0000000000..11be2ab68c
--- /dev/null
+++ b/services/references/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@overleaf/references",
+ "description": "An API for providing citation-keys",
+ "private": true,
+ "type": "module",
+ "main": "app.js",
+ "scripts": {
+ "start": "node app.js"
+ },
+ "version": "0.1.0",
+ "dependencies": {
+ "@overleaf/settings": "*",
+ "@overleaf/logger": "*",
+ "@overleaf/metrics": "*",
+ "async": "^3.2.5",
+ "express": "^4.21.0"
+ },
+ "devDependencies": {
+ "chai": "^4.3.6",
+ "chai-as-promised": "^7.1.1",
+ "esmock": "^2.6.9",
+ "mocha": "^10.2.0",
+ "sinon": "^9.2.4",
+ "typescript": "^5.0.4"
+ }
+}
diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js
index d606310542..cf1787f0f2 100644
--- a/services/web/config/settings.defaults.js
+++ b/services/web/config/settings.defaults.js
@@ -261,6 +261,9 @@ module.exports = {
notifications: {
url: `http://${process.env.NOTIFICATIONS_HOST || '127.0.0.1'}:3042`,
},
+ references: {
+ url: `http://${process.env.REFERENCES_HOST || '127.0.0.1'}:3056`,
+ },
webpack: {
url: `http://${process.env.WEBPACK_HOST || '127.0.0.1'}:3808`,
},
From e992cfca646d5d891af522efcc7da694e5b5e505 Mon Sep 17 00:00:00 2001
From: Sam Van den Vonder
Date: Wed, 4 Dec 2024 08:01:22 +0100
Subject: [PATCH 04/11] Enable Sandboxed Compiles feature
---
server-ce/config/settings.js | 35 +++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/server-ce/config/settings.js b/server-ce/config/settings.js
index a4b5ed1781..b1a3c5240a 100644
--- a/server-ce/config/settings.js
+++ b/server-ce/config/settings.js
@@ -458,6 +458,41 @@ switch (process.env.OVERLEAF_FILESTORE_BACKEND) {
}
}
+// Overleaf Extended CE Compiler options to enable sandboxed compiles.
+// -----------
+if (process.env.SANDBOXED_COMPILES === 'true') {
+ settings.clsi = {
+ ...settings.clsi,
+ dockerRunner: true,
+ docker: {
+ image: process.env.TEX_LIVE_DOCKER_IMAGE,
+ env: {
+ HOME: '/tmp',
+ PATH:
+ process.env.COMPILER_PATH ||
+ '/usr/local/bin:/usr/bin:/bin',
+ },
+ user: 'www-data',
+ }
+ }
+
+ if (settings.path == null) {
+ settings.path = {}
+ }
+ settings.path.synctexBaseDir = () => '/compile'
+ if (process.env.SANDBOXED_COMPILES_SIBLING_CONTAINERS === 'true') {
+ console.log('Using sibling containers for sandboxed compiles')
+ if (process.env.SANDBOXED_COMPILES_HOST_DIR) {
+ settings.path.sandboxedCompilesHostDir =
+ process.env.SANDBOXED_COMPILES_HOST_DIR
+ } else {
+ console.error(
+ 'Sibling containers, but SANDBOXED_COMPILES_HOST_DIR not set'
+ )
+ }
+ }
+}
+
// With lots of incoming and outgoing HTTP connections to different services,
// sometimes long running, it is a good idea to increase the default number
// of sockets that Node will hold open.
From 1fbbfe176fd3ef439553f72fdb8025d26a38e063 Mon Sep 17 00:00:00 2001
From: yu-i-i
Date: Fri, 6 Dec 2024 12:45:15 +0100
Subject: [PATCH 05/11] Allow selecting a TeX Live image for a project
---
server-ce/config/settings.js | 8 +-------
services/clsi/app/js/DockerRunner.js | 4 ++--
.../web/app/src/Features/Project/ProjectEditorHandler.js | 5 +----
.../app/src/Features/Project/ProjectOptionsHandler.js | 3 +--
services/web/config/settings.defaults.js | 9 +++++++++
5 files changed, 14 insertions(+), 15 deletions(-)
diff --git a/server-ce/config/settings.js b/server-ce/config/settings.js
index b1a3c5240a..a2d3f3e380 100644
--- a/server-ce/config/settings.js
+++ b/server-ce/config/settings.js
@@ -466,13 +466,7 @@ if (process.env.SANDBOXED_COMPILES === 'true') {
dockerRunner: true,
docker: {
image: process.env.TEX_LIVE_DOCKER_IMAGE,
- env: {
- HOME: '/tmp',
- PATH:
- process.env.COMPILER_PATH ||
- '/usr/local/bin:/usr/bin:/bin',
- },
- user: 'www-data',
+ user: process.env.TEX_LIVE_DOCKER_USER || 'www-data',
}
}
diff --git a/services/clsi/app/js/DockerRunner.js b/services/clsi/app/js/DockerRunner.js
index 7aac613db4..fed929ceff 100644
--- a/services/clsi/app/js/DockerRunner.js
+++ b/services/clsi/app/js/DockerRunner.js
@@ -234,8 +234,8 @@ const DockerRunner = {
}
}
// set the path based on the image year
- const match = image.match(/:([0-9]+)\.[0-9]+/)
- const year = match ? match[1] : '2014'
+ const match = image.match(/:([0-9]+)\.[0-9]+|:TL([0-9]+)/)
+ const year = match ? match[1] || match[2] : '2014'
env.PATH = `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/texlive/${year}/bin/x86_64-linux/`
const options = {
Cmd: command,
diff --git a/services/web/app/src/Features/Project/ProjectEditorHandler.js b/services/web/app/src/Features/Project/ProjectEditorHandler.js
index c0d6fa4f4f..ca4abc4faf 100644
--- a/services/web/app/src/Features/Project/ProjectEditorHandler.js
+++ b/services/web/app/src/Features/Project/ProjectEditorHandler.js
@@ -38,10 +38,7 @@ module.exports = ProjectEditorHandler = {
),
members: [],
invites: this.buildInvitesView(invites),
- imageName:
- project.imageName != null
- ? Path.basename(project.imageName)
- : undefined,
+ imageName: project.imageName,
}
;({ owner, ownerFeatures, members } =
diff --git a/services/web/app/src/Features/Project/ProjectOptionsHandler.js b/services/web/app/src/Features/Project/ProjectOptionsHandler.js
index 5ca89ce145..ab684ab317 100644
--- a/services/web/app/src/Features/Project/ProjectOptionsHandler.js
+++ b/services/web/app/src/Features/Project/ProjectOptionsHandler.js
@@ -22,7 +22,6 @@ const ProjectOptionsHandler = {
if (!imageName || !Array.isArray(settings.allowedImageNames)) {
return
}
- imageName = imageName.toLowerCase()
const isAllowed = settings.allowedImageNames.find(
allowed => imageName === allowed.imageName
)
@@ -30,7 +29,7 @@ const ProjectOptionsHandler = {
throw new Error(`invalid imageName: ${imageName}`)
}
const conditions = { _id: projectId }
- const update = { imageName: settings.imageRoot + '/' + imageName }
+ const update = { imageName: imageName }
return Project.updateOne(conditions, update, {})
},
diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js
index cf1787f0f2..d2221a1142 100644
--- a/services/web/config/settings.defaults.js
+++ b/services/web/config/settings.defaults.js
@@ -1019,6 +1019,15 @@ module.exports = {
managedUsers: {
enabled: false,
},
+
+ allowedImageNames: process.env.SANDBOXED_COMPILES === 'true'
+ ? parseTextExtensions(process.env.ALL_TEX_LIVE_DOCKER_IMAGES)
+ .map((imageName, index) => ({
+ imageName,
+ imageDesc: parseTextExtensions(process.env.ALL_TEX_LIVE_DOCKER_IMAGE_NAMES)[index]
+ || imageName.split(':')[1],
+ }))
+ : undefined,
}
module.exports.mergeWith = function (overrides) {
From 83719f84c222d7ac618948d349bbe587b8ff3b3d Mon Sep 17 00:00:00 2001
From: yu-i-i
Date: Wed, 11 Dec 2024 03:55:41 +0100
Subject: [PATCH 06/11] Enable Symbol Palette
---
services/web/config/settings.defaults.js | 3 +-
.../components/symbol-palette-body.js | 61 ++
.../components/symbol-palette-close-button.js | 18 +
.../components/symbol-palette-content.js | 94 ++
.../components/symbol-palette-info-link.js | 29 +
.../components/symbol-palette-item.js | 67 ++
.../components/symbol-palette-items.js | 86 ++
.../components/symbol-palette-search.js | 44 +
.../components/symbol-palette-tabs.js | 22 +
.../components/symbol-palette.js | 8 +
.../features/symbol-palette/data/symbols.json | 872 ++++++++++++++++++
.../symbol-palette/utils/categories.js | 44 +
services/web/modules/symbol-palette/index.mjs | 2 +
services/web/package.json | 1 +
14 files changed, 1350 insertions(+), 1 deletion(-)
create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-body.js
create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js
create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-content.js
create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-info-link.js
create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-item.js
create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-items.js
create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-search.js
create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-tabs.js
create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette.js
create mode 100644 services/web/frontend/js/features/symbol-palette/data/symbols.json
create mode 100644 services/web/frontend/js/features/symbol-palette/utils/categories.js
create mode 100644 services/web/modules/symbol-palette/index.mjs
diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js
index d2221a1142..e30575da3c 100644
--- a/services/web/config/settings.defaults.js
+++ b/services/web/config/settings.defaults.js
@@ -963,7 +963,7 @@ module.exports = {
pdfPreviewPromotions: [],
diagnosticActions: [],
sourceEditorCompletionSources: [],
- sourceEditorSymbolPalette: [],
+ sourceEditorSymbolPalette: ['@/features/symbol-palette/components/symbol-palette'],
sourceEditorToolbarComponents: [],
editorPromotions: [],
langFeedbackLinkingWidgets: [],
@@ -993,6 +993,7 @@ module.exports = {
'launchpad',
'server-ce-scripts',
'user-activate',
+ 'symbol-palette',
'track-changes',
],
viewIncludes: {},
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-body.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-body.js
new file mode 100644
index 0000000000..c4f47e325d
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-body.js
@@ -0,0 +1,61 @@
+import { TabPanels, TabPanel } from '@reach/tabs'
+import { useTranslation } from 'react-i18next'
+import PropTypes from 'prop-types'
+import SymbolPaletteItems from './symbol-palette-items'
+
+export default function SymbolPaletteBody({
+ categories,
+ categorisedSymbols,
+ filteredSymbols,
+ handleSelect,
+ focusInput,
+}) {
+ const { t } = useTranslation()
+
+ // searching with matches: show the matched symbols
+ // searching with no matches: show a message
+ // note: include empty tab panels so that aria-controls on tabs can still reference the panel ids
+ if (filteredSymbols) {
+ return (
+ <>
+ {filteredSymbols.length ? (
+
+ ) : (
+ {t('no_symbols_found')}
+ )}
+
+
+ {categories.map(category => (
+
+ ))}
+
+ >
+ )
+ }
+
+ // not searching: show the symbols grouped by category
+ return (
+
+ {categories.map(category => (
+
+
+
+ ))}
+
+ )
+}
+SymbolPaletteBody.propTypes = {
+ categories: PropTypes.arrayOf(PropTypes.object).isRequired,
+ categorisedSymbols: PropTypes.object,
+ filteredSymbols: PropTypes.arrayOf(PropTypes.object),
+ handleSelect: PropTypes.func.isRequired,
+ focusInput: PropTypes.func.isRequired,
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js
new file mode 100644
index 0000000000..c472c31586
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js
@@ -0,0 +1,18 @@
+import { Button } from 'react-bootstrap'
+import { useEditorContext } from '../../../shared/context/editor-context'
+
+export default function SymbolPaletteCloseButton() {
+ const { toggleSymbolPalette } = useEditorContext()
+
+ return (
+
+ ×
+
+ )
+}
+
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-content.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-content.js
new file mode 100644
index 0000000000..8537e14585
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-content.js
@@ -0,0 +1,94 @@
+import { Tabs } from '@reach/tabs'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import PropTypes from 'prop-types'
+import { matchSorter } from 'match-sorter'
+
+import symbols from '../data/symbols.json'
+import { buildCategorisedSymbols, createCategories } from '../utils/categories'
+import SymbolPaletteSearch from './symbol-palette-search'
+import SymbolPaletteBody from './symbol-palette-body'
+import SymbolPaletteTabs from './symbol-palette-tabs'
+// import SymbolPaletteInfoLink from './symbol-palette-info-link'
+import SymbolPaletteCloseButton from './symbol-palette-close-button'
+
+import '@reach/tabs/styles.css'
+
+export default function SymbolPaletteContent({ handleSelect }) {
+ const [input, setInput] = useState('')
+
+ const { t } = useTranslation()
+
+ // build the list of categories with translated labels
+ const categories = useMemo(() => createCategories(t), [t])
+
+ // group the symbols by category
+ const categorisedSymbols = useMemo(
+ () => buildCategorisedSymbols(categories),
+ [categories]
+ )
+
+ // select symbols which match the input
+ const filteredSymbols = useMemo(() => {
+ if (input === '') {
+ return null
+ }
+
+ const words = input.trim().split(/\s+/)
+
+ return words.reduceRight(
+ (symbols, word) =>
+ matchSorter(symbols, word, {
+ keys: ['command', 'description', 'character', 'aliases'],
+ threshold: matchSorter.rankings.CONTAINS,
+ }),
+ symbols
+ )
+ }, [input])
+
+ const inputRef = useRef(null)
+
+ // allow the input to be focused
+ const focusInput = useCallback(() => {
+ if (inputRef.current) {
+ inputRef.current.focus()
+ }
+ }, [])
+
+ // focus the input when the symbol palette is opened
+ useEffect(() => {
+ if (inputRef.current) {
+ inputRef.current.focus()
+ }
+ }, [])
+
+ return (
+
+
+
+
+
+
+ {/* Useless button (uncomment if you see any sense in it) */}
+ {/* */}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+SymbolPaletteContent.propTypes = {
+ handleSelect: PropTypes.func.isRequired,
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-info-link.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-info-link.js
new file mode 100644
index 0000000000..ba56cf2b10
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-info-link.js
@@ -0,0 +1,29 @@
+import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+
+export default function SymbolPaletteInfoLink() {
+ const { t } = useTranslation()
+
+ return (
+
+ {t('find_out_more_about_latex_symbols')}
+
+ }
+ >
+
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-item.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-item.js
new file mode 100644
index 0000000000..a892f33cf8
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-item.js
@@ -0,0 +1,67 @@
+import { useEffect, useRef } from 'react'
+import { OverlayTrigger, Tooltip } from 'react-bootstrap'
+import PropTypes from 'prop-types'
+
+export default function SymbolPaletteItem({
+ focused,
+ handleSelect,
+ handleKeyDown,
+ symbol,
+}) {
+ const buttonRef = useRef(null)
+
+ // call focus() on this item when appropriate
+ useEffect(() => {
+ if (
+ focused &&
+ buttonRef.current &&
+ document.activeElement?.closest('.symbol-palette-items')
+ ) {
+ buttonRef.current.focus()
+ }
+ }, [focused])
+
+ return (
+
+
+ {symbol.description}
+
+ {symbol.command}
+ {symbol.notes && (
+ {symbol.notes}
+ )}
+
+ }
+ >
+ handleSelect(symbol)}
+ onKeyDown={handleKeyDown}
+ tabIndex={focused ? 0 : -1}
+ ref={buttonRef}
+ role="option"
+ aria-label={symbol.description}
+ aria-selected={focused ? 'true' : 'false'}
+ >
+ {symbol.character}
+
+
+ )
+}
+SymbolPaletteItem.propTypes = {
+ symbol: PropTypes.shape({
+ codepoint: PropTypes.string.isRequired,
+ description: PropTypes.string.isRequired,
+ command: PropTypes.string.isRequired,
+ character: PropTypes.string.isRequired,
+ notes: PropTypes.string,
+ }),
+ handleKeyDown: PropTypes.func.isRequired,
+ handleSelect: PropTypes.func.isRequired,
+ focused: PropTypes.bool,
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-items.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-items.js
new file mode 100644
index 0000000000..44835261f5
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-items.js
@@ -0,0 +1,86 @@
+import { useCallback, useEffect, useState } from 'react'
+import PropTypes from 'prop-types'
+import SymbolPaletteItem from './symbol-palette-item'
+
+export default function SymbolPaletteItems({
+ items,
+ handleSelect,
+ focusInput,
+}) {
+ const [focusedIndex, setFocusedIndex] = useState(0)
+
+ // reset the focused item when the list of items changes
+ useEffect(() => {
+ setFocusedIndex(0)
+ }, [items])
+
+ // navigate through items with left and right arrows
+ const handleKeyDown = useCallback(
+ event => {
+ if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) {
+ return
+ }
+
+ switch (event.key) {
+ // focus previous item
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ setFocusedIndex(index => (index > 0 ? index - 1 : items.length - 1))
+ break
+
+ // focus next item
+ case 'ArrowRight':
+ case 'ArrowDown':
+ setFocusedIndex(index => (index < items.length - 1 ? index + 1 : 0))
+ break
+
+ // focus first item
+ case 'Home':
+ setFocusedIndex(0)
+ break
+
+ // focus last item
+ case 'End':
+ setFocusedIndex(items.length - 1)
+ break
+
+ // allow the default action
+ case 'Enter':
+ case ' ':
+ break
+
+ // any other key returns focus to the input
+ default:
+ focusInput()
+ break
+ }
+ },
+ [focusInput, items.length]
+ )
+
+ return (
+
+ {items.map((symbol, index) => (
+ {
+ handleSelect(symbol)
+ setFocusedIndex(index)
+ }}
+ handleKeyDown={handleKeyDown}
+ focused={index === focusedIndex}
+ />
+ ))}
+
+ )
+}
+SymbolPaletteItems.propTypes = {
+ items: PropTypes.arrayOf(
+ PropTypes.shape({
+ codepoint: PropTypes.string.isRequired,
+ })
+ ).isRequired,
+ handleSelect: PropTypes.func.isRequired,
+ focusInput: PropTypes.func.isRequired,
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-search.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-search.js
new file mode 100644
index 0000000000..cf5a1eb2a7
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-search.js
@@ -0,0 +1,44 @@
+import { useCallback, useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import PropTypes from 'prop-types'
+import { FormControl } from 'react-bootstrap'
+import useDebounce from '../../../shared/hooks/use-debounce'
+
+export default function SymbolPaletteSearch({ setInput, inputRef }) {
+ const [localInput, setLocalInput] = useState('')
+
+ // debounce the search input until a typing delay
+ const debouncedLocalInput = useDebounce(localInput, 250)
+
+ useEffect(() => {
+ setInput(debouncedLocalInput)
+ }, [debouncedLocalInput, setInput])
+
+ const { t } = useTranslation()
+
+ const inputRefCallback = useCallback(
+ element => {
+ inputRef.current = element
+ },
+ [inputRef]
+ )
+
+ return (
+ {
+ setLocalInput(event.target.value)
+ }}
+ />
+ )
+}
+SymbolPaletteSearch.propTypes = {
+ setInput: PropTypes.func.isRequired,
+ inputRef: PropTypes.object.isRequired,
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-tabs.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-tabs.js
new file mode 100644
index 0000000000..d53cd93ac0
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-tabs.js
@@ -0,0 +1,22 @@
+import { TabList, Tab } from '@reach/tabs'
+import PropTypes from 'prop-types'
+
+export default function SymbolPaletteTabs({ categories }) {
+ return (
+
+ {categories.map(category => (
+
+ {category.label}
+
+ ))}
+
+ )
+}
+SymbolPaletteTabs.propTypes = {
+ categories: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ })
+ ).isRequired,
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette.js
new file mode 100644
index 0000000000..2f1cc5e8c8
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette.js
@@ -0,0 +1,8 @@
+import SymbolPaletteContent from './symbol-palette-content'
+
+export default function SymbolPalette() {
+ const handleSelect = (symbol) => {
+ window.dispatchEvent(new CustomEvent('editor:insert-symbol', { detail: symbol }))
+ }
+ return
+}
diff --git a/services/web/frontend/js/features/symbol-palette/data/symbols.json b/services/web/frontend/js/features/symbol-palette/data/symbols.json
new file mode 100644
index 0000000000..af160b3eed
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/data/symbols.json
@@ -0,0 +1,872 @@
+[
+ {
+ "category": "Greek",
+ "command": "\\alpha",
+ "codepoint": "U+1D6FC",
+ "description": "Lowercase Greek letter alpha",
+ "aliases": ["a", "α"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\beta",
+ "codepoint": "U+1D6FD",
+ "description": "Lowercase Greek letter beta",
+ "aliases": ["b", "β"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\gamma",
+ "codepoint": "U+1D6FE",
+ "description": "Lowercase Greek letter gamma",
+ "aliases": ["γ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\delta",
+ "codepoint": "U+1D6FF",
+ "description": "Lowercase Greek letter delta",
+ "aliases": ["δ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\varepsilon",
+ "codepoint": "U+1D700",
+ "description": "Lowercase Greek letter epsilon, varepsilon",
+ "aliases": ["ε"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\epsilon",
+ "codepoint": "U+1D716",
+ "description": "Lowercase Greek letter epsilon lunate",
+ "aliases": ["ε"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\zeta",
+ "codepoint": "U+1D701",
+ "description": "Lowercase Greek letter zeta",
+ "aliases": ["ζ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\eta",
+ "codepoint": "U+1D702",
+ "description": "Lowercase Greek letter eta",
+ "aliases": ["η"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\vartheta",
+ "codepoint": "U+1D717",
+ "description": "Lowercase Greek letter curly theta, vartheta",
+ "aliases": ["θ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\theta",
+ "codepoint": "U+1D703",
+ "description": "Lowercase Greek letter theta",
+ "aliases": ["θ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\iota",
+ "codepoint": "U+1D704",
+ "description": "Lowercase Greek letter iota",
+ "aliases": ["ι"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\kappa",
+ "codepoint": "U+1D705",
+ "description": "Lowercase Greek letter kappa",
+ "aliases": ["κ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\lambda",
+ "codepoint": "U+1D706",
+ "description": "Lowercase Greek letter lambda",
+ "aliases": ["λ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\mu",
+ "codepoint": "U+1D707",
+ "description": "Lowercase Greek letter mu",
+ "aliases": ["μ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\nu",
+ "codepoint": "U+1D708",
+ "description": "Lowercase Greek letter nu",
+ "aliases": ["ν"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\xi",
+ "codepoint": "U+1D709",
+ "description": "Lowercase Greek letter xi",
+ "aliases": ["ξ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\pi",
+ "codepoint": "U+1D70B",
+ "description": "Lowercase Greek letter pi",
+ "aliases": ["π"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\varrho",
+ "codepoint": "U+1D71A",
+ "description": "Lowercase Greek letter rho, varrho",
+ "aliases": ["ρ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\rho",
+ "codepoint": "U+1D70C",
+ "description": "Lowercase Greek letter rho",
+ "aliases": ["ρ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\sigma",
+ "codepoint": "U+1D70E",
+ "description": "Lowercase Greek letter sigma",
+ "aliases": ["σ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\varsigma",
+ "codepoint": "U+1D70D",
+ "description": "Lowercase Greek letter final sigma, varsigma",
+ "aliases": ["ς"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\tau",
+ "codepoint": "U+1D70F",
+ "description": "Lowercase Greek letter tau",
+ "aliases": ["τ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\upsilon",
+ "codepoint": "U+1D710",
+ "description": "Lowercase Greek letter upsilon",
+ "aliases": ["υ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\phi",
+ "codepoint": "U+1D719",
+ "description": "Lowercase Greek letter phi",
+ "aliases": ["φ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\varphi",
+ "codepoint": "U+1D711",
+ "description": "Lowercase Greek letter phi, varphi",
+ "aliases": ["φ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\chi",
+ "codepoint": "U+1D712",
+ "description": "Lowercase Greek letter chi",
+ "aliases": ["χ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\psi",
+ "codepoint": "U+1D713",
+ "description": "Lowercase Greek letter psi",
+ "aliases": ["ψ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\omega",
+ "codepoint": "U+1D714",
+ "description": "Lowercase Greek letter omega",
+ "aliases": ["ω"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Gamma",
+ "codepoint": "U+00393",
+ "description": "Uppercase Greek letter Gamma",
+ "aliases": ["Γ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Delta",
+ "codepoint": "U+00394",
+ "description": "Uppercase Greek letter Delta",
+ "aliases": ["Δ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Theta",
+ "codepoint": "U+00398",
+ "description": "Uppercase Greek letter Theta",
+ "aliases": ["Θ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Lambda",
+ "codepoint": "U+0039B",
+ "description": "Uppercase Greek letter Lambda",
+ "aliases": ["Λ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Xi",
+ "codepoint": "U+0039E",
+ "description": "Uppercase Greek letter Xi",
+ "aliases": ["Ξ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Pi",
+ "codepoint": "U+003A0",
+ "description": "Uppercase Greek letter Pi",
+ "aliases": ["Π"],
+ "notes": "Use \\prod for the product."
+ },
+ {
+ "category": "Greek",
+ "command": "\\Sigma",
+ "codepoint": "U+003A3",
+ "description": "Uppercase Greek letter Sigma",
+ "aliases": ["Σ"],
+ "notes": "Use \\sum for the sum."
+ },
+ {
+ "category": "Greek",
+ "command": "\\Upsilon",
+ "codepoint": "U+003A5",
+ "description": "Uppercase Greek letter Upsilon",
+ "aliases": ["Υ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Phi",
+ "codepoint": "U+003A6",
+ "description": "Uppercase Greek letter Phi",
+ "aliases": ["Φ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Psi",
+ "codepoint": "U+003A8",
+ "description": "Uppercase Greek letter Psi",
+ "aliases": ["Ψ"],
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Omega",
+ "codepoint": "U+003A9",
+ "description": "Uppercase Greek letter Omega",
+ "aliases": ["Ω"],
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\neq",
+ "codepoint": "U+02260",
+ "description": "Not equal",
+ "aliases": ["!="],
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\leq",
+ "codepoint": "U+02264",
+ "description": "Less than or equal",
+ "aliases": ["<="],
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\geq",
+ "codepoint": "U+02265",
+ "description": "Greater than or equal",
+ "aliases": [">="],
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\ll",
+ "codepoint": "U+0226A",
+ "description": "Much less than",
+ "aliases": ["<<"],
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\gg",
+ "codepoint": "U+0226B",
+ "description": "Much greater than",
+ "aliases": [">>"],
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\prec",
+ "codepoint": "U+0227A",
+ "description": "Precedes",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\succ",
+ "codepoint": "U+0227B",
+ "description": "Succeeds",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\in",
+ "codepoint": "U+02208",
+ "description": "Set membership",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\notin",
+ "codepoint": "U+02209",
+ "description": "Negated set membership",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\ni",
+ "codepoint": "U+0220B",
+ "description": "Contains",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\subset",
+ "codepoint": "U+02282",
+ "description": "Subset",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\subseteq",
+ "codepoint": "U+02286",
+ "description": "Subset or equals",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\supset",
+ "codepoint": "U+02283",
+ "description": "Superset",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\simeq",
+ "codepoint": "U+02243",
+ "description": "Similar",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\approx",
+ "codepoint": "U+02248",
+ "description": "Approximate",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\equiv",
+ "codepoint": "U+02261",
+ "description": "Identical with",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\cong",
+ "codepoint": "U+02245",
+ "description": "Congruent with",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\mid",
+ "codepoint": "U+02223",
+ "description": "Mid, divides, vertical bar, modulus, absolute value",
+ "notes": "Use \\lvert...\\rvert for the absolute value."
+ },
+ {
+ "category": "Relations",
+ "command": "\\nmid",
+ "codepoint": "U+02224",
+ "description": "Negated mid, not divides",
+ "notes": "Requires \\usepackage{amssymb}."
+ },
+ {
+ "category": "Relations",
+ "command": "\\parallel",
+ "codepoint": "U+02225",
+ "description": "Parallel, double vertical bar, norm",
+ "notes": "Use \\lVert...\\rVert for the norm."
+ },
+ {
+ "category": "Relations",
+ "command": "\\perp",
+ "codepoint": "U+027C2",
+ "description": "Perpendicular",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\times",
+ "codepoint": "U+000D7",
+ "description": "Cross product, multiplication",
+ "aliases": ["x"],
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\div",
+ "codepoint": "U+000F7",
+ "description": "Division",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\cap",
+ "codepoint": "U+02229",
+ "description": "Intersection",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\cup",
+ "codepoint": "U+0222A",
+ "description": "Union",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\cdot",
+ "codepoint": "U+022C5",
+ "description": "Dot product, multiplication",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\cdots",
+ "codepoint": "U+022EF",
+ "description": "Centered dots",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\bullet",
+ "codepoint": "U+02219",
+ "description": "Bullet",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\circ",
+ "codepoint": "U+025E6",
+ "description": "Circle",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\wedge",
+ "codepoint": "U+02227",
+ "description": "Wedge, logical and",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\vee",
+ "codepoint": "U+02228",
+ "description": "Vee, logical or",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\setminus",
+ "codepoint": "U+0005C",
+ "description": "Set minus, backslash",
+ "notes": "Use \\backslash for a backslash."
+ },
+ {
+ "category": "Operators",
+ "command": "\\oplus",
+ "codepoint": "U+02295",
+ "description": "Plus sign in circle",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\otimes",
+ "codepoint": "U+02297",
+ "description": "Multiply sign in circle",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\sum",
+ "codepoint": "U+02211",
+ "description": "Summation operator",
+ "notes": "Use \\Sigma for the letter Sigma."
+ },
+ {
+ "category": "Operators",
+ "command": "\\prod",
+ "codepoint": "U+0220F",
+ "description": "Product operator",
+ "notes": "Use \\Pi for the letter Pi."
+ },
+ {
+ "category": "Operators",
+ "command": "\\bigcap",
+ "codepoint": "U+022C2",
+ "description": "Intersection operator",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\bigcup",
+ "codepoint": "U+022C3",
+ "description": "Union operator",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\int",
+ "codepoint": "U+0222B",
+ "description": "Integral operator",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\iint",
+ "codepoint": "U+0222C",
+ "description": "Double integral operator",
+ "notes": "Requires \\usepackage{amsmath}."
+ },
+ {
+ "category": "Operators",
+ "command": "\\iiint",
+ "codepoint": "U+0222D",
+ "description": "Triple integral operator",
+ "notes": "Requires \\usepackage{amsmath}."
+ },
+ {
+ "category": "Arrows",
+ "command": "\\leftarrow",
+ "codepoint": "U+02190",
+ "description": "Leftward arrow",
+ "aliases": ["<-"],
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\rightarrow",
+ "codepoint": "U+02192",
+ "description": "Rightward arrow",
+ "aliases": ["->"],
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\leftrightarrow",
+ "codepoint": "U+02194",
+ "description": "Left and right arrow",
+ "aliases": ["<->"],
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\uparrow",
+ "codepoint": "U+02191",
+ "description": "Upward arrow",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\downarrow",
+ "codepoint": "U+02193",
+ "description": "Downward arrow",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\Leftarrow",
+ "codepoint": "U+021D0",
+ "description": "Is implied by",
+ "aliases": ["<="],
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\Rightarrow",
+ "codepoint": "U+021D2",
+ "description": "Implies",
+ "aliases": ["=>"],
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\Leftrightarrow",
+ "codepoint": "U+021D4",
+ "description": "Left and right double arrow",
+ "aliases": ["<=>"],
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\mapsto",
+ "codepoint": "U+021A6",
+ "description": "Maps to, rightward",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\nearrow",
+ "codepoint": "U+02197",
+ "description": "NE pointing arrow",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\searrow",
+ "codepoint": "U+02198",
+ "description": "SE pointing arrow",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\rightleftharpoons",
+ "codepoint": "U+021CC",
+ "description": "Right harpoon over left",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\leftharpoonup",
+ "codepoint": "U+021BC",
+ "description": "Left harpoon up",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\rightharpoonup",
+ "codepoint": "U+021C0",
+ "description": "Right harpoon up",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\leftharpoondown",
+ "codepoint": "U+021BD",
+ "description": "Left harpoon down",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\rightharpoondown",
+ "codepoint": "U+021C1",
+ "description": "Right harpoon down",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\infty",
+ "codepoint": "U+0221E",
+ "description": "Infinity",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\partial",
+ "codepoint": "U+1D715",
+ "description": "Partial differential",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\nabla",
+ "codepoint": "U+02207",
+ "description": "Nabla, del, hamilton operator",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\varnothing",
+ "codepoint": "U+02300",
+ "description": "Empty set",
+ "notes": "Requires \\usepackage{amssymb}."
+ },
+ {
+ "category": "Misc",
+ "command": "\\forall",
+ "codepoint": "U+02200",
+ "description": "For all",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\exists",
+ "codepoint": "U+02203",
+ "description": "There exists",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\neg",
+ "codepoint": "U+000AC",
+ "description": "Not sign",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\Re",
+ "codepoint": "U+0211C",
+ "description": "Real part",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\Im",
+ "codepoint": "U+02111",
+ "description": "Imaginary part",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\Box",
+ "codepoint": "U+025A1",
+ "description": "Square",
+ "notes": "Requires \\usepackage{amssymb}."
+ },
+ {
+ "category": "Misc",
+ "command": "\\triangle",
+ "codepoint": "U+025B3",
+ "description": "Triangle",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\aleph",
+ "codepoint": "U+02135",
+ "description": "Hebrew letter aleph",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\wp",
+ "codepoint": "U+02118",
+ "description": "Weierstrass letter p",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\#",
+ "codepoint": "U+00023",
+ "description": "Number sign, hashtag",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\$",
+ "codepoint": "U+00024",
+ "description": "Dollar sign",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\%",
+ "codepoint": "U+00025",
+ "description": "Percent sign",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\&",
+ "codepoint": "U+00026",
+ "description": "Et sign, and, ampersand",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\{",
+ "codepoint": "U+0007B",
+ "description": "Left curly brace",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\}",
+ "codepoint": "U+0007D",
+ "description": "Right curly brace",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\langle",
+ "codepoint": "U+027E8",
+ "description": "Left angle bracket, bra",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\rangle",
+ "codepoint": "U+027E9",
+ "description": "Right angle bracket, ket",
+ "notes": ""
+ }
+]
diff --git a/services/web/frontend/js/features/symbol-palette/utils/categories.js b/services/web/frontend/js/features/symbol-palette/utils/categories.js
new file mode 100644
index 0000000000..872534771f
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/utils/categories.js
@@ -0,0 +1,44 @@
+import symbols from '../data/symbols.json'
+export function createCategories(t) {
+ return [
+ {
+ id: 'Greek',
+ label: t('category_greek'),
+ },
+ {
+ id: 'Arrows',
+ label: t('category_arrows'),
+ },
+ {
+ id: 'Operators',
+ label: t('category_operators'),
+ },
+ {
+ id: 'Relations',
+ label: t('category_relations'),
+ },
+ {
+ id: 'Misc',
+ label: t('category_misc'),
+ },
+ ]
+}
+
+export function buildCategorisedSymbols(categories) {
+ const output = {}
+
+ for (const category of categories) {
+ output[category.id] = []
+ }
+
+ for (const item of symbols) {
+ if (item.category in output) {
+ item.character = String.fromCodePoint(
+ parseInt(item.codepoint.replace(/^U\+0*/, ''), 16)
+ )
+ output[item.category].push(item)
+ }
+ }
+
+ return output
+}
diff --git a/services/web/modules/symbol-palette/index.mjs b/services/web/modules/symbol-palette/index.mjs
new file mode 100644
index 0000000000..3a412c2eec
--- /dev/null
+++ b/services/web/modules/symbol-palette/index.mjs
@@ -0,0 +1,2 @@
+import logger from '@overleaf/logger'
+logger.debug({}, 'Enable Symbol Palette')
diff --git a/services/web/package.json b/services/web/package.json
index fa8b1e646b..6494b1005e 100644
--- a/services/web/package.json
+++ b/services/web/package.json
@@ -212,6 +212,7 @@
"@pollyjs/adapter-node-http": "^6.0.6",
"@pollyjs/core": "^6.0.6",
"@pollyjs/persister-fs": "^6.0.6",
+ "@reach/tabs": "0.18.0",
"@replit/codemirror-emacs": "overleaf/codemirror-emacs#4394c03858f27053f8768258e9493866e06e938e",
"@replit/codemirror-indentation-markers": "overleaf/codemirror-indentation-markers#78264032eb286bc47871569ae87bff5ca1c6c161",
"@replit/codemirror-vim": "overleaf/codemirror-vim#1bef138382d948018f3f9b8a4d7a70ab61774e4b",
From 5d2febba7d83e358ea073cdd52a17e0525a0c471 Mon Sep 17 00:00:00 2001
From: yu-i-i
Date: Tue, 3 Dec 2024 01:18:19 +0100
Subject: [PATCH 07/11] Enable LDAP and SAML authentication support
---
patches/@node-saml+node-saml+4.0.5.patch | 23 +++
patches/ldapauth-fork+4.3.3.patch | 64 +++++++
.../AuthenticationController.js | 5 +-
.../PasswordReset/PasswordResetController.mjs | 4 +
.../PasswordReset/PasswordResetHandler.mjs | 4 +
.../app/src/Features/User/UserController.js | 3 +-
services/web/app/views/user/login.pug | 13 +-
services/web/app/views/user/settings.pug | 4 +-
services/web/config/settings.defaults.js | 2 +
services/web/locales/en.json | 2 +
.../launchpad/app/src/LaunchpadController.mjs | 3 +-
.../modules/launchpad/app/views/launchpad.pug | 81 ++++++++-
.../app/src/AuthenticationControllerLdap.mjs | 64 +++++++
.../app/src/AuthenticationManagerLdap.mjs | 80 +++++++++
.../app/src/InitLdapSettings.mjs | 17 ++
.../app/src/LdapContacts.mjs | 136 +++++++++++++++
.../app/src/LdapStrategy.mjs | 78 +++++++++
.../web/modules/ldap-authentication/index.mjs | 30 ++++
.../app/src/AuthenticationControllerSaml.mjs | 160 ++++++++++++++++++
.../app/src/AuthenticationManagerSaml.mjs | 60 +++++++
.../app/src/InitSamlSettings.mjs | 16 ++
.../app/src/SamlNonCsrfRouter.mjs | 12 ++
.../app/src/SamlRouter.mjs | 14 ++
.../app/src/SamlStrategy.mjs | 62 +++++++
.../web/modules/saml-authentication/index.mjs | 26 +++
25 files changed, 953 insertions(+), 10 deletions(-)
create mode 100644 patches/@node-saml+node-saml+4.0.5.patch
create mode 100644 patches/ldapauth-fork+4.3.3.patch
create mode 100644 services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs
create mode 100644 services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs
create mode 100644 services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs
create mode 100644 services/web/modules/ldap-authentication/app/src/LdapContacts.mjs
create mode 100644 services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs
create mode 100644 services/web/modules/ldap-authentication/index.mjs
create mode 100644 services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs
create mode 100644 services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs
create mode 100644 services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs
create mode 100644 services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs
create mode 100644 services/web/modules/saml-authentication/app/src/SamlRouter.mjs
create mode 100644 services/web/modules/saml-authentication/app/src/SamlStrategy.mjs
create mode 100644 services/web/modules/saml-authentication/index.mjs
diff --git a/patches/@node-saml+node-saml+4.0.5.patch b/patches/@node-saml+node-saml+4.0.5.patch
new file mode 100644
index 0000000000..81fd700b31
--- /dev/null
+++ b/patches/@node-saml+node-saml+4.0.5.patch
@@ -0,0 +1,23 @@
+diff --git a/node_modules/@node-saml/node-saml/lib/saml.js b/node_modules/@node-saml/node-saml/lib/saml.js
+index fba15b9..a5778cb 100644
+--- a/node_modules/@node-saml/node-saml/lib/saml.js
++++ b/node_modules/@node-saml/node-saml/lib/saml.js
+@@ -336,7 +336,8 @@ class SAML {
+ const requestOrResponse = request || response;
+ (0, utility_1.assertRequired)(requestOrResponse, "either request or response is required");
+ let buffer;
+- if (this.options.skipRequestCompression) {
++ // logout requestOrResponse must be compressed anyway
++ if (this.options.skipRequestCompression && operation !== "logout") {
+ buffer = Buffer.from(requestOrResponse, "utf8");
+ }
+ else {
+@@ -495,7 +496,7 @@ class SAML {
+ try {
+ xml = Buffer.from(container.SAMLResponse, "base64").toString("utf8");
+ doc = await (0, xml_1.parseDomFromString)(xml);
+- const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response']/@InResponseTo");
++ const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response' or local-name()='LogoutResponse']/@InResponseTo");
+ if (inResponseToNodes) {
+ inResponseTo = inResponseToNodes.length ? inResponseToNodes[0].nodeValue : null;
+ await this.validateInResponseTo(inResponseTo);
diff --git a/patches/ldapauth-fork+4.3.3.patch b/patches/ldapauth-fork+4.3.3.patch
new file mode 100644
index 0000000000..4d31210c9d
--- /dev/null
+++ b/patches/ldapauth-fork+4.3.3.patch
@@ -0,0 +1,64 @@
+diff --git a/node_modules/ldapauth-fork/lib/ldapauth.js b/node_modules/ldapauth-fork/lib/ldapauth.js
+index 85ecf36a8b..a7d07e0f78 100644
+--- a/node_modules/ldapauth-fork/lib/ldapauth.js
++++ b/node_modules/ldapauth-fork/lib/ldapauth.js
+@@ -69,6 +69,7 @@ function LdapAuth(opts) {
+ this.opts.bindProperty || (this.opts.bindProperty = 'dn');
+ this.opts.groupSearchScope || (this.opts.groupSearchScope = 'sub');
+ this.opts.groupDnProperty || (this.opts.groupDnProperty = 'dn');
++ this.opts.tlsStarted = false;
+
+ EventEmitter.call(this);
+
+@@ -108,21 +109,7 @@ function LdapAuth(opts) {
+ this._userClient.on('error', this._handleError.bind(this));
+
+ var self = this;
+- if (this.opts.starttls) {
+- // When starttls is enabled, this callback supplants the 'connect' callback
+- this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function(err) {
+- if (err) {
+- self._handleError(err);
+- } else {
+- self._onConnectAdmin();
+- }
+- });
+- this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function(err) {
+- if (err) {
+- self._handleError(err);
+- }
+- });
+- } else if (opts.reconnect) {
++ if (opts.reconnect && !this.opts.starttls) {
+ this.once('_installReconnectListener', function() {
+ self.log && self.log.trace('install reconnect listener');
+ self._adminClient.on('connect', function() {
+@@ -384,6 +371,28 @@ LdapAuth.prototype._findGroups = function(user, callback) {
+ */
+ LdapAuth.prototype.authenticate = function(username, password, callback) {
+ var self = this;
++ if (this.opts.starttls && !this.opts.tlsStarted) {
++ // When starttls is enabled, this callback supplants the 'connect' callback
++ this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function (err) {
++ if (err) {
++ self._handleError(err);
++ } else {
++ self._onConnectAdmin(function(){self._handleAuthenticate(username, password, callback);});
++ }
++ });
++ this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function (err) {
++ if (err) {
++ self._handleError(err);
++ }
++ });
++ } else {
++ self._handleAuthenticate(username, password, callback);
++ }
++};
++
++LdapAuth.prototype._handleAuthenticate = function (username, password, callback) {
++ this.opts.tlsStarted = true;
++ var self = this;
+
+ if (typeof password === 'undefined' || password === null || password === '') {
+ return callback(new Error('no password given'));
diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.js b/services/web/app/src/Features/Authentication/AuthenticationController.js
index 0df10e0715..6b0ad091c9 100644
--- a/services/web/app/src/Features/Authentication/AuthenticationController.js
+++ b/services/web/app/src/Features/Authentication/AuthenticationController.js
@@ -102,9 +102,9 @@ const AuthenticationController = {
// so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
// and send a `{redir: ""}` response on success
passport.authenticate(
- 'local',
+ Settings.ldap?.enable ? ['custom-fail-ldapauth','local'] : ['local'],
{ keepSessionInfo: true },
- async function (err, user, info) {
+ async function (err, user, infoArray) {
if (err) {
return next(err)
}
@@ -126,6 +126,7 @@ const AuthenticationController = {
return next(err)
}
} else {
+ let info = infoArray[0]
if (info.redir != null) {
return res.json({ redir: info.redir })
} else {
diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs
index aa4ac057dc..7fe3665dda 100644
--- a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs
+++ b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs
@@ -136,6 +136,10 @@ async function requestReset(req, res, next) {
return res.status(404).json({
message: req.i18n.translate('secondary_email_password_reset'),
})
+ } else if (status === 'external') {
+ return res.status(403).json({
+ message: req.i18n.translate('password_managed_externally'),
+ })
} else {
return res.status(404).json({
message: req.i18n.translate('cant_find_email'),
diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs
index 094f18b95f..0ac203222c 100644
--- a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs
+++ b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs
@@ -18,6 +18,10 @@ async function generateAndEmailResetToken(email) {
return null
}
+ if (!user.hashedPassword) {
+ return 'external'
+ }
+
if (user.email !== email) {
return 'secondary'
}
diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js
index 3c1818979f..603731dd67 100644
--- a/services/web/app/src/Features/User/UserController.js
+++ b/services/web/app/src/Features/User/UserController.js
@@ -396,7 +396,7 @@ async function updateUserSettings(req, res, next) {
if (
newEmail == null ||
newEmail === user.email ||
- req.externalAuthenticationSystemUsed()
+ (req.externalAuthenticationSystemUsed() && !user.hashedPassword)
) {
// end here, don't update email
SessionManager.setInSessionUser(req.session, {
@@ -473,6 +473,7 @@ async function doLogout(req) {
}
async function logout(req, res, next) {
+ if (req?.session.saml_extce) return res.redirect(308, '/saml/logout')
const requestedRedirect = req.body.redirect
? UrlHelper.getSafeRedirectPath(req.body.redirect)
: undefined
diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug
index 522aff3cde..f6df902668 100644
--- a/services/web/app/views/user/login.pug
+++ b/services/web/app/views/user/login.pug
@@ -19,10 +19,10 @@ block content
| !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}.
.form-group
input.form-control(
- type='email',
+ type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email',
name='email',
required,
- placeholder='email@example.com',
+ placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com',
autofocus="true"
)
.form-group
@@ -44,3 +44,12 @@ block content
hr
p.text-center !{login_support_text}
+ if settings.saml && settings.saml.enable
+ form(data-ol-async-form, name="samlLoginForm")
+ .actions(style='margin-top: 30px;')
+ a.btn.btn-secondary.btn-block(
+ href='/saml/login',
+ data-ol-disabled-inflight
+ )
+ span(data-ol-inflight="idle") #{settings.saml.identityServiceName}
+ span(hidden data-ol-inflight="pending") #{translate("logging_in")}…
diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug
index aee09abc2b..b80999e247 100644
--- a/services/web/app/views/user/settings.pug
+++ b/services/web/app/views/user/settings.pug
@@ -10,7 +10,7 @@ block vars
block append meta
meta(name="ol-hasPassword" data-type="boolean" content=hasPassword)
- meta(name="ol-shouldAllowEditingDetails" data-type="boolean" content=shouldAllowEditingDetails)
+ meta(name="ol-shouldAllowEditingDetails" data-type="boolean" content=shouldAllowEditingDetails || hasPassword)
meta(name="ol-oauthProviders", data-type="json", content=oauthProviders)
meta(name="ol-institutionLinked", data-type="json", content=institutionLinked)
meta(name="ol-samlError", data-type="json", content=samlError)
@@ -22,7 +22,7 @@ block append meta
meta(name="ol-ssoErrorMessage", content=ssoErrorMessage)
meta(name="ol-thirdPartyIds", data-type="json", content=thirdPartyIds || {})
meta(name="ol-passwordStrengthOptions", data-type="json", content=settings.passwordStrengthOptions || {})
- meta(name="ol-isExternalAuthenticationSystemUsed" data-type="boolean" content=externalAuthenticationSystemUsed())
+ meta(name="ol-isExternalAuthenticationSystemUsed" data-type="boolean" content=externalAuthenticationSystemUsed() && !hasPassword)
meta(name="ol-user" data-type="json" content=user)
meta(name="ol-dropbox" data-type="json" content=dropbox)
meta(name="ol-github" data-type="json" content=github)
diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js
index e30575da3c..e5b0f9e89a 100644
--- a/services/web/config/settings.defaults.js
+++ b/services/web/config/settings.defaults.js
@@ -995,6 +995,8 @@ module.exports = {
'user-activate',
'symbol-palette',
'track-changes',
+ 'ldap-authentication',
+ 'saml-authentication',
],
viewIncludes: {},
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 422526725f..c61852cc00 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -147,6 +147,7 @@
"also": "Also",
"also_available_as_on_premises": "Also available as On-Premises",
"alternatively_create_new_institution_account": "Alternatively, you can create a new account with your institution email (__email__ ) by clicking __clickText__ .",
+ "alternatively_create_local_admin_account": "Alternatively, you can create __appName__ local admin account.",
"an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__0>. Please wait and try again later.",
"an_error_occured_while_restoring_project": "An error occured while restoring the project",
"an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code",
@@ -1209,6 +1210,7 @@
"loading_prices": "loading prices",
"loading_recent_github_commits": "Loading recent commits",
"loading_writefull": "Loading Writefull",
+ "local_account": "Local account",
"log_entry_description": "Log entry with level: __level__",
"log_entry_maximum_entries": "Maximum log entries limit hit",
"log_entry_maximum_entries_enable_stop_on_first_error": "Try to fix the first error and recompile. Often one error causes many later error messages. You can <0>Enable “Stop on first error”0> to focus on fixing errors. We recommend fixing errors as soon as possible; letting them accumulate may lead to hard-to-debug and fatal errors. <1>Learn more1>",
diff --git a/services/web/modules/launchpad/app/src/LaunchpadController.mjs b/services/web/modules/launchpad/app/src/LaunchpadController.mjs
index b626e0176e..49dd9ea9cb 100644
--- a/services/web/modules/launchpad/app/src/LaunchpadController.mjs
+++ b/services/web/modules/launchpad/app/src/LaunchpadController.mjs
@@ -154,7 +154,8 @@ function registerExternalAuthAdmin(authMethod) {
await User.updateOne(
{ _id: user._id },
{
- $set: { isAdmin: true, emails: [{ email, reversedHostname }] },
+ $set: { isAdmin: true, emails: [{ email, reversedHostname, 'confirmedAt' : Date.now() }] },
+ $unset: { 'hashedPassword': "" }, // external-auth user must not have a hashedPassword
}
).exec()
} catch (err) {
diff --git a/services/web/modules/launchpad/app/views/launchpad.pug b/services/web/modules/launchpad/app/views/launchpad.pug
index 28d3ff8fc8..13d1ddd7fa 100644
--- a/services/web/modules/launchpad/app/views/launchpad.pug
+++ b/services/web/modules/launchpad/app/views/launchpad.pug
@@ -28,7 +28,7 @@ block vars
block append meta
meta(name="ol-adminUserExists" data-type="boolean" content=adminUserExists)
- meta(name="ol-ideJsPath" content=buildJsPath('ide.js'))
+ meta(name="ol-ideJsPath" content=buildJsPath('ide-detached.js'))
block content
script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js')
@@ -125,6 +125,45 @@ block content
span(data-ol-inflight="idle") #{translate("register")}
span(hidden data-ol-inflight="pending") #{translate("registering")}…
+ h3 #{translate('local_account')}
+ p
+ | #{translate('alternatively_create_local_admin_account')}
+
+ form(
+ data-ol-async-form
+ data-ol-register-admin
+ action="/launchpad/register_admin"
+ method="POST"
+ )
+ input(name='_csrf', type='hidden', value=csrfToken)
+ +formMessages()
+ .form-group
+ label(for='email') #{translate("email")}
+ input.form-control(
+ type='email',
+ name='email',
+ placeholder="email@example.com"
+ autocomplete="username"
+ required,
+ autofocus="true"
+ )
+ .form-group
+ label(for='password') #{translate("password")}
+ input.form-control#passwordField(
+ type='password',
+ name='password',
+ placeholder="********",
+ autocomplete="new-password"
+ required,
+ )
+ .actions
+ button.btn-primary.btn(
+ type='submit'
+ data-ol-disabled-inflight
+ )
+ span(data-ol-inflight="idle") #{translate("register")}
+ span(hidden data-ol-inflight="pending") #{translate("registering")}…
+
// Saml Form
if authMethod === 'saml'
h3 #{translate('saml')}
@@ -136,6 +175,35 @@ block content
data-ol-register-admin
action="/launchpad/register_saml_admin"
method="POST"
+ )
+ input(name='_csrf', type='hidden', value=csrfToken)
+ +formMessages()
+ .form-group
+ label(for='email') #{translate("email")}
+ input.form-control(
+ name='email',
+ placeholder="email@example.com"
+ autocomplete="username"
+ required,
+ autofocus="true"
+ )
+ .actions
+ button.btn-primary.btn(
+ type='submit'
+ data-ol-disabled-inflight
+ )
+ span(data-ol-inflight="idle") #{translate("register")}
+ span(hidden data-ol-inflight="pending") #{translate("registering")}…
+
+ h3 #{translate('local_account')}
+ p
+ | #{translate('alternatively_create_local_admin_account')}
+
+ form(
+ data-ol-async-form
+ data-ol-register-admin
+ action="/launchpad/register_admin"
+ method="POST"
)
input(name='_csrf', type='hidden', value=csrfToken)
+formMessages()
@@ -149,6 +217,15 @@ block content
required,
autofocus="true"
)
+ .form-group
+ label(for='password') #{translate("password")}
+ input.form-control#passwordField(
+ type='password',
+ name='password',
+ placeholder="********",
+ autocomplete="new-password"
+ required,
+ )
.actions
button.btn-primary.btn(
type='submit'
@@ -219,7 +296,7 @@ block content
p
a(href="/admin").btn.btn-info
| Go To Admin Panel
- |
+ p
a(href="/project").btn.btn-primary
| Start Using #{settings.appName}
br
diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs b/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs
new file mode 100644
index 0000000000..64fa4f5a96
--- /dev/null
+++ b/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs
@@ -0,0 +1,64 @@
+import logger from '@overleaf/logger'
+import LoginRateLimiter from '../../../../app/src/Features/Security/LoginRateLimiter.js'
+import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js'
+import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
+import AuthenticationManagerLdap from './AuthenticationManagerLdap.mjs'
+
+const AuthenticationControllerLdap = {
+ async doPassportLdapLogin(req, ldapUser, done) {
+ let user, info
+ try {
+ ;({ user, info } = await AuthenticationControllerLdap._doPassportLdapLogin(
+ req,
+ ldapUser
+ ))
+ } catch (error) {
+ return done(error)
+ }
+ return done(undefined, user, info)
+ },
+ async _doPassportLdapLogin(req, ldapUser) {
+ const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
+ const auditLog = {
+ ipAddress: req.ip,
+ info: { method: 'LDAP password login', fromKnownDevice },
+ }
+
+ let user, isPasswordReused
+ try {
+ user = await AuthenticationManagerLdap.promises.findOrCreateLdapUser(ldapUser, auditLog)
+ } catch (error) {
+ return {
+ user: false,
+ info: handleAuthenticateErrors(error, req),
+ }
+ }
+ if (user && AuthenticationController.captchaRequiredForLogin(req, user)) {
+ return {
+ user: false,
+ info: {
+ text: req.i18n.translate('cannot_verify_user_not_robot'),
+ type: 'error',
+ errorReason: 'cannot_verify_user_not_robot',
+ status: 400,
+ },
+ }
+ } else if (user) {
+ // async actions
+ return { user, info: undefined }
+ } else { //something wrong
+ logger.debug({ email : ldapUser.mail }, 'failed LDAP log in')
+ return {
+ user: false,
+ info: {
+ type: 'error',
+ status: 500,
+ },
+ }
+ }
+ },
+}
+
+export const {
+ doPassportLdapLogin,
+} = AuthenticationControllerLdap
diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs b/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs
new file mode 100644
index 0000000000..1371f76d52
--- /dev/null
+++ b/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs
@@ -0,0 +1,80 @@
+import Settings from '@overleaf/settings'
+import { callbackify } from '@overleaf/promise-utils'
+import UserCreator from '../../../../app/src/Features/User/UserCreator.js'
+import { User } from '../../../../app/src/models/User.js'
+
+const AuthenticationManagerLdap = {
+ splitFullName(fullName) {
+ fullName = fullName.trim();
+ let lastSpaceIndex = fullName.lastIndexOf(' ');
+ let firstNames = fullName.substring(0, lastSpaceIndex).trim();
+ let lastName = fullName.substring(lastSpaceIndex + 1).trim();
+ return [firstNames, lastName];
+ },
+ async findOrCreateLdapUser(profile, auditLog) {
+ //user is already authenticated in Ldap
+ const {
+ attEmail,
+ attFirstName,
+ attLastName,
+ attName,
+ attAdmin,
+ valAdmin,
+ updateUserDetailsOnLogin,
+ } = Settings.ldap
+
+ const email = Array.isArray(profile[attEmail])
+ ? profile[attEmail][0].toLowerCase()
+ : profile[attEmail].toLowerCase()
+ let nameParts = ["",""]
+ if ((!attFirstName || !attLastName) && attName) {
+ nameParts = this.splitFullName(profile[attName] || "")
+ }
+ const firstName = attFirstName ? (profile[attFirstName] || "") : nameParts[0]
+ let lastName = attLastName ? (profile[attLastName] || "") : nameParts[1]
+ if (!firstName && !lastName) lastName = email
+ let isAdmin = false
+ if( attAdmin && valAdmin ) {
+ isAdmin = (profile._groups?.length > 0) ||
+ (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) :
+ profile[attAdmin] === valAdmin)
+ }
+ let user = await User.findOne({ 'email': email }).exec()
+ if( !user ) {
+ user = await UserCreator.promises.createNewUser(
+ {
+ email: email,
+ first_name: firstName,
+ last_name: lastName,
+ isAdmin: isAdmin,
+ holdingAccount: false,
+ }
+ )
+ await User.updateOne(
+ { _id: user._id },
+ { $set : { 'emails.0.confirmedAt' : Date.now() } }
+ ).exec() //email of ldap user is confirmed
+ }
+ let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {}
+ if( attAdmin && valAdmin ) {
+ user.isAdmin = isAdmin
+ userDetails.isAdmin = isAdmin
+ }
+ const result = await User.updateOne(
+ { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails },
+ {}
+ ).exec()
+ if (result.modifiedCount !== 1) {
+ throw new ParallelLoginError()
+ }
+ return user
+ },
+}
+
+export default {
+ findOrCreateLdapUser: callbackify(AuthenticationManagerLdap.findOrCreateLdapUser),
+ promises: AuthenticationManagerLdap,
+}
+export const {
+ splitFullName,
+} = AuthenticationManagerLdap
diff --git a/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs b/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs
new file mode 100644
index 0000000000..e7f312fc11
--- /dev/null
+++ b/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs
@@ -0,0 +1,17 @@
+import Settings from '@overleaf/settings'
+
+function initLdapSettings() {
+ Settings.ldap = {
+ enable: true,
+ placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username',
+ attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail',
+ attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT,
+ attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT,
+ attName: process.env.OVERLEAF_LDAP_NAME_ATT,
+ attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT,
+ valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE,
+ updateUserDetailsOnLogin: String(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN ).toLowerCase() === 'true',
+ }
+}
+
+export default initLdapSettings
diff --git a/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs b/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs
new file mode 100644
index 0000000000..c4093b8684
--- /dev/null
+++ b/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs
@@ -0,0 +1,136 @@
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import passport from 'passport'
+import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js'
+import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
+import { splitFullName } from './AuthenticationManagerLdap.mjs'
+
+async function fetchLdapContacts(userId, contacts) {
+ if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) {
+ return []
+ }
+
+ const ldapOpts = passport._strategy('custom-fail-ldapauth').options.server
+ const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap
+ const {
+ url,
+ timeout,
+ connectTimeout,
+ tlsOptions,
+ starttls,
+ bindDN,
+ bindCredentials,
+ } = ldapOpts
+ const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOpts.searchBase
+ const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub'
+ const ldapConfig = { url, timeout, connectTimeout, tlsOptions }
+
+ let ldapUsers
+ const client = ldapjs.createClient(ldapConfig)
+ try {
+ if (starttls) {
+ await _upgradeToTLS(client, tlsOptions)
+ }
+ await _bindLdap(client, bindDN, bindCredentials)
+
+ const filter = await _formContactsSearchFilter(client, ldapOpts, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER)
+ const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter }
+
+ ldapUsers = await _searchLdap(client, searchBase, searchOptions)
+ } catch (err) {
+ logger.warn({ err }, 'error in fetchLdapContacts')
+ return []
+ } finally {
+ client.unbind()
+ }
+
+ const newLdapContacts = ldapUsers.reduce((acc, ldapUser) => {
+ const email = Array.isArray(ldapUser[attEmail])
+ ? ldapUser[attEmail][0]?.toLowerCase()
+ : ldapUser[attEmail]?.toLowerCase()
+ if (!email) return acc
+ if (!contacts.some(contact => contact.email === email)) {
+ let nameParts = ["",""]
+ if ((!attFirstName || !attLastName) && attName) {
+ nameParts = splitFullName(ldapUser[attName] || "")
+ }
+ const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0]
+ const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1]
+ acc.push({
+ first_name: firstName,
+ last_name: lastName,
+ email: email,
+ type: 'user',
+ })
+ }
+ return acc
+ }, [])
+
+ return newLdapContacts.sort((a, b) =>
+ a.last_name.localeCompare(b.last_name) ||
+ a.first_name.localeCompare(a.first_name) ||
+ a.email.localeCompare(b.email)
+ )
+}
+
+function _upgradeToTLS(client, tlsOptions) {
+ return new Promise((resolve, reject) => {
+ client.on('error', error => reject(new Error(`LDAP client error: ${error}`)))
+ client.on('connect', () => {
+ client.starttls(tlsOptions, null, error => {
+ if (error) {
+ reject(new Error(`StartTLS error: ${error}`))
+ } else {
+ resolve()
+ }
+ })
+ })
+ })
+}
+
+function _bindLdap(client, bindDN, bindCredentials) {
+ return new Promise((resolve, reject) => {
+ client.bind(bindDN, bindCredentials, error => {
+ if (error) {
+ reject(error)
+ } else {
+ resolve()
+ }
+ })
+ })
+}
+
+function _searchLdap(client, baseDN, options) {
+ return new Promise((resolve, reject) => {
+ const searchEntries = []
+ client.search(baseDN, options, (error, res) => {
+ if (error) {
+ reject(error)
+ } else {
+ res.on('searchEntry', entry => searchEntries.push(entry.object))
+ res.on('error', reject)
+ res.on('end', () => resolve(searchEntries))
+ }
+ })
+ })
+}
+
+async function _formContactsSearchFilter(client, ldapOpts, userId, contactsFilter) {
+ const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY
+ if (!searchProperty) {
+ return contactsFilter
+ }
+ const email = await UserGetter.promises.getUserEmail(userId)
+ const searchOptions = {
+ scope: ldapOpts.searchScope,
+ attributes: [searchProperty],
+ filter: `(${Settings.ldap.attEmail}=${email})`,
+ }
+ const searchBase = ldapOpts.searchBase
+ const ldapUser = (await _searchLdap(client, searchBase, searchOptions))[0]
+ const searchPropertyValue = ldapUser ? ldapUser[searchProperty]
+ : process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING'
+ return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue)
+}
+
+export default fetchLdapContacts
diff --git a/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs b/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs
new file mode 100644
index 0000000000..b07dc3f3bd
--- /dev/null
+++ b/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs
@@ -0,0 +1,78 @@
+import fs from 'fs'
+import passport from 'passport'
+import Settings from '@overleaf/settings'
+import { doPassportLdapLogin } from './AuthenticationControllerLdap.mjs'
+import { Strategy as LdapStrategy } from 'passport-ldapauth'
+
+function _readFilesContentFromEnv(envVar) {
+// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]'
+ if (!envVar) return undefined
+ try {
+ const parsedFileNames = JSON.parse(envVar)
+ return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8'))
+ } catch (error) {
+ if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name
+ return fs.readFileSync(envVar, 'utf8')
+ } else {
+ throw error
+ }
+ }
+}
+
+// custom responses on authentication failure
+class CustomFailLdapStrategy extends LdapStrategy {
+ constructor(options, validate) {
+ super(options, validate);
+ this.name = 'custom-fail-ldapauth'
+ }
+ authenticate(req, options) {
+ const defaultFail = this.fail.bind(this)
+ this.fail = function(info, status) {
+ info.type = 'error'
+ info.key = 'invalid-password-retry-or-reset'
+ info.status = 401
+ return defaultFail(info, status)
+ }.bind(this)
+ super.authenticate(req, options)
+ }
+}
+
+const ldapServerOpts = {
+ url: process.env.OVERLEAF_LDAP_URL,
+ bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "",
+ bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "",
+ bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY,
+ searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE,
+ searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER,
+ searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub',
+ searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'),
+ groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE,
+ groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER,
+ groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub',
+ groupSearchAttributes: ["dn"],
+ groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY,
+ cache: String(process.env.OVERLEAF_LDAP_CACHE).toLowerCase() === 'true',
+ timeout: process.env.OVERLEAF_LDAP_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_TIMEOUT) : undefined,
+ connectTimeout: process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT) : undefined,
+ starttls: String(process.env.OVERLEAF_LDAP_STARTTLS).toLowerCase() === 'true',
+ tlsOptions: {
+ ca: _readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH),
+ rejectUnauthorized: String(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH).toLowerCase() === 'true',
+ }
+}
+
+function addLdapStrategy(passport) {
+ passport.use(
+ new CustomFailLdapStrategy(
+ {
+ server: ldapServerOpts,
+ passReqToCallback: true,
+ usernameField: 'email',
+ passwordField: 'password',
+ },
+ doPassportLdapLogin
+ )
+ )
+}
+
+export default addLdapStrategy
diff --git a/services/web/modules/ldap-authentication/index.mjs b/services/web/modules/ldap-authentication/index.mjs
new file mode 100644
index 0000000000..f56d7ffee0
--- /dev/null
+++ b/services/web/modules/ldap-authentication/index.mjs
@@ -0,0 +1,30 @@
+import initLdapSettings from './app/src/InitLdapSettings.mjs'
+import addLdapStrategy from './app/src/LdapStrategy.mjs'
+import fetchLdapContacts from './app/src/LdapContacts.mjs'
+
+let ldapModule = {};
+if (process.env.EXTERNAL_AUTH === 'ldap') {
+ initLdapSettings()
+ ldapModule = {
+ name: 'ldap-authentication',
+ hooks: {
+ passportSetup: function (passport, callback) {
+ try {
+ addLdapStrategy(passport)
+ callback(null)
+ } catch (error) {
+ callback(error)
+ }
+ },
+ getContacts: async function (userId, contacts, callback) {
+ try {
+ const newLdapContacts = await fetchLdapContacts(userId, contacts)
+ callback(null, newLdapContacts)
+ } catch (error) {
+ callback(error)
+ }
+ },
+ }
+ }
+}
+export default ldapModule
diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs b/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs
new file mode 100644
index 0000000000..f5db3f738d
--- /dev/null
+++ b/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs
@@ -0,0 +1,160 @@
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import passport from 'passport'
+import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
+import AuthenticationManagerSaml from './AuthenticationManagerSaml.mjs'
+import UserController from '../../../../app/src/Features/User/UserController.js'
+import UserSessionsManager from '../../../../app/src/Features/User/UserSessionsManager.js'
+import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js'
+import { xmlResponse } from '../../../../app/src/infrastructure/Response.js'
+
+const AuthenticationControllerSaml = {
+ passportSamlAuthWithIdP(req, res, next) {
+ if ( passport._strategy('saml')._saml.options.authnRequestBinding === 'HTTP-POST') {
+ const csp = res.getHeader('Content-Security-Policy')
+ if (csp) {
+ res.setHeader(
+ 'Content-Security-Policy',
+ csp.replace(/(?:^|\s)(default-src|form-action)[^;]*;?/g, '')
+ )
+ }
+ }
+ passport.authenticate('saml')(req, res, next)
+ },
+ passportSamlLogin(req, res, next) {
+ // This function is middleware which wraps the passport.authenticate middleware,
+ // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
+ // and send a `{redir: ""}` response on success
+ passport.authenticate(
+ 'saml',
+ { keepSessionInfo: true },
+ async function (err, user, info) {
+ if (err) {
+ return next(err)
+ }
+ if (user) {
+ // `user` is either a user object or false
+ AuthenticationController.setAuditInfo(req, {
+ method: 'SAML login',
+ })
+ try {
+ await AuthenticationController.promises.finishLogin(user, req, res)
+ } catch (err) {
+ return next(err)
+ }
+ } else {
+ if (info.redir != null) {
+ return res.json({ redir: info.redir })
+ } else {
+ res.status(info.status || 200)
+ delete info.status
+ const body = { message: info }
+ const { errorReason } = info
+ if (errorReason) {
+ body.errorReason = errorReason
+ delete info.errorReason
+ }
+ return res.json(body)
+ }
+ }
+ }
+ )(req, res, next)
+ },
+ async doPassportSamlLogin(req, profile, done) {
+ let user, info
+ try {
+ ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogin(
+ req,
+ profile
+ ))
+ } catch (error) {
+ return done(error)
+ }
+ return done(undefined, user, info)
+ },
+ async _doPassportSamlLogin(req, profile) {
+ const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
+ const auditLog = {
+ ipAddress: req.ip,
+ info: { method: 'SAML login', fromKnownDevice },
+ }
+
+ let user
+ try {
+ user = await AuthenticationManagerSaml.promises.findOrCreateSamlUser(profile, auditLog)
+ } catch (error) {
+ return {
+ user: false,
+ info: handleAuthenticateErrors(error, req),
+ }
+ }
+ if (user) {
+ req.session.saml_extce = {nameID : profile.nameID, sessionIndex : profile.sessionIndex}
+ return { user, info: undefined }
+ } else { //something wrong
+ logger.debug({ email : profile.mail }, 'failed SAML log in')
+ return {
+ user: false,
+ info: {
+ type: 'error',
+ text: 'Unknown error',
+ status: 500,
+ },
+ }
+ }
+ },
+ async passportSamlSPLogout(req, res, next) {
+ passport._strategy('saml').logout(req, async (err, url) => {
+ if (err) logger.error({ err }, 'can not generate logout url')
+ await UserController.promises.doLogout(req)
+ res.redirect(url)
+ })
+ },
+ passportSamlIdPLogout(req, res, next) {
+ passport.authenticate('saml')(req, res, (err) => {
+ if (err) return next(err)
+ res.redirect('/login');
+ })
+ },
+ async doPassportSamlLogout(req, profile, done) {
+ let user, info
+ try {
+ ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogout(
+ req,
+ profile
+ ))
+ } catch (error) {
+ return done(error)
+ }
+ return done(undefined, user, info)
+ },
+ async _doPassportSamlLogout(req, profile) {
+ if (req?.session?.saml_extce?.nameID === profile.nameID &&
+ req?.session?.saml_extce?.sessionIndex === profile.sessionIndex) {
+ profile = req.user
+ }
+ await UserSessionsManager.promises.untrackSession(req.user, req.sessionID).catch(err => {
+ logger.warn({ err, userId: req.user._id }, 'failed to untrack session')
+ })
+ return { user: profile, info: undefined }
+ },
+ passportSamlMetadata(req, res) {
+ const samlStratery = passport._strategy('saml')
+ res.setHeader('Content-Disposition', `attachment; filename="${samlStratery._saml.options.issuer}-meta.xml"`)
+ xmlResponse(res,
+ samlStratery.generateServiceProviderMetadata(
+ samlStratery._saml.options.decryptionCert,
+ samlStratery._saml.options.signingCert
+ )
+ )
+ },
+}
+export const {
+ passportSamlAuthWithIdP,
+ passportSamlLogin,
+ passportSamlSPLogout,
+ passportSamlIdPLogout,
+ doPassportSamlLogin,
+ doPassportSamlLogout,
+ passportSamlMetadata,
+} = AuthenticationControllerSaml
diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs b/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs
new file mode 100644
index 0000000000..47d97f3019
--- /dev/null
+++ b/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs
@@ -0,0 +1,60 @@
+import Settings from '@overleaf/settings'
+import UserCreator from '../../../../app/src/Features/User/UserCreator.js'
+import { User } from '../../../../app/src/models/User.js'
+
+const AuthenticationManagerSaml = {
+ async findOrCreateSamlUser(profile, auditLog) {
+ const {
+ attEmail,
+ attFirstName,
+ attLastName,
+ attAdmin,
+ valAdmin,
+ updateUserDetailsOnLogin,
+ } = Settings.saml
+ const email = Array.isArray(profile[attEmail])
+ ? profile[attEmail][0].toLowerCase()
+ : profile[attEmail].toLowerCase()
+ const firstName = attFirstName ? profile[attFirstName] : ""
+ const lastName = attLastName ? profile[attLastName] : email
+ let isAdmin = false
+ if( attAdmin && valAdmin ) {
+ isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) :
+ profile[attAdmin] === valAdmin)
+ }
+ let user = await User.findOne({ 'email': email }).exec()
+ if( !user ) {
+ user = await UserCreator.promises.createNewUser(
+ {
+ email: email,
+ first_name: firstName,
+ last_name: lastName,
+ isAdmin: isAdmin,
+ holdingAccount: false,
+ }
+ )
+ await User.updateOne(
+ { _id: user._id },
+ { $set : { 'emails.0.confirmedAt' : Date.now() } }
+ ).exec() //email of saml user is confirmed
+ }
+ let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {}
+ if( attAdmin && valAdmin ) {
+ user.isAdmin = isAdmin
+ userDetails.isAdmin = isAdmin
+ }
+ const result = await User.updateOne(
+ { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails },
+ {}
+ ).exec()
+
+ if (result.modifiedCount !== 1) {
+ throw new ParallelLoginError()
+ }
+ return user
+ },
+}
+
+export default {
+ promises: AuthenticationManagerSaml,
+}
diff --git a/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs b/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs
new file mode 100644
index 0000000000..441f9033af
--- /dev/null
+++ b/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs
@@ -0,0 +1,16 @@
+import Settings from '@overleaf/settings'
+
+function initSamlSettings() {
+ Settings.saml = {
+ enable: true,
+ identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Login with SAML IdP',
+ attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID',
+ attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName',
+ attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName',
+ attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD,
+ valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE,
+ updateUserDetailsOnLogin: String(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN).toLowerCase() === 'true',
+ }
+}
+
+export default initSamlSettings
diff --git a/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs
new file mode 100644
index 0000000000..65b42c92ae
--- /dev/null
+++ b/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs
@@ -0,0 +1,12 @@
+import logger from '@overleaf/logger'
+import { passportSamlLogin, passportSamlIdPLogout } from './AuthenticationControllerSaml.mjs'
+
+export default {
+ apply(webRouter) {
+ logger.debug({}, 'Init SAML NonCsrfRouter')
+ webRouter.get('/saml/login/callback', passportSamlLogin)
+ webRouter.post('/saml/login/callback', passportSamlLogin)
+ webRouter.get('/saml/logout/callback', passportSamlIdPLogout)
+ webRouter.post('/saml/logout/callback', passportSamlIdPLogout)
+ },
+}
diff --git a/services/web/modules/saml-authentication/app/src/SamlRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlRouter.mjs
new file mode 100644
index 0000000000..9ee3677901
--- /dev/null
+++ b/services/web/modules/saml-authentication/app/src/SamlRouter.mjs
@@ -0,0 +1,14 @@
+import logger from '@overleaf/logger'
+import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
+import { passportSamlAuthWithIdP, passportSamlSPLogout, passportSamlMetadata} from './AuthenticationControllerSaml.mjs'
+
+export default {
+ apply(webRouter) {
+ logger.debug({}, 'Init SAML router')
+ webRouter.get('/saml/login', passportSamlAuthWithIdP)
+ AuthenticationController.addEndpointToLoginWhitelist('/saml/login')
+ webRouter.post('/saml/logout', AuthenticationController.requireLogin(), passportSamlSPLogout)
+ webRouter.get('/saml/meta', passportSamlMetadata)
+ AuthenticationController.addEndpointToLoginWhitelist('/saml/meta')
+ },
+}
diff --git a/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs b/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs
new file mode 100644
index 0000000000..3a16459f98
--- /dev/null
+++ b/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs
@@ -0,0 +1,62 @@
+import fs from 'fs'
+import passport from 'passport'
+import Settings from '@overleaf/settings'
+import { doPassportSamlLogin, doPassportSamlLogout } from './AuthenticationControllerSaml.mjs'
+import { Strategy as SamlStrategy } from '@node-saml/passport-saml'
+
+function _readFilesContentFromEnv(envVar) {
+// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]'
+ if (!envVar) return undefined
+ try {
+ const parsedFileNames = JSON.parse(envVar)
+ return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8'))
+ } catch (error) {
+ if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name
+ return fs.readFileSync(envVar, 'utf8')
+ } else {
+ throw error
+ }
+ }
+}
+
+const samlOptions = {
+ entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT,
+ callbackUrl: process.env.OVERLEAF_SAML_CALLBACK_URL,
+ issuer: process.env.OVERLEAF_SAML_ISSUER,
+ audience: process.env.OVERLEAF_SAML_AUDIENCE,
+ cert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT),
+ signingCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT),
+ privateKey: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY),
+ decryptionCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT),
+ decryptionPvk: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK),
+ signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM,
+ additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'),
+ additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'),
+ identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT,
+ acceptedClockSkewMs: process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS ? Number(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS) : undefined,
+ attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX,
+ authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined,
+ forceAuthn: String(process.env.OVERLEAF_SAML_FORCE_AUTHN).toLowerCase() === 'true',
+ disableRequestedAuthnContext: String(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT).toLowerCase() === 'true',
+ skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST
+ authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING,
+ validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO,
+ requestIdExpirationPeriodMs: process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS ? Number(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS) : undefined,
+// cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER,
+ logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL,
+ logoutCallbackUrl: process.env.OVERLEAF_SAML_LOGOUT_CALLBACK_URL,
+ additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'),
+ passReqToCallback: true,
+}
+
+function addSamlStrategy(passport) {
+ passport.use(
+ new SamlStrategy(
+ samlOptions,
+ doPassportSamlLogin,
+ doPassportSamlLogout
+ )
+ )
+}
+
+export default addSamlStrategy
diff --git a/services/web/modules/saml-authentication/index.mjs b/services/web/modules/saml-authentication/index.mjs
new file mode 100644
index 0000000000..35ea70283f
--- /dev/null
+++ b/services/web/modules/saml-authentication/index.mjs
@@ -0,0 +1,26 @@
+import initSamlSettings from './app/src/InitSamlSettings.mjs'
+import addSamlStrategy from './app/src/SamlStrategy.mjs'
+import SamlRouter from './app/src/SamlRouter.mjs'
+import SamlNonCsrfRouter from './app/src/SamlNonCsrfRouter.mjs'
+
+let samlModule = {};
+
+if (process.env.EXTERNAL_AUTH === 'saml') {
+ initSamlSettings()
+ samlModule = {
+ name: 'saml-authentication',
+ hooks: {
+ passportSetup: function (passport, callback) {
+ try {
+ addSamlStrategy(passport)
+ callback(null)
+ } catch (error) {
+ callback(error)
+ }
+ },
+ },
+ router: SamlRouter,
+ nonCsrfRouter: SamlNonCsrfRouter,
+ }
+}
+export default samlModule
From 1e407e34e5ec41da9714b0deaf09707fbca47f39 Mon Sep 17 00:00:00 2001
From: yu-i-i
Date: Mon, 9 Dec 2024 06:18:51 +0100
Subject: [PATCH 08/11] Update README.md
---
README.md | 675 +++++++++++++++++++++++++++++++++++++++++++--
doc/screenshot.png | Bin 601370 -> 1080606 bytes
2 files changed, 659 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index 7e15159eb9..2dd7ab63d0 100644
--- a/README.md
+++ b/README.md
@@ -14,30 +14,676 @@
License
-
+
- Figure 1: A screenshot of a project being edited in Overleaf Community Edition.
+ Figure 1: A screenshot of a project being edited in Overleaf Extended Community Edition.
## Community Edition
-[Overleaf](https://www.overleaf.com) is an open-source online real-time collaborative LaTeX editor. We run a hosted version at [www.overleaf.com](https://www.overleaf.com), but you can also run your own local version, and contribute to the development of Overleaf.
+[Overleaf](https://www.overleaf.com) is an open-source online real-time collaborative LaTeX editor. Overleaf runs a hosted version at [www.overleaf.com](https://www.overleaf.com), but you can also run your own local version, and contribute to the development of Overleaf.
+
+## Extended Community Edition
+
+The present "extended" version of Overleaf CE includes:
+
+- Sandboxed Compiles with TeX Live image selection
+- LDAP authentication
+- SAML authentication
+- Real-time track changes and comments
+- Autocomplete of reference keys
+- Symbol Palette
## Enterprise
-If you want help installing and maintaining Overleaf in your lab or workplace, we offer an officially supported version called [Overleaf Server Pro](https://www.overleaf.com/for/enterprises). It also includes more features for security (SSO with LDAP or SAML), administration and collaboration (e.g. tracked changes). [Find out more!](https://www.overleaf.com/for/enterprises)
-
-## Keeping up to date
-
-Sign up to the [mailing list](https://mailchi.mp/overleaf.com/community-edition-and-server-pro) to get updates on Overleaf releases and development.
+If you want help installing and maintaining Overleaf in your lab or workplace, Overleaf offers an officially supported version called [Overleaf Server Pro](https://www.overleaf.com/for/enterprises).
## Installation
-We have detailed installation instructions in the [Overleaf Toolkit](https://github.com/overleaf/toolkit/).
+Detailed installation instructions can be found in the [Overleaf Toolkit](https://github.com/overleaf/toolkit/).
+To run a custom image, add a file named docker-compose.override.yml with the following or similar content into the `overleaf-toolkit/config directory`:
-## Upgrading
+```yml
+---
+version: '2.2'
+services:
+ sharelatex:
+ image: sharelatex/sharelatex:ext-ce
+ volumes:
+ - ../config/certs:/overleaf/certs
+```
+Here, the attached volume provides convenient access for the container to the certificates needed for SAML or LDAP authentication.
-If you are upgrading from a previous version of Overleaf, please see the [Release Notes section on the Wiki](https://github.com/overleaf/overleaf/wiki#release-notes) for all of the versions between your current version and the version you are upgrading to.
+If you want to build a Docker image of the extended CE based on the upstream v5.2.1 codebase, you can check out the corresponding tag by running:
+```
+git checkout v5.2.1-ext
+```
+Alternatively, you can download a prebuilt image from Docker Hub:
+```
+docker pull overleafcep/sharelatex:5.2.1-ext
+```
+Make sure to update the image name in overleaf-toolkit/config/docker-compose.override.yml accordingly.
+
+## Sandboxed Compiles
+
+To enable sandboxed compiles (also known as "Sibling containers"), set the following configuration options in `overleaf-toolkit/config/overleaf.rc`:
+
+```
+SERVER_PRO=true
+SIBLING_CONTAINERS_ENABLED=true
+```
+
+The following environment variables are used to specify which TeX Live images to use for sandboxed compiles:
+
+- `ALL_TEX_LIVE_DOCKER_IMAGES` **(required)**
+ * A comma-separated list of TeX Live images to use. These images will be downloaded or updated.
+ To skip downloading the images, set `SIBLING_CONTAINERS_PULL=false` in `config/overleaf.rc`.
+- `ALL_TEX_LIVE_DOCKER_IMAGE_NAMES`
+ * A comma-separated list of friendly names for the images. If omitted, the version name will be used (e.g., `latest-full`).
+- `TEX_LIVE_DOCKER_IMAGE` **(required)**
+ * The default TeX Live image that will be used for compiling new projects. The environment variable `ALL_TEX_LIVE_DOCKER_IMAGES` must include this image.
+
+Users can select the image for their project in the project menu.
+
+Here is an example where the default TeX Live image is `latest-full` from Docker Hub, but the `TL2023-historic` image can be used for older projects:
+```
+ALL_TEX_LIVE_DOCKER_IMAGES=texlive/texlive:latest-full, texlive/texlive:TL2023-historic
+ALL_TEX_LIVE_DOCKER_IMAGE_NAMES=TeXLive 2024, TeXLive 2023
+TEX_LIVE_DOCKER_IMAGE=texlive/texlive:latest-full
+```
+For additional details refer to
+[Server Pro: Sandboxed Compiles](https://github.com/overleaf/overleaf/wiki/Server-Pro:-Sandboxed-Compiles) and
+[Toolkit: Sandboxed Compiles](https://github.com/overleaf/toolkit/blob/master/doc/sandboxed-compiles.md).
+
+
+Sample variables.env file
+
+```
+OVERLEAF_APP_NAME="Our Overleaf Instance"
+
+ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
+
+# Enables Thumbnail generation using ImageMagick
+ENABLE_CONVERSIONS=true
+
+# Disables email confirmation requirement
+EMAIL_CONFIRMATION_DISABLED=true
+
+## Nginx
+# NGINX_WORKER_PROCESSES=4
+# NGINX_WORKER_CONNECTIONS=768
+
+## Set for TLS via nginx-proxy
+# OVERLEAF_BEHIND_PROXY=true
+# OVERLEAF_SECURE_COOKIE=true
+
+OVERLEAF_SITE_URL=http://my-overleaf-instance.com
+OVERLEAF_NAV_TITLE=Our Overleaf Instance
+# OVERLEAF_HEADER_IMAGE_URL=http://somewhere.com/mylogo.png
+OVERLEAF_ADMIN_EMAIL=support@example.com
+
+OVERLEAF_LEFT_FOOTER=[{"text": "Contact your support team", "url": "mailto:support@example.com"}]
+OVERLEAF_RIGHT_FOOTER=[{"text":"Hello, I am on the Right", "url":"https://github.com/yu-i-i/overleaf-cep"}]
+
+OVERLEAF_EMAIL_FROM_ADDRESS=team@example.com
+OVERLEAF_EMAIL_SMTP_HOST=smtp.example.com
+OVERLEAF_EMAIL_SMTP_PORT=587
+OVERLEAF_EMAIL_SMTP_SECURE=false
+# OVERLEAF_EMAIL_SMTP_USER=
+# OVERLEAF_EMAIL_SMTP_PASS=
+# OVERLEAF_EMAIL_SMTP_NAME=
+OVERLEAF_EMAIL_SMTP_LOGGER=false
+OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH=true
+OVERLEAF_EMAIL_SMTP_IGNORE_TLS=false
+OVERLEAF_CUSTOM_EMAIL_FOOTER=This system is run by department x
+
+OVERLEAF_PROXY_LEARN=true
+NAV_HIDE_POWERED_BY=true
+
+########################
+## Sandboxed Compiles ##
+########################
+
+ALL_TEX_LIVE_DOCKER_IMAGES=texlive/texlive:latest-full, texlive/texlive:TL2023-historic
+ALL_TEX_LIVE_DOCKER_IMAGE_NAMES=TeXLive 2024, TeXLive 2023
+TEX_LIVE_DOCKER_IMAGE=texlive/texlive:latest-full
+```
+
+
+## Authentication Methods
+
+The following authentication methods are supported: Local authentication, LDAP authentication, and SAML authentication. Local authentication is always active.
+To enable LDAP or SAML authentication, the environment variable `EXTERNAL_AUTH` must be set to `ldap` or `saml`, respectively.
+
+
+Local Authentication
+
+Password of local users stored in the MongoDB database. An admin user can create a new local user. For details, visit the
+[wiki of Overleaf project](https://github.com/overleaf/overleaf/wiki/Creating-and-managing-users).
+
+It is possible to enforce password restrictions on local users:
+
+
+* `OVERLEAF_PASSWORD_VALIDATION_MIN_LENGTH`: The minimum length required
+
+* `OVERLEAF_PASSWORD_VALIDATION_MAX_LENGTH`: The maximum length allowed
+
+* `OVERLEAF_PASSWORD_VALIDATION_PATTERN`: is used to validate password strength
+
+ - `abc123` – password requires 3 letters and 3 numbers and be at least 6 characters long
+ - `aA` – password requires lower and uppercase letters and be at least 2 characters long
+ - `ab$3` – it must contain letters, digits and symbols and be at least 4 characters long
+ - There are 4 groups of characters: letters, UPPERcase letters, digits, symbols. Anything that is neither a letter nor a digit is considered to be a symbol.
+
+
+
+
+LDAP Authentication
+
+Internally, Overleaf LDAP uses the [passport-ldapauth](https://github.com/vesse/passport-ldapauth) library. Most of these configuration options are passed through to the `server` config object which is used to configure `passport-ldapauth`. If you are having issues configuring LDAP, it is worth reading the README for `passport-ldapauth` to understand the configuration it expects.
+
+#### Environment Variables
+
+- `OVERLEAF_LDAP_URL` **(required)**
+ * URL of the LDAP server.
+
+ - Example: `ldaps://ldap.example.com:636` (LDAP over SSL)
+ - Example: `ldap://ldap.example.com:389` (unencrypted or STARTTLS, if configured).
+
+- `OVERLEAF_LDAP_EMAIL_ATT`
+ * The email attribute returned by the LDAP server, default `mail`. Each LDAP user must have at least one email address.
+ If multiple addresses are provided, only the first one will be used.
+
+- `OVERLEAF_LDAP_FIRST_NAME_ATT`
+ * The property name holding the first name of the user which is used in the application, usually `givenName`.
+
+- `OVERLEAF_LDAP_LAST_NAME_ATT`
+ * The property name holding the family name of the user which is used in the application, usually `sn`.
+
+- `OVERLEAF_LDAP_NAME_ATT`
+ * The property name holding the full name of the user, usually `cn`. If either of the two previous variables is not defined,
+ the first and/or last name of the user is extracted from this variable. Otherwise, it is not used.
+
+- `OVERLEAF_LDAP_PLACEHOLDER`
+ * The placeholder for the login form, defaults to `Username`.
+
+- `OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN`
+ * If set to `true`, updates the LDAP user `first_name` and `last_name` field on login, and turn off the user details form on the `/user/settings`
+ page for LDAP users. Otherwise, details will be fetched only on first login.
+
+- `OVERLEAF_LDAP_BIND_DN`
+ * The distinguished name of the LDAP user that should be used for the LDAP connection
+ (this user should be able to search/list accounts on the LDAP server),
+ e.g., `cn=ldap_reader,dc=example,dc=com`. If not defined, anonymous binding is used.
+
+- `OVERLEAF_LDAP_BIND_CREDENTIALS`
+ * Password for `OVERLEAF_LDAP_BIND_DN`.
+
+- `OVERLEAF_LDAP_BIND_PROPERTY`
+ * Property of the user to bind against the client, defaults to `dn`.
+
+- `OVERLEAF_LDAP_SEARCH_BASE` **(required)**
+ * The base DN from which to search for users. E.g., `ou=people,dc=example,dc=com`.
+
+- `OVERLEAF_LDAP_SEARCH_FILTER`
+ * LDAP search filter with which to find a user. Use the literal '{{username}}' to have the given username be interpolated in for the LDAP search.
+
+ - Example: `(|(uid={{username}})(mail={{username}}))` (user can login with email or with login name).
+ - Example: `(sAMAccountName={{username}})` (Active Directory).
+
+- `OVERLEAF_LDAP_SEARCH_SCOPE`
+ * The scope of the search can be `base`, `one`, or `sub` (default).
+
+- `OVERLEAF_LDAP_SEARCH_ATTRIBUTES`
+ * JSON array of attributes to fetch from the LDAP server, e.g., `["uid", "mail", "givenName", "sn"]`.
+ By default, all attributes are fetched.
+
+- `OVERLEAF_LDAP_STARTTLS`
+ * If `true`, LDAP over TLS is used.
+
+- `OVERLEAF_LDAP_TLS_OPTS_CA_PATH`
+ * Path to the file containing the CA certificate used to verify the LDAP server's SSL/TLS certificate. If there are multiple certificates, then
+ it can be a JSON array of paths to the certificates. The files must be accessible to the docker container.
+
+ - Example (one certificate): `/overleaf/certs/ldap_ca_cert.pem`
+ - Example (multiple certificates): `["/overleaf/certs/ldap_ca_cert1.pem", "/overleaf/certs/ldap_ca_cert2.pem"]`
+
+- `OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH`
+ * If `true`, the server certificate is verified against the list of supplied CAs.
+
+- `OVERLEAF_LDAP_CACHE`
+ * If `true`, then up to 100 credentials at a time will be cached for 5 minutes.
+
+- `OVERLEAF_LDAP_TIMEOUT`
+ * How long the client should let operations live for before timing out, ms (Default: Infinity).
+
+- `OVERLEAF_LDAP_CONNECT_TIMEOUT`
+ * How long the client should wait before timing out on TCP connections, ms (Default: OS default).
+
+- `OVERLEAF_LDAP_IS_ADMIN_ATT` and `OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE`
+ * When both environment variables are set, the login process updates `user.isAdmin = true` if the LDAP profile contains the attribute specified by
+ `OVERLEAF_LDAP_IS_ADMIN_ATT` and its value either matches `OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE` or is an array containing `OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE`,
+ otherwise `user.isAdmin` is set to `false`. If either of these variables is not set, then the admin status is only set to `true` during admin user
+ creation in Launchpad.
+
+The following five variables are used to configure how user contacts are retrieved from the LDAP server.
+
+- `OVERLEAF_LDAP_CONTACTS_FILTER`
+ * The filter used to search for users in the LDAP server to be loaded into contacts. The placeholder '{{userProperty}}' within the filter is replaced with the value of
+ the property specified by `OVERLEAF_LDAP_CONTACTS_PROPERTY` from the LDAP user initiating the search. If not defined, no users are retrieved from the LDAP server into contacts.
+
+- `OVERLEAF_LDAP_CONTACTS_SEARCH_BASE`
+ * Specifies the base DN from which to start searching for the contacts. Defaults to `OVERLEAF_LDAP_SEARCH_BASE`.
+
+- `OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE`
+ * The scope of the search can be `base`, `one`, or `sub` (default).
+
+- `OVERLEAF_LDAP_CONTACTS_PROPERTY`
+ * Specifies the property of the user object that will replace the '{{userProperty}}' placeholder in the `OVERLEAF_LDAP_CONTACTS_FILTER`.
+
+- `OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE`
+ * Specifies the value of the `OVERLEAF_LDAP_CONTACTS_PROPERTY` if the search is initiated by a non-LDAP user. If this variable is not defined, the resulting filter
+ will match nothing. The value `*` can be used as a wildcard.
+
+
+Example
+
+ OVERLEAF_LDAP_CONTACTS_FILTER=(gidNumber={{userProperty}})
+ OVERLEAF_LDAP_CONTACTS_PROPERTY=gidNumber
+ OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE=1000
+
+The above example results in loading into the contacts of the current LDAP user all LDAP users who have the same UNIX `gid`. Non-LDAP users will have all LDAP users with UNIX `gid=1000` in their contacts.
+
+
+
+
+
+Sample variables.env file
+
+```
+OVERLEAF_APP_NAME="Our Overleaf Instance"
+
+ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
+
+# Enables Thumbnail generation using ImageMagick
+ENABLE_CONVERSIONS=true
+
+# Disables email confirmation requirement
+EMAIL_CONFIRMATION_DISABLED=true
+
+## Nginx
+# NGINX_WORKER_PROCESSES=4
+# NGINX_WORKER_CONNECTIONS=768
+
+## Set for TLS via nginx-proxy
+# OVERLEAF_BEHIND_PROXY=true
+# OVERLEAF_SECURE_COOKIE=true
+
+OVERLEAF_SITE_URL=http://my-overleaf-instance.com
+OVERLEAF_NAV_TITLE=Our Overleaf Instance
+# OVERLEAF_HEADER_IMAGE_URL=http://somewhere.com/mylogo.png
+OVERLEAF_ADMIN_EMAIL=support@example.com
+
+OVERLEAF_LEFT_FOOTER=[{"text": "Contact your support team", "url": "mailto:support@example.com"}]
+OVERLEAF_RIGHT_FOOTER=[{"text":"Hello, I am on the Right", "url":"https://github.com/yu-i-i/overleaf-cep"}]
+
+OVERLEAF_EMAIL_FROM_ADDRESS=team@example.com
+OVERLEAF_EMAIL_SMTP_HOST=smtp.example.com
+OVERLEAF_EMAIL_SMTP_PORT=587
+OVERLEAF_EMAIL_SMTP_SECURE=false
+# OVERLEAF_EMAIL_SMTP_USER=
+# OVERLEAF_EMAIL_SMTP_PASS=
+# OVERLEAF_EMAIL_SMTP_NAME=
+OVERLEAF_EMAIL_SMTP_LOGGER=false
+OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH=true
+OVERLEAF_EMAIL_SMTP_IGNORE_TLS=false
+OVERLEAF_CUSTOM_EMAIL_FOOTER=This system is run by department x
+
+OVERLEAF_PROXY_LEARN=true
+NAV_HIDE_POWERED_BY=true
+
+#################
+## LDAP for CE ##
+#################
+
+EXTERNAL_AUTH=ldap
+OVERLEAF_LDAP_URL=ldap://ldap.example.com:389
+OVERLEAF_LDAP_STARTTLS=true
+OVERLEAF_LDAP_TLS_OPTS_CA_PATH=/overleaf/certs/ldap_ca_cert.pem
+OVERLEAF_LDAP_SEARCH_BASE=ou=people,dc=example,dc=com
+OVERLEAF_LDAP_SEARCH_FILTER=(|(uid={{username}})(mail={{username}}))
+OVERLEAF_LDAP_BIND_DN=cn=ldap_reader,dc=example,dc=com
+OVERLEAF_LDAP_BIND_CREDENTIALS=GoodNewsEveryone
+OVERLEAF_LDAP_EMAIL_ATT=mail
+OVERLEAF_LDAP_FIRST_NAME_ATT=givenName
+OVERLEAF_LDAP_LAST_NAME_ATT=sn
+# OVERLEAF_LDAP_NAME_ATT=cn
+OVERLEAF_LDAP_SEARCH_ATTRIBUTES=["uid", "sn", "givenName", "mail"]
+
+OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN=true
+
+OVERLEAF_LDAP_PLACEHOLDER='Username or email address'
+
+OVERLEAF_LDAP_IS_ADMIN_ATT=mail
+OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE=admin@example.com
+
+OVERLEAF_LDAP_CONTACTS_FILTER=(gidNumber={{userProperty}})
+OVERLEAF_LDAP_CONTACTS_PROPERTY=gidNumber
+OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE='*'
+```
+
+
+
+Deprecated variables
+
+**These variables will be removed soon**, use `OVERLEAF_LDAP_IS_ADMIN_ATT` and `OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE` instead.
+
+The following variables are used to determine if the user has admin rights.
+Please note: the user gains admin status if the search result is not empty, not when the user is explicitly included in the search results.
+
+- `OVERLEAF_LDAP_ADMIN_SEARCH_BASE`
+ * Specifies the base DN from which to start searching for the admin group. If this variable is defined,
+ `OVERLEAF_LDAP_ADMIN_SEARCH_FILTER` must also be defined for the search to function properly.
+
+- `OVERLEAF_LDAP_ADMIN_SEARCH_FILTER`
+ * Defines the LDAP search filter used to identify the admin group. The placeholder `{{dn}}` within the filter
+ is replaced with the value of the property specified by `OVERLEAF_LDAP_ADMIN_DN_PROPERTY`. The placeholder `{{username}}` is also supported.
+
+- `OVERLEAF_LDAP_ADMIN_DN_PROPERTY`
+ * Specifies the property of the user object that will replace the '{{dn}}' placeholder
+ in the `OVERLEAF_LDAP_ADMIN_SEARCH_FILTER`, defaults to `dn`.
+
+- `OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE`
+ * The scope of the LDAP search can be `base`, `one`, or `sub` (default)
+
+
+Example
+
+In the following example admins are members of a group `admins`, the objectClass of the entry `admins` is `groupOfNames`:
+
+ OVERLEAF_LDAP_ADMIN_SEARCH_BASE='cn=admins,ou=group,dc=example,dc=com'
+ OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(member={{dn}})'
+
+In the following example admins are members of a group 'admins', the objectClass of the entry `admins` is `posixGroup`:
+
+ OVERLEAF_LDAP_ADMIN_SEARCH_BASE='cn=admins,ou=group,dc=example,dc=com'
+ OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(memberUid={{username}})'
+
+In the following example admins are users with UNIX gid=1234:
+
+ OVERLEAF_LDAP_ADMIN_SEARCH_BASE='ou=people,dc=example,dc=com'
+ OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(&(gidNumber=1234)(uid={{username}}))'
+
+In the following example admin is the user with `uid=someuser`:
+
+ OVERLEAF_LDAP_ADMIN_SEARCH_BASE='ou=people,dc=example,dc=com'
+ OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(&(uid=someuser)(uid={{username}}))'
+
+The filter
+
+ OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(uid=someuser)'
+
+where `someuser` is the uid of an existing user, will always produce a non-empty search result.
+As a result, **every user will be granted admin rights**, not just `someuser`, as one might expect.
+
+
+
+
+
+
+SAML Authentication
+
+Internally, Overleaf SAML module uses the [passport-saml](https://github.com/node-saml/passport-saml) library, most of the following
+configuration options are passed through to `passport-saml`. If you are having issues configuring SAML, it is worth reading the README
+for `passport-saml` to get a feel for the configuration it expects.
+
+#### Environment Variables
+
+- `OVERLEAF_SAML_IDENTITY_SERVICE_NAME`
+ * Display name for the identity service, used on the login page (default: `Login with SAML IdP`).
+
+- `OVERLEAF_SAML_EMAIL_FIELD`
+ * Name of the Email field in user profile, default to 'nameID'.
+
+- `OVERLEAF_SAML_FIRST_NAME_FIELD`
+ * Name of the firstName field in user profile, default to 'givenName'.
+
+- `OVERLEAF_SAML_LAST_NAME_FIELD`
+ * Name of the lastName field in user profile, default to 'lastName'
+
+- `OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN`
+ * If set to `true`, updates the user `first_name` and `last_name` field on login,
+ and turn off the user details form on `/user/settings` page.
+
+- `OVERLEAF_SAML_ENTRYPOINT` **(required)**
+ * Entrypoint URL for the SAML identity service.
+
+ - Example: `https://idp.example.com/simplesaml/saml2/idp/SSOService.php`
+ - Azure Example: `https://login.microsoftonline.com/8b26b46a-6dd3-45c7-a104-f883f4db1f6b/saml2`
+
+- `OVERLEAF_SAML_CALLBACK_URL` **(required)**
+ * Callback URL for Overleaf service. Should be the full URL of the `/saml/login/callback` path.
+
+ - Example: `https://my-overleaf-instance.com/saml/login/callback`
+
+- `OVERLEAF_SAML_ISSUER` **(required)**
+ * The Issuer name.
+
+- `OVERLEAF_SAML_AUDIENCE`
+ * Expected saml response Audience, defaults to value of `OVERLEAF_SAML_ISSUER`.
+
+- `OVERLEAF_SAML_IDP_CERT` **(required)**
+ * Path to a file containing the Identity Provider's public certificate, used to validate the signatures of incoming SAML responses. If the Identity Provider has multiple valid signing certificates, then
+ it can be a JSON array of paths to the certificates.
+
+ - Example (one certificate): `/overleaf/certs/idp_cert.pem`
+ - Example (multiple certificates): `["/overleaf/certs/idp_cert.pem", "/overleaf/certs/idp_cert_old.pem"]`
+
+- `OVERLEAF_SAML_PUBLIC_CERT`
+ * Path to a file containing public signing certificate used to embed in auth requests in order for the IdP to validate the signatures of the incoming SAML Request. It's required when setting up the [metadata endpoint](#metadata-for-the-identity-provider)
+ when the strategy is configured with a `OVERLEAF_SAML_PRIVATE_KEY`. A JSON array of paths to certificates can be provided to support certificate rotation. When supplying an array of certificates, the first entry in the array should match the
+ current `OVERLEAF_SAML_PRIVATE_KEY`. Additional entries in the array can be used to publish upcoming certificates to IdPs before changing the `OVERLEAF_SAML_PRIVATE_KEY`.
+
+- `OVERLEAF_SAML_PRIVATE_KEY`
+ * Path to a file containing a PEM-formatted private key matching the `OVERLEAF_SAML_PUBLIC_CERT` used to sign auth requests sent by passport-saml.
+
+- `OVERLEAF_SAML_DECRYPTION_CERT`
+ * Path to a file containing public certificate, used for the [metadata endpoint](#metadata-for-the-identity-provider).
+
+- `OVERLEAF_SAML_DECRYPTION_PVK`
+ * Path to a file containing private key matching the `OVERLEAF_SAML_DECRYPTION_CERT` that will be used to attempt to decrypt any encrypted assertions that are received.
+
+- `OVERLEAF_SAML_SIGNATURE_ALGORITHM`
+ * Optionally set the signature algorithm for signing requests,
+ valid values are 'sha1' (default), 'sha256' (prefered), 'sha512' (most secure, check if your IdP supports it).
+
+- `OVERLEAF_SAML_ADDITIONAL_PARAMS`
+ * JSON dictionary of additional query params to add to all requests.
+
+- `OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS`
+ * JSON dictionary of additional query params to add to 'authorize' requests.
+
+ - Example: `{"some_key": "some_value"}`
+
+- `OVERLEAF_SAML_IDENTIFIER_FORMAT`
+ * Name identifier format to request from identity provider (default: `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`).
+
+- `OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS`
+ * Time in milliseconds of skew that is acceptable between client and server when checking OnBefore and NotOnOrAfter assertion
+ condition validity timestamps. Setting to -1 will disable checking these conditions entirely. Default is 0.
+
+- `OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX`
+ * `AttributeConsumingServiceIndex` attribute to add to AuthnRequest to instruct the IdP which attribute set to attach
+ to the response ([link](http://blog.aniljohn.com/2014/01/data-minimization-front-channel-saml-attribute-requests.html)).
+
+- `OVERLEAF_SAML_AUTHN_CONTEXT`
+ * JSON array of name identifier format values to request auth context. Default: `["urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"]`.
+
+- `OVERLEAF_SAML_FORCE_AUTHN`
+ * If `true`, the initial SAML request from the service provider specifies that the IdP should force re-authentication of the user,
+ even if they possess a valid session.
+
+- `OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT`
+ * If `true`, do not request a specific auth context. For example, you can this this to `true` to allow additional contexts such as password-less logins (`urn:oasis:names:tc:SAML:2.0:ac:classes:X509`). Support for additional contexts is dependant on your IdP.
+
+- `OVERLEAF_SAML_AUTHN_REQUEST_BINDING`
+ * If set to `HTTP-POST`, will request authentication from IdP via HTTP POST binding, otherwise defaults to HTTP-Redirect.
+
+- `OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO`
+ * If `always`, then InResponseTo will be validated from incoming SAML responses.
+ * If `never`, then InResponseTo won't be validated (default).
+ * If `ifPresent`, then InResponseTo will only be validated if present in the incoming SAML response.
+
+- `OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS`
+ * Defines the expiration time when a Request ID generated for a SAML request will not be valid if seen
+ in a SAML response in the `InResponseTo` field. Default: 28800000 (8 hours).
+
+- `OVERLEAF_SAML_LOGOUT_URL`
+ * base address to call with logout requests (default: `entryPoint`).
+
+ - Example: `https://idp.example.com/simplesaml/saml2/idp/SingleLogoutService.php`
+
+- `OVERLEAF_SAML_LOGOUT_CALLBACK_URL`
+ * Callback URL for IdP initiated logout. Should be the full URL of the `/saml/logot/callback` path.
+ With this value the `Location` attribute in the `SingleLogoutService` elements in the generated service provider metadata is populated with this value.
+
+ - Example: `https://my-overleaf-instance.com/saml/logout/callback`
+
+- `OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS`
+ * JSON dictionary of additional query params to add to 'logout' requests.
+
+
+- `OVERLEAF_SAML_IS_ADMIN_FIELD` and `OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE`
+ * When both environment variables are set, the login process updates `user.isAdmin = true` if the profile returned by the SAML IdP contains the attribute specified by
+ `OVERLEAF_SAML_IS_ADMIN_FIELD` and its value either matches `OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE` or is an array containing `OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE`,
+ otherwise `user.isAdmin` is set to `false`. If either of these variables is not set, then the admin status is only set to `true` during admin user.
+ creation in Launchpad.
+
+#### Metadata for the Identity Provider
+
+The current version of Overleaf CE includes and endpoint to retrieve Service Provider Metadata: `http://my-overleaf-instance.com/saml/meta`
+
+The Identity Provider will need to be configured to recognize the Overleaf server as a "Service Provider". Consult the documentation for your SAML server for instructions on how to do this.
+
+Below is an example of appropriate Service Provider metadata:
+
+
+ol-meta.xml
+
+```
+
+
+
+
+
+
+ MII...
+[skipped]
+
+
+
+
+
+
+
+ MII...
+[skipped]
+
+
+
+
+
+
+
+
+
+ urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
+
+
+
+
+```
+
+
+Note the certificates, `AssertionConsumerService.Location`, `SingleLogoutService.Location` and `EntityDescriptor.entityID`
+and set as appropriate in your IdP configuration, or send the metadata file to the IdP admin.
+
+
+Sample variables.env file
+
+```
+OVERLEAF_APP_NAME="Our Overleaf Instance"
+
+ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
+
+# Enables Thumbnail generation using ImageMagick
+ENABLE_CONVERSIONS=true
+
+# Disables email confirmation requirement
+EMAIL_CONFIRMATION_DISABLED=true
+
+## Nginx
+# NGINX_WORKER_PROCESSES=4
+# NGINX_WORKER_CONNECTIONS=768
+
+## Set for TLS via nginx-proxy
+# OVERLEAF_BEHIND_PROXY=true
+# OVERLEAF_SECURE_COOKIE=true
+
+OVERLEAF_SITE_URL=http://my-overleaf-instance.com
+OVERLEAF_NAV_TITLE=Our Overleaf Instance
+# OVERLEAF_HEADER_IMAGE_URL=http://somewhere.com/mylogo.png
+OVERLEAF_ADMIN_EMAIL=support@example.com
+
+OVERLEAF_LEFT_FOOTER=[{"text": "Contact your support team", "url": "mailto:support@example.com"}]
+OVERLEAF_RIGHT_FOOTER=[{"text":"Hello, I am on the Right", "url":"https://github.com/yu-i-i/overleaf-cep"}]
+
+OVERLEAF_EMAIL_FROM_ADDRESS=team@example.com
+OVERLEAF_EMAIL_SMTP_HOST=smtp.example.com
+OVERLEAF_EMAIL_SMTP_PORT=587
+OVERLEAF_EMAIL_SMTP_SECURE=false
+# OVERLEAF_EMAIL_SMTP_USER=
+# OVERLEAF_EMAIL_SMTP_PASS=
+# OVERLEAF_EMAIL_SMTP_NAME=
+OVERLEAF_EMAIL_SMTP_LOGGER=false
+OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH=true
+OVERLEAF_EMAIL_SMTP_IGNORE_TLS=false
+OVERLEAF_CUSTOM_EMAIL_FOOTER=This system is run by department x
+
+OVERLEAF_PROXY_LEARN=true
+NAV_HIDE_POWERED_BY=true
+
+#################
+## SAML for CE ##
+#################
+
+EXTERNAL_AUTH=saml
+OVERLEAF_SAML_IDENTITY_SERVICE_NAME='Login with My IdP'
+OVERLEAF_SAML_EMAIL_FIELD=mail
+OVERLEAF_SAML_FIRST_NAME_FIELD=givenName
+OVERLEAF_SAML_LAST_NAME_FIELD=sn
+OVERLEAF_SAML_ENTRYPOINT=https://idp.example.com/simplesamlphp/saml2/idp/SSOService.php
+OVERLEAF_SAML_CALLBACK_URL=https://my-overleaf-instance.com/saml/login/callback
+OVERLEAF_SAML_LOGOUT_URL=https://idp.example.com/simplesamlphp/saml2/idp/SingleLogoutService.php
+OVERLEAF_SAML_LOGOUT_CALLBACK_URL=https://my-overleaf-instance.com/saml/logout/callback
+OVERLEAF_SAML_ISSUER=MyOverleaf
+OVERLEAF_SAML_IDP_CERT=/overleaf/certs/idp_cert.pem
+OVERLEAF_SAML_PUBLIC_CERT=/overleaf/certs/myol_cert.pem
+OVERLEAF_SAML_PRIVATE_KEY=/overleaf/certs/myol_key.pem
+OVERLEAF_SAML_DECRYPTION_CERT=/overleaf/certs/myol_decr_cert.pem
+OVERLEAF_SAML_DECRYPTION_PVK=/overleaf/certs/myol_decr_key.pem
+OVERLEAF_SAML_IS_ADMIN_FIELD=mail
+OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE=overleaf.admin@example.com
+```
+
+
## Overleaf Docker Image
@@ -60,14 +706,11 @@ in which to run the Overleaf services. Baseimage uses the `runit` service
manager to manage services, and we add our init-scripts from the `server-ce/runit`
folder.
-
-## Contributing
-
-Please see the [CONTRIBUTING](CONTRIBUTING.md) file for information on contributing to the development of Overleaf.
-
## Authors
[The Overleaf Team](https://www.overleaf.com/about)
+
+Extensions for CE by: [yu-i-i](https://github.com/yu-i-i/overleaf-cep)
## License
diff --git a/doc/screenshot.png b/doc/screenshot.png
index 1c1f339630b048780aed1e692290dada8aa23307..92633192a51ec83a2e16a0b41d2f4f0d7a392cf9 100644
GIT binary patch
literal 1080606
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