Compare commits

...

11 commits

Author SHA1 Message Date
yu-i-i
9f39fd05e1 Update README.md 2025-01-30 13:23:32 +01:00
yu-i-i
2d1c61650a Refactor authentication code; add OIDC support 2025-01-27 05:16:26 +01:00
yu-i-i
91bebe3cbf Allow adding extra flags to LaTeX compiler through environment variable 2025-01-26 15:53:38 +01:00
yu-i-i
1e407e34e5 Update README.md 2025-01-26 15:53:38 +01:00
yu-i-i
5d2febba7d Enable LDAP and SAML authentication support 2025-01-26 15:53:38 +01:00
yu-i-i
83719f84c2 Enable Symbol Palette 2025-01-26 15:53:38 +01:00
yu-i-i
1fbbfe176f Allow selecting a TeX Live image for a project 2025-01-26 15:53:38 +01:00
Sam Van den Vonder
e992cfca64 Enable Sandboxed Compiles feature 2025-01-26 15:53:38 +01:00
yu-i-i
b748a664a8 Enable autocomplete of reference keys feature 2025-01-26 15:53:38 +01:00
yu-i-i
a7ddd99339 Enable track changes and comments feature 2025-01-26 15:53:37 +01:00
yu-i-i
24fb73810d Redirect non-existing links to Overleaf page 2025-01-26 15:53:37 +01:00
74 changed files with 4224 additions and 57 deletions

865
README.md
View file

@ -14,30 +14,866 @@
<a href="#license">License</a>
</p>
<img src="doc/screenshot.png" alt="A screenshot of a project being edited in Overleaf Community Edition">
<img src="doc/screenshot.png" alt="A screenshot of a project being edited in Overleaf Extended Community Edition">
<p align="center">
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.
</p>
## 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
- OpenID Connect 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.3.1 codebase, you can check out the corresponding tag by running:
```
git checkout v5.3.1-ext-v1
```
After building the image, switch to the latest state of the repository and check the `server-ce/hotfix` directory. If a subdirectory matching your version (e.g., `5.3.1`) exists, build a patched image.
Alternatively, you can download a prebuilt image from Docker Hub:
```
docker pull overleafcep/sharelatex:5.3.1-ext-v1
```
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).
When the compilation takes place in a dedicated container, it is relatively safe to permit running external commands from inside the TeX
file during compilation. This is required for packages like `minted`. For this purpose, the following environment variable can be used:
- `TEX_COMPILER_EXTRA_FLAGS`
* A list of extra flags for TeX compiler. Example: `-shell-escape -file-line-error`
<details>
<summary><h4>Sample variables.env file</h4></summary>
```
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
TEX_COMPILER_EXTRA_FLAGS=-shell-escape
```
</details>
## Authentication Methods
The following authentication methods are supported: local authentication, LDAP authentication, SAML authentication,
and OpenID Connect (OIDC) authentication. Local authentication is always active. The environment variable `EXTERNAL_AUTH`
specifies which external authentication methods are activated. The value of this variable is a list. If the list includes `ldap`, `saml`, or `oidc`,
then LDAP authentication, SAML authentication, and OIDC authentication will be activated, respectively.
For example: `EXTERNAL_AUTH=ldap saml oidc`
This configuration activates all available authentication methods, although this is rarely necessary.
<details>
<summary><h3>Local Authentication</h3></summary>
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.
</details>
<details>
<summary><h3>LDAP Authentication</h3></summary>
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.
When using Local and LDAP authentication methods, a user enters a `username` and `password` in the login form. If LDAP authentication is enabled, it is attempted first:
1. An LDAP user is searched for in the LDAP directory using the filter defined by `OVERLEAF_LDAP_SEARCH_FILTER` and authenticated.
2. If authentication is successful, the Overleaf users database is checked for a user with the primary email address that matches the email address of the authenticated LDAP user:
- If a matching user is found, the `hashedPassword` field for this user is deleted (if it exists). This ensures that the user can only log in via LDAP authentication in the future.
- If no matching user is found, a new Overleaf user is created using the email, first name, and last name retrieved from the LDAP server.
3. If LDAP authentication fails or is unsuccessful, local authentication is attempted.
#### 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.
<details>
<summary><h5>Example</h5></summary>
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.
</details>
<details>
<summary><h4>Sample variables.env file</h4></summary>
```
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='*'
```
</details>
<details>
<summary><i>Deprecated variables</i></summary>
**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)
<details>
<summary><h5>Example</h5></summary>
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.
</details>
</details>
</details>
<details>
<summary><h3>SAML Authentication</h3></summary>
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.
When using the SAML authentication method, a user is redirected to the Identity Provider (IdP) authentication site.
If the IdP successfully authenticates the user, the Overleaf users database is checked for a record containing a `samlIdentifiers` field structured as follows:
```
samlIdentifiers: [
{
externalUserId: "...",
providerId: "1",
userIdAttribute: "..."
}
]
```
The `externalUserId` must match the value of the property specified by `userIdAttribute` in the user profile returned by the IdP server.
If no matching record is found, the database is searched for a user with the primary email address matching the email in the IdP user profile:
- If such a user is found, the `hashedPassword` field is deleted to disable local authentication, and the `samlIdentifiers` field is added.
- If no matching user is found, a new user is created with the email address and `samlIdentifiers` from the IdP profile.
**Note:** Currently, only one SAML IdP is supported. The `providerId` field in `samlIdentifiers` is fixed to `'1'`.
#### Environment Variables
- `OVERLEAF_SAML_IDENTITY_SERVICE_NAME`
* Display name for the identity service, used on the login page (default: `Log in with SAML IdP`).
- `OVERLEAF_SAML_USER_ID_FIELD`
* The value of this attribute will be used by Overleaf as the external user ID, defaults to `nameID`.
- `OVERLEAF_SAML_EMAIL_FIELD`
* Name of the Email field in user profile, defaults to `nameID`.
- `OVERLEAF_SAML_FIRST_NAME_FIELD`
* Name of the firstName field in user profile, defaults to `givenName`.
- `OVERLEAF_SAML_LAST_NAME_FIELD`
* Name of the lastName field in user profile, defaults 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_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 the identity provider (default: `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`).
If using `urn:oasis:names:tc:SAML:2.0:nameid-format:persistent`, ensure the `OVERLEAF_SAML_EMAIL_FIELD` envirionment variable is defined.
If `urn:oasis:names:tc:SAML:2.0:nameid-format:transient` is required, you must also define the `OVERLEAF_SAML_ID_FIELD` environment variable,
which can, for example, be set to the user's email address.
- `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_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:
<details>
<summary><h5>ol-meta.xml</h5></summary>
```
<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="MyOverleaf"
ID="_b508c83b7dda452f5b269383fb391107116f8f57">
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol" AuthnRequestsSigned="true" WantAssertionsSigned="true">
<KeyDescriptor use="signing">
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>MII...
[skipped]
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
<KeyDescriptor use="encryption">
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>MII...
[skipped]
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
<EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes256-gcm"/>
<EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
</KeyDescriptor>
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://my-overleaf-instance.com/saml/logout/callback"/>
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService index="1"
isDefault="true"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://my-overleaf-instance.com/saml/login/callback"/>
</SPSSODescriptor>
</EntityDescriptor>
```
</details>
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.
<details>
<summary><h4>Sample variables.env file</h4></summary>
```
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='Log in 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
```
</details>
</details>
<details>
<summary><h3>OIDC Authentication</h3></summary>
Internally, Overleaf OIDC module uses the [passport-openidconnect](https://github.com/jaredhanson/passport-openidconnect) library.
If you are having issues configuring OpenID Connect, it is worth reading the README for `passport-openidconnect` to get a feel for the configuration it expects.
When using the OIDC authentication method, a user is redirected to the Identity Provider (IdP) authentication site.
If the IdP successfully authenticates the user, the Overleaf users database is checked for a record containing a `thirdPartyIdentifiers` field structured as follows:
```
thirdPartyIdentifiers: [
{
externalUserId: "...",
externalData: null,
providerId: "..."
}
]
```
The `externalUserId` must match the user ID in the profile returned by the IdP server (see the `OVERLEAF_OIDC_USER_ID_FIELD` environment variable), and `providerId`
must match the ID of the OIDC provider (see the `OVERLEAF_OIDC_PROVIDER_ID`).
If no matching record is found, the database is searched for a user with the primary email address matching the email in the IdP user profile:
- If such a user is found, the `thirdPartyIdentifiers` field is updated.
- If no matching user is found, a new user is created with the email address and `thirdPartyIdentifiers` from the IdP profile.
In both cases, the user is said to be 'linked' to the external OIDC user. The user can be unlinked from the OIDC provider on the `/user/settings` page.
#### Environment Variables
The values of the following five required variables can be found using `.well-known/openid-configuration` endpoint of your OpenID Provider (OP).
- `OVERLEAF_OIDC_ISSUER` **(required)**
- `OVERLEAF_OIDC_AUTHORIZATION_URL` **(required)**
- `OVERLEAF_OIDC_TOKEN_URL` **(required)**
- `OVERLEAF_OIDC_USER_INFO_URL` **(required)**
- `OVERLEAF_OIDC_LOGOUT_URL` **(required)**
The values of the following two required variables will be provided by the admin of your OP
- `OVERLEAF_OIDC_CLIENT_ID` **(required)**
- `OVERLEAF_OIDC_CLIENT_SECRET` **(required)**
- `OVERLEAF_OIDC_SCOPE`
* Default: `openid profile email`
- `OVERLEAF_OIDC_PROVIDER_ID`
* Arbitrary ID of the OP, defaults to `oidc`.
- `OVERLEAF_OIDC_PROVIDER_NAME`
* The name of the OP, used in the `Linked Accounts` section of the `/user/settings` page, defaults to `OIDC Provider`.
- `OVERLEAF_OIDC_IDENTITY_SERVICE_NAME`
* Display name for the identity service, used on the login page (default: `Log in with $OVERLEAF_OIDC_PROVIDER_NAME`).
- `OVERLEAF_OIDC_PROVIDER_DESCRIPTION`
* Description of OP, used in the `Linked Accounts` section (default: `Log in with $OVERLEAF_OIDC_PROVIDER_NAME`).
- `OVERLEAF_OIDC_PROVIDER_INFO_LINK`
* `Learn more` URL in the OP description, default: no `Learn more` link in the description.
- `OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED`
* Do not show OP on the `/user/settings` page, if the user's account is not linked with the OP, default `false`.
- `OVERLEAF_OIDC_USER_ID_FIELD`
* The value of this attribute will be used by Overleaf as the external user ID, defaults to `id`.
- `OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN`
* If set to `true`, updates the user `first_name` and `last_name` field on login,
and disables the user details form on `/user/settings` page.
- `OVERLEAF_OIDC_IS_ADMIN_FIELD` and `OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE`
* When both environment variables are set, the login process updates `user.isAdmin = true` if the profile returned by the OP contains the attribute specified by
`OVERLEAF_OIDC_IS_ADMIN_FIELD` and its value matches `OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE`, otherwise `user.isAdmin` is set to `false`.
If `OVERLEAF_OIDC_IS_ADMIN_FIELD` is `email` then the value of the attribute `emails[0].value` is used for match checking.
<details>
<summary><h4>Sample variables.env file</h4></summary>
```
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
#################
## OIDC for CE ##
#################
EXTERNAL_AUTH=oidc
OVERLEAF_OIDC_PROVIDER_ID=oidc
OVERLEAF_OIDC_ISSUER=https://keycloak.provider.com/realms/example
OVERLEAF_OIDC_AUTHORIZATION_URL=https://keycloak.provider.com/realms/example/protocol/openid-connect/auth
OVERLEAF_OIDC_TOKEN_URL=https://keycloak.provider.com/realms/example/protocol/openid-connect/token
OVERLEAF_OIDC_USER_INFO_URL=https://keycloak.provider.com/realms/example/protocol/openid-connect/userinfo
OVERLEAF_OIDC_LOGOUT_URL=https://keycloak.provider.com/realms/example/protocol/openid-connect/logout
OVERLEAF_OIDC_CLIENT_ID=Overleaf-OIDC
OVERLEAF_OIDC_CLIENT_SECRET=DoNotUseThisATGgaAcTgCcATgGATTACAagGtTCaGcGTAG
OVERLEAF_OIDC_IDENTITY_SERVICE_NAME='Log in with Keycloak OIDC Provider'
OVERLEAF_OIDC_PROVIDER_NAME=OIDC Keycloak Provider
OVERLEAF_OIDC_PROVIDER_INFO_LINK=https://openid.net
OVERLEAF_OIDC_IS_ADMIN_FIELD=email
OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE=overleaf.admin@example.com
OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN=false
```
</details>
</details>
## Overleaf Docker Image
@ -60,14 +896,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)
<br>
Extensions for CE by: [yu-i-i](https://github.com/yu-i-i/overleaf-cep)
## License

View file

@ -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

View file

@ -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:

View file

@ -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:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 KiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

View file

@ -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);

View file

@ -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'));

View file

@ -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

View file

@ -458,6 +458,35 @@ 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,
user: process.env.TEX_LIVE_DOCKER_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.

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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()
})
})

View file

@ -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." })
}
}
}

View file

@ -0,0 +1,9 @@
module.exports = {
internal: {
references: {
port: 3056,
host: process.env.REFERENCES_HOST || '127.0.0.1',
},
},
}

View file

@ -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"
}
}

View file

@ -82,6 +82,7 @@ const AuthenticationController = {
analyticsId: user.analyticsId || user._id,
alphaProgram: user.alphaProgram || undefined, // only store if set
betaProgram: user.betaProgram || undefined, // only store if set
externalAuth: user.externalAuth || false,
}
if (user.isAdmin) {
lightUser.isAdmin = true

View file

@ -683,7 +683,7 @@ async function _getContentFromMongo(projectId) {
function _finaliseRequest(projectId, options, project, docs, files) {
const resources = []
let flags
let flags = []
let rootResourcePath = null
let rootResourcePathOverride = null
let hasMainFile = false
@ -762,6 +762,10 @@ function _finaliseRequest(projectId, options, project, docs, files) {
flags = ['-file-line-error']
}
if (process.env.TEX_COMPILER_EXTRA_FLAGS) {
flags.push(...process.env.TEX_COMPILER_EXTRA_FLAGS.split(/\s+/).filter(Boolean))
}
return {
compile: {
options: {

View file

@ -118,7 +118,7 @@ async function requestReset(req, res, next) {
OError.tag(err, 'failed to generate and email password reset token', {
email,
})
if (err.message === 'user does not have permission for change-password') {
if (err.message === 'user does not have one or more permissions within change-password') {
return res.status(403).json({
message: {
key: 'no-password-allowed-due-to-sso',

View file

@ -72,6 +72,7 @@ async function getUserForPasswordResetToken(token) {
'overleaf.id': 1,
email: 1,
must_reconfirm: 1,
hashedPassword: 1,
})
await assertUserPermissions(user, ['change-password'])

View file

@ -9,7 +9,7 @@ function mergeDeletedDocs(a, b) {
}
module.exports = ProjectEditorHandler = {
trackChangesAvailable: false,
trackChangesAvailable: true,
buildProjectModelView(project, members, invites, deletedDocsFromDocstore) {
let owner, ownerFeatures
@ -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 } =

View file

@ -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, {})
},

View file

@ -53,10 +53,8 @@ async function settingsPage(req, res) {
const reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed'])
delete req.session.saml
let shouldAllowEditingDetails = true
if (Settings.ldap && Settings.ldap.updateUserDetailsOnLogin) {
shouldAllowEditingDetails = false
}
if (Settings.saml && Settings.saml.updateUserDetailsOnLogin) {
const externalAuth = req.user.externalAuth
if (externalAuth && Settings[externalAuth].updateUserDetailsOnLogin) {
shouldAllowEditingDetails = false
}
const oauthProviders = Settings.oauthProviders || {}

View file

@ -106,9 +106,9 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
webRouter.use(function (req, res, next) {
req.externalAuthenticationSystemUsed =
Features.externalAuthenticationSystemUsed
() => !!req?.user?.externalAuth
res.locals.externalAuthenticationSystemUsed =
Features.externalAuthenticationSystemUsed
() => !!req?.user?.externalAuth
req.hasFeature = res.locals.hasFeature = Features.hasFeature
next()
})

View file

@ -229,6 +229,8 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
CaptchaMiddleware.canSkipCaptcha
)
await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
webRouter.get('/login', UserPagesController.loginPage)
AuthenticationController.addEndpointToLoginWhitelist('/login')
@ -290,8 +292,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
UserMembershipRouter.apply(webRouter)
TokenAccessRouter.apply(webRouter)
await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
if (Settings.enableSubscriptions) {
webRouter.get(
'/user/bonus',
@ -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)

View file

@ -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,21 @@ 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")}…
if settings.oidc && settings.oidc.enable
form(data-ol-async-form, name="oidcLoginForm")
.actions(style='margin-top: 30px;')
a.btn.btn-secondary.btn-block(
href='/oidc/login',
data-ol-disabled-inflight
)
span(data-ol-inflight="idle") #{settings.oidc.identityServiceName}
span(hidden data-ol-inflight="pending") #{translate("logging_in")}…

View file

@ -49,7 +49,7 @@ block content
.notification-content-and-cta
.notification-content
p
| !{translate("you_cant_reset_password_due_to_sso", {}, [{name: 'a', attrs: {href: '/sso-login'}}])}
| !{translate("you_cant_reset_password_due_to_ldap_or_sso")}
input(type="hidden", name="_csrf", value=csrfToken)
.form-group.mb-3

View file

@ -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`,
},
@ -960,7 +963,7 @@ module.exports = {
pdfPreviewPromotions: [],
diagnosticActions: [],
sourceEditorCompletionSources: [],
sourceEditorSymbolPalette: [],
sourceEditorSymbolPalette: ['@/features/symbol-palette/components/symbol-palette'],
sourceEditorToolbarComponents: [],
editorPromotions: [],
langFeedbackLinkingWidgets: [],
@ -990,6 +993,11 @@ module.exports = {
'launchpad',
'server-ce-scripts',
'user-activate',
'symbol-palette',
'track-changes',
'authentication/ldap',
'authentication/saml',
'authentication/oidc',
],
viewIncludes: {},
@ -1015,6 +1023,29 @@ 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,
oauthProviders: {
...(process.env.EXTERNAL_AUTH && process.env.EXTERNAL_AUTH.includes('oidc') && {
[process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc']: {
name: process.env.OVERLEAF_OIDC_PROVIDER_NAME || 'OIDC Provider',
descriptionKey: process.env.OVERLEAF_OIDC_PROVIDER_DESCRIPTION,
descriptionOptions: { link: process.env.OVERLEAF_OIDC_PROVIDER_INFO_LINK },
hideWhenNotLinked: process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED ?
process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED.toLowerCase() === 'true' : undefined,
linkPath: '/oidc/login',
},
}),
},
}
module.exports.mergeWith = function (overrides) {

View file

@ -1925,6 +1925,7 @@
"you_can_request_a_maximum_of_limit_fixes_per_day": "",
"you_can_select_or_invite": "",
"you_can_select_or_invite_plural": "",
"you_cant_add_or_change_password_due_to_ldap_or_sso": "",
"you_cant_add_or_change_password_due_to_sso": "",
"you_cant_join_this_group_subscription": "",
"you_dont_have_any_repositories": "",

View file

@ -10,7 +10,7 @@ export default function HotkeysModalBottomText() {
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
onClick={() => 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"
/>,
]}

View file

@ -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)
}
},

View file

@ -212,7 +212,8 @@ function SSOLinkingWidgetContainer({
const { t } = useTranslation()
const { unlink } = useSSOContext()
let description = ''
let description = subscription.provider.descriptionKey ||
`${t('login_with_service', { service: subscription.provider.name, })}.`
switch (subscription.providerId) {
case 'collabratec':
description = t('linked_collabratec_description')

View file

@ -4,6 +4,7 @@ import { FetchError } from '../../../../infrastructure/fetch-json'
import IEEELogo from '../../../../shared/svgs/ieee-logo'
import GoogleLogo from '../../../../shared/svgs/google-logo'
import OrcidLogo from '../../../../shared/svgs/orcid-logo'
import OpenIDLogo from '../../../../shared/svgs/openid-logo'
import LinkingStatus from './status'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
@ -17,6 +18,7 @@ const providerLogos: { readonly [p: string]: JSX.Element } = {
collabratec: <IEEELogo />,
google: <GoogleLogo />,
orcid: <OrcidLogo />,
oidc: <OpenIDLogo />,
}
type SSOLinkingWidgetProps = {
@ -66,7 +68,7 @@ export function SSOLinkingWidget({
return (
<div className="settings-widget-container">
<div>{providerLogos[providerId]}</div>
<div>{providerLogos[providerId] || providerLogos['oidc']}</div>
<div className="description-container">
<div className="title-row">
<h4>{title}</h4>

View file

@ -39,11 +39,7 @@ function CanOnlyLogInThroughSSO() {
return (
<p>
<Trans
i18nKey="you_cant_add_or_change_password_due_to_sso"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/learn/how-to/Logging_in_with_Group_single_sign-on" />,
]}
i18nKey="you_cant_add_or_change_password_due_to_ldap_or_sso"
/>
</p>
)

View file

@ -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
}

View file

@ -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 ? (
<SymbolPaletteItems
items={filteredSymbols}
handleSelect={handleSelect}
focusInput={focusInput}
/>
) : (
<div className="symbol-palette-empty">{t('no_symbols_found')}</div>
)}
<TabPanels>
{categories.map(category => (
<TabPanel key={category.id} tabIndex={-1} />
))}
</TabPanels>
</>
)
}
// not searching: show the symbols grouped by category
return (
<TabPanels>
{categories.map(category => (
<TabPanel key={category.id} tabIndex={-1}>
<SymbolPaletteItems
items={categorisedSymbols[category.id]}
handleSelect={handleSelect}
focusInput={focusInput}
/>
</TabPanel>
))}
</TabPanels>
)
}
SymbolPaletteBody.propTypes = {
categories: PropTypes.arrayOf(PropTypes.object).isRequired,
categorisedSymbols: PropTypes.object,
filteredSymbols: PropTypes.arrayOf(PropTypes.object),
handleSelect: PropTypes.func.isRequired,
focusInput: PropTypes.func.isRequired,
}

View file

@ -0,0 +1,18 @@
import { Button } from 'react-bootstrap'
import { useEditorContext } from '../../../shared/context/editor-context'
export default function SymbolPaletteCloseButton() {
const { toggleSymbolPalette } = useEditorContext()
return (
<Button
bsStyle="link"
bsSize="small"
className="symbol-palette-close-button"
onClick={toggleSymbolPalette} // Trigger closePanel on click
>
&times;
</Button>
)
}

View file

@ -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 (
<Tabs className="symbol-palette-container">
<div className="symbol-palette">
<div className="symbol-palette-header-outer">
<div className="symbol-palette-header">
<SymbolPaletteTabs categories={categories} />
<div className="symbol-palette-header-group">
{/* Useless button (uncomment if you see any sense in it) */}
{/* <SymbolPaletteInfoLink /> */}
<SymbolPaletteSearch setInput={setInput} inputRef={inputRef} />
</div>
</div>
<SymbolPaletteCloseButton />
</div>
<div className="symbol-palette-body">
<SymbolPaletteBody
categories={categories}
categorisedSymbols={categorisedSymbols}
filteredSymbols={filteredSymbols}
handleSelect={handleSelect}
focusInput={focusInput}
/>
</div>
</div>
</Tabs>
)
}
SymbolPaletteContent.propTypes = {
handleSelect: PropTypes.func.isRequired,
}

View file

@ -0,0 +1,29 @@
import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
export default function SymbolPaletteInfoLink() {
const { t } = useTranslation()
return (
<OverlayTrigger
placement="top"
trigger={['hover', 'focus']}
overlay={
<Tooltip id="tooltip-symbol-palette-info">
{t('find_out_more_about_latex_symbols')}
</Tooltip>
}
>
<Button
bsStyle="link"
bsSize="small"
className="symbol-palette-info-link"
href="https://www.overleaf.com/learn/latex/List_of_Greek_letters_and_math_symbols"
target="_blank"
rel="noopener noreferer"
>
<span className="info-badge" />
</Button>
</OverlayTrigger>
)
}

View file

@ -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 (
<OverlayTrigger
placement="top"
trigger={['hover', 'focus']}
overlay={
<Tooltip id={`tooltip-symbol-${symbol.codepoint}`}>
<div className="symbol-palette-item-description">
{symbol.description}
</div>
<div className="symbol-palette-item-command">{symbol.command}</div>
{symbol.notes && (
<div className="symbol-palette-item-notes">{symbol.notes}</div>
)}
</Tooltip>
}
>
<button
key={symbol.codepoint}
className="symbol-palette-item"
onClick={() => handleSelect(symbol)}
onKeyDown={handleKeyDown}
tabIndex={focused ? 0 : -1}
ref={buttonRef}
role="option"
aria-label={symbol.description}
aria-selected={focused ? 'true' : 'false'}
>
{symbol.character}
</button>
</OverlayTrigger>
)
}
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,
}

View file

@ -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 (
<div className="symbol-palette-items" role="listbox" aria-label="Symbols">
{items.map((symbol, index) => (
<SymbolPaletteItem
key={symbol.codepoint}
symbol={symbol}
handleSelect={symbol => {
handleSelect(symbol)
setFocusedIndex(index)
}}
handleKeyDown={handleKeyDown}
focused={index === focusedIndex}
/>
))}
</div>
)
}
SymbolPaletteItems.propTypes = {
items: PropTypes.arrayOf(
PropTypes.shape({
codepoint: PropTypes.string.isRequired,
})
).isRequired,
handleSelect: PropTypes.func.isRequired,
focusInput: PropTypes.func.isRequired,
}

View file

@ -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 (
<FormControl
className="symbol-palette-search"
type="search"
inputRef={inputRefCallback}
id="symbol-palette-input"
aria-label="Search"
value={localInput}
placeholder={t('search') + '…'}
onChange={event => {
setLocalInput(event.target.value)
}}
/>
)
}
SymbolPaletteSearch.propTypes = {
setInput: PropTypes.func.isRequired,
inputRef: PropTypes.object.isRequired,
}

View file

@ -0,0 +1,22 @@
import { TabList, Tab } from '@reach/tabs'
import PropTypes from 'prop-types'
export default function SymbolPaletteTabs({ categories }) {
return (
<TabList aria-label="Symbol Categories" className="symbol-palette-tab-list">
{categories.map(category => (
<Tab key={category.id} className="symbol-palette-tab">
{category.label}
</Tab>
))}
</TabList>
)
}
SymbolPaletteTabs.propTypes = {
categories: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
})
).isRequired,
}

View file

@ -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 <SymbolPaletteContent handleSelect={handleSelect} />
}

View file

@ -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": ""
}
]

View file

@ -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
}

View file

@ -0,0 +1,27 @@
function OpenIDLogo() {
return (
<svg
width="40"
height="40"
viewBox="0 0 40 40"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="40" height="40" fill="white" />
<path
d="M18.185415 36.042565 23.298193 32.35627 23.060446 3.090316 18.185415 6.8918455Z"
fill="#ff8e00"
/>
<path
d="M18.246064 36.042565C-0.37463741 32.997945 -1.0248032 15.054095 18.13083 11.143396l0.05944 3.322396 c -13.3672163 2.225847 -11.6629563 14.187201 0 15.92785l0.05944 3.127104Z"
fill="#626262"
/>
<path
d="M23.219348 14.720521c2.279219 0.01577 4.262468 1.057732 6.237225 2.117891l-2.917255 2.176115h9.317022l0.05701 -6.371868 -2.917255 2.176115C30.03396 13.32315 27.308358 11.530342 23.169615 11.496378Z"
fill="#626262"
/>
</svg>
)
}
export default OpenIDLogo

View file

@ -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 <b>new account</b> with your institution email (<b>__email__</b>) by clicking <b>__clickText__</b>.",
"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 more</1>",
@ -2586,8 +2588,10 @@
"you_can_request_a_maximum_of_limit_fixes_per_day": "You can request a maximum of __limit__ fixes per day. Please try again tomorrow.",
"you_can_select_or_invite": "You can select or invite __count__ editor on your current plan, or upgrade to get more.",
"you_can_select_or_invite_plural": "You can select or invite __count__ editors on your current plan, or upgrade to get more.",
"you_cant_add_or_change_password_due_to_ldap_or_sso": "You cant add or change your password because your group or organization uses LDAP or SSO.",
"you_cant_add_or_change_password_due_to_sso": "You cant add or change your password because your group or organization uses <0>single sign-on (SSO)</0>.",
"you_cant_join_this_group_subscription": "You cant join this group subscription",
"you_cant_reset_password_due_to_ldap_or_sso": "You cant reset your password because your group or organization uses LDAP or SSO. Contact your system administrator.",
"you_cant_reset_password_due_to_sso": "You cant reset your password because your group or organization uses SSO. <0>Log in with SSO</0>.",
"you_dont_have_any_repositories": "You dont have any repositories",
"you_get_access_to": "You get access to",

View file

@ -0,0 +1,112 @@
import logger from '@overleaf/logger'
import passport from 'passport'
import EmailHelper from '../../../../../app/src/Features/Helpers/EmailHelper.js'
import { handleAuthenticateErrors } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js'
import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js'
import LDAPAuthenticationManager from './LDAPAuthenticationManager.mjs'
const LDAPAuthenticationController = {
passportLogin(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(
'ldapauth',
{ keepSessionInfo: true },
async function (err, user, info, status) {
if (err) { //we cannot be here as long as errors are treated as fails
return next(err)
}
if (user) {
// `user` is either a user object or false
AuthenticationController.setAuditInfo(req, {
method: 'LDAP password login',
})
try {
await AuthenticationController.promises.finishLogin(user, req, res)
res.status(200)
return
} catch (err) {
return next(err)
}
} else {
if (status != 401) {
logger.warn(status, 'LDAP: ' + info.message)
}
if (EmailHelper.parseEmail(req.body.email)) return next() //Try local authentication
if (info.redir != null) {
return res.json({ redir: info.redir })
} else {
res.status(status || info.status || 401)
delete info.status
info.type = 'error'
info.key = 'invalid-password-retry-or-reset'
const body = { message: info }
const { errorReason } = info
if (errorReason) {
body.errorReason = errorReason
delete info.errorReason
}
return res.json(body)
}
}
}
)(req, res, next)
},
async doPassportLogin(req, profile, done) {
let user, info
try {
;({ user, info } = await LDAPAuthenticationController._doPassportLogin(
req,
profile
))
} catch (error) {
return done(error)
}
return done(undefined, user, info)
},
async _doPassportLogin(req, profile) {
const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
const auditLog = {
ipAddress: req.ip,
info: { method: 'LDAP password login', fromKnownDevice },
}
let user, isPasswordReused
try {
user = await LDAPAuthenticationManager.promises.findOrCreateUser(profile, 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) {
user.externalAuth = 'ldap'
return { user, info: undefined }
} else { //we cannot be here, something is terribly wrong
logger.debug({ email : profile.mail }, 'failed LDAP log in')
return {
user: false,
info: {
type: 'error',
text: 'Unknown error',
status: 500,
},
}
}
},
}
export default LDAPAuthenticationController

View file

@ -0,0 +1,76 @@
import Settings from '@overleaf/settings'
import { callbackify } from '@overleaf/promise-utils'
import UserCreator from '../../../../../app/src/Features/User/UserCreator.js'
import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js'
import { User } from '../../../../../app/src/models/User.js'
import { splitFullName } from '../../../utils.mjs'
const LDAPAuthenticationManager = {
async findOrCreateUser(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 = 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,
$unset: { hashedPassword: "" },
}
).exec()
if (result.modifiedCount !== 1) {
throw new ParallelLoginError()
}
return user
},
}
export default {
promises: LDAPAuthenticationManager,
}

View file

@ -0,0 +1,120 @@
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import { promisify } from 'util'
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 '../../../utils.mjs'
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 fetchLDAPContacts(userId, contacts) {
if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) {
return []
}
const ldapOptions = passport._strategy('ldapauth').options.server
const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap
const {
url,
timeout,
connectTimeout,
tlsOptions,
starttls,
bindDN,
bindCredentials
} = ldapOptions
const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOptions.searchBase
const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub'
const ldapConfig = { url, timeout, connectTimeout, tlsOptions }
let ldapUsers
let client
try {
await new Promise((resolve, reject) => {
client = ldapjs.createClient(ldapConfig)
client.on('error', (error) => { reject(error) })
client.on('connectTimeout', (error) => { reject(error) })
client.on('connect', () => { resolve() })
})
if (starttls) {
const starttlsAsync = promisify(client.starttls).bind(client)
await starttlsAsync(tlsOptions, null)
}
const bindAsync = promisify(client.bind).bind(client)
await bindAsync(bindDN, bindCredentials)
async function createContactsSearchFilter(client, ldapOptions, userId, contactsFilter) {
const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY
if (!searchProperty) {
return contactsFilter
}
const email = await UserGetter.promises.getUserEmail(userId)
const searchOptions = {
scope: ldapOptions.searchScope,
attributes: [searchProperty],
filter: `(${Settings.ldap.attEmail}=${email})`
}
const searchBase = ldapOptions.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)
}
const filter = await createContactsSearchFilter(client, ldapOptions, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER)
const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter }
ldapUsers = await _searchLDAP(client, searchBase, searchOptions)
} catch (error) {
logger.warn({ error }, '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(b.first_name) ||
a.email.localeCompare(b.email)
)
}
export default fetchLDAPContacts

View file

@ -0,0 +1,112 @@
import logger from '@overleaf/logger'
import passport from 'passport'
import { Strategy as LDAPStrategy } from 'passport-ldapauth'
import Settings from '@overleaf/settings'
import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js'
import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs'
import LDAPAuthenticationController from './LDAPAuthenticationController.mjs'
import fetchLDAPContacts from './LDAPContacts.mjs'
const LDAPModuleManager = {
initSettings() {
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: boolFromEnv(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN),
}
},
passportSetup(passport, callback) {
const ldapOptions = {
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: boolFromEnv(process.env.OVERLEAF_LDAP_CACHE),
timeout: numFromEnv(process.env.OVERLEAF_LDAP_TIMEOUT),
connectTimeout: numFromEnv(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT),
starttls: boolFromEnv(process.env.OVERLEAF_LDAP_STARTTLS),
tlsOptions: {
ca: readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH),
rejectUnauthorized: boolFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH),
}
}
try {
passport.use(
new LDAPStrategy(
{
server: ldapOptions,
passReqToCallback: true,
usernameField: 'email',
passwordField: 'password',
handleErrorsAsFailures: true,
},
LDAPAuthenticationController.doPassportLogin
)
)
callback(null)
} catch (error) {
callback(error)
}
},
async getContacts(userId, contacts, callback) {
try {
const newContacts = await fetchLDAPContacts(userId, contacts)
callback(null, newContacts)
} catch (error) {
callback(error)
}
},
initPolicy() {
try {
PermissionsManager.registerCapability('change-password', { default : true })
} catch (error) {
logger.info({}, error.message)
}
const ldapPolicyValidator = async ({ user, subscription }) => {
// If user is not logged in, user.externalAuth is undefined,
// in this case allow to change password if the user has a hashedPassword
return user.externalAuth === 'ldap' || (user.externalAuth === undefined && !user.hashedPassword)
}
try {
PermissionsManager.registerPolicy(
'ldapPolicy',
{ 'change-password' : false },
{ validator: ldapPolicyValidator }
)
} catch (error) {
logger.info({}, error.message)
}
},
async getGroupPolicyForUser(user, callback) {
try {
const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({
user,
groupPolicy : { 'ldapPolicy' : true },
subscription : null
})
let groupPolicy = Object.fromEntries(userValidationMap)
callback(null, {'groupPolicy' : groupPolicy })
} catch (error) {
callback(error)
}
},
}
export default LDAPModuleManager

View file

@ -0,0 +1,19 @@
import logger from '@overleaf/logger'
import RateLimiterMiddleware from '../../../../../app/src/Features/Security/RateLimiterMiddleware.js'
import CaptchaMiddleware from '../../../../../app/src/Features/Captcha/CaptchaMiddleware.js'
import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js'
import { overleafLoginRateLimiter } from '../../../../../app/src/infrastructure/RateLimiter.js'
import LDAPAuthenticationController from './LDAPAuthenticationController.mjs'
export default {
apply(webRouter) {
logger.debug({}, 'Init LDAP router')
webRouter.post('/login',
RateLimiterMiddleware.rateLimit(overleafLoginRateLimiter), // rate limit IP (20 / 60s)
RateLimiterMiddleware.loginRateLimitEmail, // rate limit email (10 / 120s)
CaptchaMiddleware.validateCaptcha('login'),
LDAPAuthenticationController.passportLogin,
AuthenticationController.passportLogin,
)
},
}

View file

@ -0,0 +1,17 @@
let ldapModule = {}
if (process.env.EXTERNAL_AUTH.includes('ldap')) {
const { default: LDAPModuleManager } = await import('./app/src/LDAPModuleManager.mjs')
const { default: router } = await import('./app/src/LDAPRouter.mjs')
LDAPModuleManager.initSettings()
LDAPModuleManager.initPolicy()
ldapModule = {
name: 'ldap-authentication',
hooks: {
passportSetup: LDAPModuleManager.passportSetup,
getContacts: LDAPModuleManager.getContacts,
getGroupPolicyForUser: LDAPModuleManager.getGroupPolicyForUser,
},
router: router,
}
}
export default ldapModule

View file

@ -0,0 +1,18 @@
let SAMLAuthenticationController
if (process.env.EXTERNAL_AUTH.includes('saml')) {
SAMLAuthenticationController = await import('./saml/app/src/SAMLAuthenticationController.mjs')
}
let OIDCAuthenticationController
if (process.env.EXTERNAL_AUTH.includes('oidc')) {
OIDCAuthenticationController = await import('./oidc/app/src/OIDCAuthenticationController.mjs')
}
export default async function logout(req, res, next) {
switch(req.user.externalAuth) {
case 'saml':
return SAMLAuthenticationController.default.passportLogout(req, res, next)
case 'oidc':
return OIDCAuthenticationController.default.passportLogout(req, res, next)
default:
next()
}
}

View file

@ -0,0 +1,171 @@
import logger from '@overleaf/logger'
import passport from 'passport'
import Settings from '@overleaf/settings'
import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js'
import UserController from '../../../../../app/src/Features/User/UserController.js'
import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.js'
import OIDCAuthenticationManager from './OIDCAuthenticationManager.mjs'
import { acceptsJson } from '../../../../../app/src/infrastructure/RequestContentTypeDetection.js'
const OIDCAuthenticationController = {
passportLogin(req, res, next) {
req.session.intent = req.query.intent
passport.authenticate('openidconnect')(req, res, next)
},
passportLoginCallback(req, res, next) {
passport.authenticate(
'openidconnect',
{ keepSessionInfo: true },
async function (err, user, info) {
if (err) {
return next(err)
}
if(req.session.intent === 'link') {
delete req.session.intent
// After linking, log out from the OIDC provider and redirect back to '/user/settings'.
// Keycloak supports this; Authentik does not (yet).
const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL
const redirectUri = `${Settings.siteUrl.replace(/\/+$/, '')}/user/settings`
return res.redirect(`${logoutUrl}?id_token_hint=${info.idToken}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`)
}
if (user) {
req.session.idToken = info.idToken
user.externalAuth = 'oidc'
// `user` is either a user object or false
AuthenticationController.setAuditInfo(req, {
method: 'OIDC 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 || 401)
delete info.status
const body = { message: info }
return res.json(body)
}
}
}
)(req, res, next)
},
async doPassportLogin(req, issuer, profile, context, idToken, accessToken, refreshToken, done) {
let user, info
try {
if(req.session.intent === 'link') {
;({ user, info } = await OIDCAuthenticationController._doLink(
req,
profile
))
} else {
;({ user, info } = await OIDCAuthenticationController._doLogin(
req,
profile
))
}
} catch (error) {
return done(error)
}
if (user) {
info = {
...(info || {}),
idToken: idToken
}
}
return done(null, user, info)
},
async _doLogin(req, profile) {
const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
const auditLog = {
ipAddress: req.ip,
info: { method: 'OIDC login', fromKnownDevice },
}
let user
try {
user = await OIDCAuthenticationManager.promises.findOrCreateUser(profile, auditLog)
} catch (error) {
logger.debug({ email : profile.emails[0].value }, `OIDC login failed: ${error}`)
return {
user: false,
info: {
type: 'error',
text: error.message,
status: 401,
},
}
}
if (user) {
return { user, info: undefined }
} else { // we cannot be here, something is terribly wrong
logger.debug({ email : profile.emails[0].value }, 'failed OIDC log in')
return {
user: false,
info: {
type: 'error',
text: 'Unknown error',
status: 500,
},
}
}
},
async _doLink(req, profile) {
const { user: { _id: userId }, ip } = req
try {
const auditLog = {
ipAddress: ip,
initiatorId: userId,
}
await OIDCAuthenticationManager.promises.linkAccount(userId, profile, auditLog)
} catch (error) {
logger.error(error.info, error.message)
return {
user: true,
info: {
type: 'error',
text: error.message,
status: 200,
},
}
}
return { user: true, info: undefined }
},
async unlinkAccount(req, res, next) {
try {
const { user: { _id: userId }, body: { providerId }, ip } = req
const auditLog = {
ipAddress: ip,
initiatorId: userId,
}
await ThirdPartyIdentityManager.promises.unlink(userId, providerId, auditLog)
return res.status(200).end()
} catch (error) {
logger.error(error.info, error.message)
return {
user: false,
info: {
type: 'error',
text: 'Can not unlink account',
status: 200,
}
}
}
},
async passportLogout(req, res, next) {
// TODO: instead of storing idToken in session, use refreshToken to obtain a new idToken?
const idTokenHint = req.session.idToken
await UserController.promises.doLogout(req)
const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL
const redirectUri = Settings.siteUrl
res.redirect(`${logoutUrl}?id_token_hint=${idTokenHint}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`)
},
passportLogoutCallback(req, res, next) {
const redirectUri = Settings.siteUrl
res.redirect(redirectUri)
},
}
export default OIDCAuthenticationController

View file

@ -0,0 +1,94 @@
import Settings from '@overleaf/settings'
import UserCreator from '../../../../../app/src/Features/User/UserCreator.js'
import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.js'
import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js'
import { User } from '../../../../../app/src/models/User.js'
const OIDCAuthenticationManager = {
async findOrCreateUser(profile, auditLog) {
const {
attUserId,
attAdmin,
valAdmin,
updateUserDetailsOnLogin,
providerId,
} = Settings.oidc
const oidcUserId = profile[attUserId]
const email = profile.emails[0].value
const firstName = profile.name?.givenName || ""
const lastName = profile.name?.familyName || ""
let isAdmin = false
if (attAdmin && valAdmin) {
if (attAdmin === 'email') {
isAdmin = (email === valAdmin)
} else {
isAdmin = (profile[attAdmin] === valAdmin)
}
}
const oidcUserData = null // Possibly it can be used later
let user
try {
user = await ThirdPartyIdentityManager.promises.login(providerId, oidcUserId, oidcUserData)
} catch {
// A user with the specified OIDC ID and provider ID is not found. Search for a user with the given email.
// If no user exists with this email, create a new user and link the OIDC account to it.
// If a user exists but no account from the specified OIDC provider is linked to this user, link the OIDC account to this user.
// If an account from the specified provider is already linked to this user, unlink it, and link the OIDC account to this user.
// (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error)
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,
}
)
}
// const alreadyLinked = user.thirdPartyIdentifiers.some(item => item.providerId === providerId)
// if (!alreadyLinked) {
auditLog.initiatorId = user._id
await ThirdPartyIdentityManager.promises.link(user._id, providerId, oidcUserId, oidcUserData, auditLog)
await User.updateOne(
{ _id: user._id },
{ $set : {
'emails.0.confirmedAt': Date.now(), //email of external user is confirmed
},
}
).exec()
// } else {
// throw new Error(`Overleaf user ${user.email} is already linked to another ${providerId} user`)
// }
}
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
},
async linkAccount(userId, profile, auditLog) {
const {
attUserId,
providerId,
} = Settings.oidc
const oidcUserId = profile[attUserId]
const oidcUserData = null // Possibly it can be used later
await ThirdPartyIdentityManager.promises.link(userId, providerId, oidcUserId, oidcUserData, auditLog)
},
}
export default {
promises: OIDCAuthenticationManager,
}

View file

@ -0,0 +1,82 @@
import logger from '@overleaf/logger'
import passport from 'passport'
import Settings from '@overleaf/settings'
import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs'
import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js'
import OIDCAuthenticationController from './OIDCAuthenticationController.mjs'
import { Strategy as OIDCStrategy } from 'passport-openidconnect'
const OIDCModuleManager = {
initSettings() {
let providerId = process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc'
Settings.oidc = {
enable: true,
providerId: providerId,
identityServiceName: process.env.OVERLEAF_OIDC_IDENTITY_SERVICE_NAME || `Log in with ${Settings.oauthProviders[providerId].name}`,
attUserId: process.env.OVERLEAF_OIDC_USER_ID_FIELD || 'id',
attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD,
valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE,
updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN),
}
},
passportSetup(passport, callback) {
const oidcOptions = {
issuer: process.env.OVERLEAF_OIDC_ISSUER,
authorizationURL: process.env.OVERLEAF_OIDC_AUTHORIZATION_URL,
tokenURL: process.env.OVERLEAF_OIDC_TOKEN_URL,
userInfoURL: process.env.OVERLEAF_OIDC_USER_INFO_URL,
clientID: process.env.OVERLEAF_OIDC_CLIENT_ID,
clientSecret: process.env.OVERLEAF_OIDC_CLIENT_SECRET,
callbackURL: `${Settings.siteUrl.replace(/\/+$/, '')}/oidc/login/callback`,
scope: process.env.OVERLEAF_OIDC_SCOPE || 'openid profile email',
passReqToCallback: true,
}
try {
passport.use(
new OIDCStrategy(
oidcOptions,
OIDCAuthenticationController.doPassportLogin
)
)
callback(null)
} catch (error) {
callback(error)
}
},
initPolicy() {
try {
PermissionsManager.registerCapability('change-password', { default : true })
} catch (error) {
logger.info({}, error.message)
}
const oidcPolicyValidator = async ({ user, subscription }) => {
// If user is not logged in, user.externalAuth is undefined,
// in this case allow to change password if the user has a hashedPassword
return user.externalAuth === 'oidc' || (user.externalAuth === undefined && !user.hashedPassword)
}
try {
PermissionsManager.registerPolicy(
'oidcPolicy',
{ 'change-password' : false },
{ validator: oidcPolicyValidator }
)
} catch (error) {
logger.info({}, error.message)
}
},
async getGroupPolicyForUser(user, callback) {
try {
const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({
user,
groupPolicy : { 'oidcPolicy' : true },
subscription : null
})
let groupPolicy = Object.fromEntries(userValidationMap)
callback(null, {'groupPolicy' : groupPolicy })
} catch (error) {
callback(error)
}
},
}
export default OIDCModuleManager

View file

@ -0,0 +1,15 @@
import logger from '@overleaf/logger'
import UserController from '../../../../../app/src/Features/User/UserController.js'
import OIDCAuthenticationController from './OIDCAuthenticationController.mjs'
import logout from '../../../logout.mjs'
export default {
apply(webRouter) {
logger.debug({}, 'Init OIDC router')
webRouter.get('/oidc/login', OIDCAuthenticationController.passportLogin)
webRouter.get('/oidc/login/callback', OIDCAuthenticationController.passportLoginCallback)
webRouter.get('/oidc/logout/callback', OIDCAuthenticationController.passportLogoutCallback)
webRouter.post('/user/oauth-unlink', OIDCAuthenticationController.unlinkAccount)
webRouter.post('/logout', logout, UserController.logout)
},
}

View file

@ -0,0 +1,16 @@
let oidcModule = {}
if (process.env.EXTERNAL_AUTH.includes('oidc')) {
const { default: OIDCModuleManager } = await import('./app/src/OIDCModuleManager.mjs')
const { default: router } = await import('./app/src/OIDCRouter.mjs')
OIDCModuleManager.initSettings()
OIDCModuleManager.initPolicy()
oidcModule = {
name: 'oidc-authentication',
hooks: {
passportSetup: OIDCModuleManager.passportSetup,
getGroupPolicyForUser: OIDCModuleManager.getGroupPolicyForUser,
},
router: router,
}
}
export default oidcModule

View file

@ -0,0 +1,150 @@
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import passport from 'passport'
import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js'
import SAMLAuthenticationManager from './SAMLAuthenticationManager.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'
import { readFilesContentFromEnv } from '../../../utils.mjs'
const SAMLAuthenticationController = {
passportLogin(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)
},
passportLoginCallback(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 || 401)
delete info.status
const body = { message: info }
return res.json(body)
}
}
}
)(req, res, next)
},
async doPassportLogin(req, profile, done) {
let user, info
try {
;({ user, info } = await SAMLAuthenticationController._doPassportLogin(
req,
profile
))
} catch (error) {
return done(error)
}
return done(undefined, user, info)
},
async _doPassportLogin(req, profile) {
const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
const auditLog = {
ipAddress: req.ip,
info: { method: 'SAML login', fromKnownDevice },
}
let user
try {
user = await SAMLAuthenticationManager.promises.findOrCreateUser(profile, auditLog)
} catch (error) {
return {
user: false,
info: handleAuthenticateErrors(error, req),
}
}
if (user) {
user.externalAuth = 'saml'
req.session.saml_extce = {nameID : profile.nameID, sessionIndex : profile.sessionIndex}
return { user, info: undefined }
} else { // we cannot be here, something is terribly wrong
logger.debug({ email : profile.mail }, 'failed SAML log in')
return {
user: false,
info: {
type: 'error',
text: 'Unknown error',
status: 500,
},
}
}
},
async passportLogout(req, res, next) {
passport._strategy('saml').logout(req, async (err, url) => {
await UserController.promises.doLogout(req)
if (err) return next(err)
res.redirect(url)
})
},
passportLogoutCallback(req, res, next) {
//TODO: is it possible to close the editor?
passport.authenticate('saml')(req, res, (err) => {
if (err) return next(err)
res.redirect('/login');
})
},
async doPassportLogout(req, profile, done) {
let user, info
try {
;({ user, info } = await SAMLAuthenticationController._doPassportLogout(
req,
profile
))
} catch (error) {
return done(error)
}
return done(undefined, user, info)
},
async _doPassportLogout(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 }
},
getSPMetadata(req, res) {
const samlStratery = passport._strategy('saml')
res.setHeader('Content-Disposition', `attachment; filename="${samlStratery._saml.options.issuer}-meta.xml"`)
xmlResponse(res,
samlStratery.generateServiceProviderMetadata(
readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT),
readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT)
)
)
},
}
export default SAMLAuthenticationController

View file

@ -0,0 +1,85 @@
import Settings from '@overleaf/settings'
import UserCreator from '../../../../../app/src/Features/User/UserCreator.js'
import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js'
import SAMLIdentityManager from '../../../../../app/src/Features/User/SAMLIdentityManager.js'
import { User } from '../../../../../app/src/models/User.js'
const SAMLAuthenticationManager = {
async findOrCreateUser(profile, auditLog) {
const {
attUserId,
attEmail,
attFirstName,
attLastName,
attAdmin,
valAdmin,
updateUserDetailsOnLogin,
} = Settings.saml
const externalUserId = profile[attUserId]
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)
}
const providerId = '1' // for now, only one fixed IdP is supported
// We search for a SAML user, and if none is found, we search for a user with the given email. If a user is found,
// we update the user to be a SAML user, otherwise, we create a new SAML user with the given email. In the case of
// multiple SAML IdPs, one would have to do something similar, or possibly report an error like
// 'the email is associated with the wrong IdP'
let user = await SAMLIdentityManager.getUser(providerId, externalUserId, attUserId)
if (!user) {
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,
samlIdentifiers: [{ providerId: providerId }],
}
)
}
// cannot use SAMLIdentityManager.linkAccounts because affilations service is not there
await User.updateOne(
{ _id: user._id },
{
$set : {
'emails.0.confirmedAt': Date.now(), //email of saml user is confirmed
'emails.0.samlProviderId': providerId,
'samlIdentifiers.0.providerId': providerId,
'samlIdentifiers.0.externalUserId': externalUserId,
'samlIdentifiers.0.userIdAttribute': attUserId,
},
}
).exec()
}
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,
$unset: { hashedPassword: "" },
},
).exec()
if (result.modifiedCount !== 1) {
throw new ParallelLoginError()
}
return user
},
}
export default {
promises: SAMLAuthenticationManager,
}

View file

@ -0,0 +1,100 @@
import logger from '@overleaf/logger'
import passport from 'passport'
import Settings from '@overleaf/settings'
import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs'
import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js'
import SAMLAuthenticationController from './SAMLAuthenticationController.mjs'
import { Strategy as SAMLStrategy } from '@node-saml/passport-saml'
const SAMLModuleManager = {
initSettings() {
Settings.saml = {
enable: true,
identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Log in with SAML IdP',
attUserId: process.env.OVERLEAF_SAML_USER_ID_FIELD || 'nameID',
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: boolFromEnv(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN),
}
},
passportSetup(passport, callback) {
const samlOptions = {
entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT,
callbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/login/callback`,
issuer: process.env.OVERLEAF_SAML_ISSUER,
audience: process.env.OVERLEAF_SAML_AUDIENCE,
cert: readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT),
privateKey: readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY),
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: numFromEnv(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS),
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: boolFromEnv(process.env.OVERLEAF_SAML_FORCE_AUTHN),
disableRequestedAuthnContext: boolFromEnv(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT),
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: numFromEnv(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS),
// cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER,
logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL,
logoutCallbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/logout/callback`,
additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'),
passReqToCallback: true,
}
try {
passport.use(
new SAMLStrategy(
samlOptions,
SAMLAuthenticationController.doPassportLogin,
SAMLAuthenticationController.doPassportLogout
)
)
callback(null)
} catch (error) {
callback(error)
}
},
initPolicy() {
try {
PermissionsManager.registerCapability('change-password', { default : true })
} catch (error) {
logger.info({}, error.message)
}
const samlPolicyValidator = async ({ user, subscription }) => {
// If user is not logged in, user.externalAuth is undefined,
// in this case allow to change password if the user has a hashedPassword
return user.externalAuth === 'saml' || (user.externalAuth === undefined && !user.hashedPassword)
}
try {
PermissionsManager.registerPolicy(
'samlPolicy',
{ 'change-password' : false },
{ validator: samlPolicyValidator }
)
} catch (error) {
logger.info({}, error.message)
}
},
async getGroupPolicyForUser(user, callback) {
try {
const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({
user,
groupPolicy : { 'samlPolicy' : true },
subscription : null
})
let groupPolicy = Object.fromEntries(userValidationMap)
callback(null, {'groupPolicy' : groupPolicy })
} catch (error) {
callback(error)
}
},
}
export default SAMLModuleManager

View file

@ -0,0 +1,11 @@
import logger from '@overleaf/logger'
import SAMLAuthenticationController from './SAMLAuthenticationController.mjs'
export default {
apply(webRouter) {
logger.debug({}, 'Init SAML NonCsrfRouter')
webRouter.post('/saml/login/callback', SAMLAuthenticationController.passportLoginCallback)
webRouter.get ('/saml/logout/callback', SAMLAuthenticationController.passportLogoutCallback)
webRouter.post('/saml/logout/callback', SAMLAuthenticationController.passportLogoutCallback)
},
}

View file

@ -0,0 +1,16 @@
import logger from '@overleaf/logger'
import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js'
import UserController from '../../../../../app/src/Features/User/UserController.js'
import SAMLAuthenticationController from './SAMLAuthenticationController.mjs'
import logout from '../../../logout.mjs'
export default {
apply(webRouter) {
logger.debug({}, 'Init SAML router')
webRouter.get('/saml/login', SAMLAuthenticationController.passportLogin)
AuthenticationController.addEndpointToLoginWhitelist('/saml/login')
webRouter.get('/saml/meta', SAMLAuthenticationController.getSPMetadata)
AuthenticationController.addEndpointToLoginWhitelist('/saml/meta')
webRouter.post('/logout', logout, UserController.logout)
},
}

View file

@ -0,0 +1,18 @@
let samlModule = {}
if (process.env.EXTERNAL_AUTH.includes('saml')) {
const { default: SAMLModuleManager } = await import('./app/src/SAMLModuleManager.mjs')
const { default: router } = await import('./app/src/SAMLRouter.mjs')
const { default: nonCsrfRouter } = await import('./app/src/SAMLNonCsrfRouter.mjs')
SAMLModuleManager.initSettings()
SAMLModuleManager.initPolicy()
samlModule = {
name: 'saml-authentication',
hooks: {
passportSetup: SAMLModuleManager.passportSetup,
getGroupPolicyForUser: SAMLModuleManager.getGroupPolicyForUser,
},
router: router,
nonCsrfRouter: nonCsrfRouter,
}
}
export default samlModule

View file

@ -0,0 +1,42 @@
import fs from 'fs'
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
}
}
}
function numFromEnv(env) {
return env ? Number(env) : undefined
}
function boolFromEnv(env) {
if (env === undefined || env === null) return undefined
if (typeof env === "string") {
const envLower = env.toLowerCase()
if (envLower === 'true') return true
if (envLower === 'false') return false
}
throw new Error("Invalid value for boolean envirionment variable")
}
function 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];
}
export {
readFilesContentFromEnv,
numFromEnv,
boolFromEnv,
splitFullName,
}

View file

@ -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) {

View file

@ -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
| &nbsp;
p
a(href="/project").btn.btn-primary
| Start Using #{settings.appName}
br

View file

@ -0,0 +1,2 @@
import logger from '@overleaf/logger'
logger.debug({}, 'Enable Symbol Palette')

View file

@ -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

View file

@ -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
)
},
}

View file

@ -0,0 +1,2 @@
const TrackChangesRouter = require('./app/src/TrackChangesRouter')
module.exports = { router : TrackChangesRouter }

View file

@ -152,6 +152,7 @@
"passport-ldapauth": "^2.1.4",
"passport-local": "^1.0.0",
"passport-oauth2": "^1.5.0",
"passport-openidconnect": "^0.1.2",
"passport-orcid": "0.0.4",
"pug": "^3.0.3",
"pug-runtime": "^3.0.1",
@ -212,6 +213,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",