Compare commits

...

9 commits

Author SHA1 Message Date
yu-i-i
f300441211 Update README.md 2024-12-12 18:49:19 +01:00
yu-i-i
e76ca07da2 Enable Symbol Palette 2024-12-12 18:49:19 +01:00
yu-i-i
3e7d1d3160 Allow selecting a TeX Live image for a project 2024-12-12 18:49:19 +01:00
Sam Van den Vonder
12690a9b06 Enable Sandboxed Compiles feature 2024-12-12 18:49:04 +01:00
yu-i-i
5d68b00a7c Enable autocomplete of reference keys feature 2024-12-12 18:27:59 +01:00
yu-i-i
409293cb15 Enable track changes and comments feature 2024-12-12 18:25:29 +01:00
yu-i-i
b44addd5a1 Enable LDAP and SAML authentication support 2024-12-12 18:25:21 +01:00
yu-i-i
4345ff5a22 Redirect non-existing links to Overleaf page 2024-12-12 18:20:38 +01:00
Miguel Serrano
f0022392ac Merge pull request #21327 from overleaf/msm-optional-subnet-rate-limiter
[web] Add option to disable subnet rate limiting (+CE/SP Hotfix `5.2.1`)

GitOrigin-RevId: 78d60c9638cede729dd93c3c2421f55b34c0dbfe
2024-11-29 15:11:35 +01:00
65 changed files with 3537 additions and 46 deletions

675
README.md
View file

@ -14,30 +14,676 @@
<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
- Real-time track changes and comments
- Autocomplete of reference keys
- Symbol Palette
## Enterprise
If you want help installing and maintaining Overleaf in your lab or workplace, we offer an officially supported version called [Overleaf Server Pro](https://www.overleaf.com/for/enterprises). It also includes more features for security (SSO with LDAP or SAML), administration and collaboration (e.g. tracked changes). [Find out more!](https://www.overleaf.com/for/enterprises)
## Keeping up to date
Sign up to the [mailing list](https://mailchi.mp/overleaf.com/community-edition-and-server-pro) to get updates on Overleaf releases and development.
If you want help installing and maintaining Overleaf in your lab or workplace, Overleaf offers an officially supported version called [Overleaf Server Pro](https://www.overleaf.com/for/enterprises).
## Installation
We have detailed installation instructions in the [Overleaf Toolkit](https://github.com/overleaf/toolkit/).
Detailed installation instructions can be found in the [Overleaf Toolkit](https://github.com/overleaf/toolkit/).
To run a custom image, add a file named docker-compose.override.yml with the following or similar content into the `overleaf-toolkit/config directory`:
## Upgrading
```yml
---
version: '2.2'
services:
sharelatex:
image: sharelatex/sharelatex:ext-ce
volumes:
- ../config/certs:/overleaf/certs
```
Here, the attached volume provides convenient access for the container to the certificates needed for SAML or LDAP authentication.
If you are upgrading from a previous version of Overleaf, please see the [Release Notes section on the Wiki](https://github.com/overleaf/overleaf/wiki#release-notes) for all of the versions between your current version and the version you are upgrading to.
If you want to build a Docker image of the extended CE based on the upstream v5.2.1 codebase, you can check out the corresponding tag by running:
```
git checkout v5.2.1-ext
```
Alternatively, you can download a prebuilt image from Docker Hub:
```
docker pull overleafcep/sharelatex:5.2.1-ext
```
Make sure to update the image name in overleaf-toolkit/config/docker-compose.override.yml accordingly.
## Sandboxed Compiles
To enable sandboxed compiles (also known as "Sibling containers"), set the following configuration options in `overleaf-toolkit/config/overleaf.rc`:
```
SERVER_PRO=true
SIBLING_CONTAINERS_ENABLED=true
```
The following environment variables are used to specify which TeX Live images to use for sandboxed compiles:
- `ALL_TEX_LIVE_DOCKER_IMAGES` **(required)**
* A comma-separated list of TeX Live images to use. These images will be downloaded or updated.
To skip downloading the images, set `SIBLING_CONTAINERS_PULL=false` in `config/overleaf.rc`.
- `ALL_TEX_LIVE_DOCKER_IMAGE_NAMES`
* A comma-separated list of friendly names for the images. If omitted, the version name will be used (e.g., `latest-full`).
- `TEX_LIVE_DOCKER_IMAGE` **(required)**
* The default TeX Live image that will be used for compiling new projects. The environment variable `ALL_TEX_LIVE_DOCKER_IMAGES` must include this image.
Users can select the image for their project in the project menu.
Here is an example where the default TeX Live image is `latest-full` from Docker Hub, but the `TL2023-historic` image can be used for older projects:
```
ALL_TEX_LIVE_DOCKER_IMAGES=texlive/texlive:latest-full, texlive/texlive:TL2023-historic
ALL_TEX_LIVE_DOCKER_IMAGE_NAMES=TeXLive 2024, TeXLive 2023
TEX_LIVE_DOCKER_IMAGE=texlive/texlive:latest-full
```
For additional details refer to
[Server Pro: Sandboxed Compiles](https://github.com/overleaf/overleaf/wiki/Server-Pro:-Sandboxed-Compiles) and
[Toolkit: Sandboxed Compiles](https://github.com/overleaf/toolkit/blob/master/doc/sandboxed-compiles.md).
<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
```
</details>
## Authentication Methods
The following authentication methods are supported: Local authentication, LDAP authentication, and SAML authentication. Local authentication is always active.
To enable LDAP or SAML authentication, the environment variable `EXTERNAL_AUTH` must be set to `ldap` or `saml`, respectively.
<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.
#### 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.
#### Environment Variables
- `OVERLEAF_SAML_IDENTITY_SERVICE_NAME`
* Display name for the identity service, used on the login page (default: `Login with SAML IdP`).
- `OVERLEAF_SAML_EMAIL_FIELD`
* Name of the Email field in user profile, default to 'nameID'.
- `OVERLEAF_SAML_FIRST_NAME_FIELD`
* Name of the firstName field in user profile, default to 'givenName'.
- `OVERLEAF_SAML_LAST_NAME_FIELD`
* Name of the lastName field in user profile, default to 'lastName'
- `OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN`
* If set to `true`, updates the user `first_name` and `last_name` field on login,
and turn off the user details form on `/user/settings` page.
- `OVERLEAF_SAML_ENTRYPOINT` **(required)**
* Entrypoint URL for the SAML identity service.
- Example: `https://idp.example.com/simplesaml/saml2/idp/SSOService.php`
- Azure Example: `https://login.microsoftonline.com/8b26b46a-6dd3-45c7-a104-f883f4db1f6b/saml2`
- `OVERLEAF_SAML_CALLBACK_URL` **(required)**
* Callback URL for Overleaf service. Should be the full URL of the `/saml/login/callback` path.
- Example: `https://my-overleaf-instance.com/saml/login/callback`
- `OVERLEAF_SAML_ISSUER` **(required)**
* The Issuer name.
- `OVERLEAF_SAML_AUDIENCE`
* Expected saml response Audience, defaults to value of `OVERLEAF_SAML_ISSUER`.
- `OVERLEAF_SAML_IDP_CERT` **(required)**
* Path to a file containing the Identity Provider's public certificate, used to validate the signatures of incoming SAML responses. If the Identity Provider has multiple valid signing certificates, then
it can be a JSON array of paths to the certificates.
- Example (one certificate): `/overleaf/certs/idp_cert.pem`
- Example (multiple certificates): `["/overleaf/certs/idp_cert.pem", "/overleaf/certs/idp_cert_old.pem"]`
- `OVERLEAF_SAML_PUBLIC_CERT`
* Path to a file containing public signing certificate used to embed in auth requests in order for the IdP to validate the signatures of the incoming SAML Request. It's required when setting up the [metadata endpoint](#metadata-for-the-identity-provider)
when the strategy is configured with a `OVERLEAF_SAML_PRIVATE_KEY`. A JSON array of paths to certificates can be provided to support certificate rotation. When supplying an array of certificates, the first entry in the array should match the
current `OVERLEAF_SAML_PRIVATE_KEY`. Additional entries in the array can be used to publish upcoming certificates to IdPs before changing the `OVERLEAF_SAML_PRIVATE_KEY`.
- `OVERLEAF_SAML_PRIVATE_KEY`
* Path to a file containing a PEM-formatted private key matching the `OVERLEAF_SAML_PUBLIC_CERT` used to sign auth requests sent by passport-saml.
- `OVERLEAF_SAML_DECRYPTION_CERT`
* Path to a file containing public certificate, used for the [metadata endpoint](#metadata-for-the-identity-provider).
- `OVERLEAF_SAML_DECRYPTION_PVK`
* Path to a file containing private key matching the `OVERLEAF_SAML_DECRYPTION_CERT` that will be used to attempt to decrypt any encrypted assertions that are received.
- `OVERLEAF_SAML_SIGNATURE_ALGORITHM`
* Optionally set the signature algorithm for signing requests,
valid values are 'sha1' (default), 'sha256' (prefered), 'sha512' (most secure, check if your IdP supports it).
- `OVERLEAF_SAML_ADDITIONAL_PARAMS`
* JSON dictionary of additional query params to add to all requests.
- `OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS`
* JSON dictionary of additional query params to add to 'authorize' requests.
- Example: `{"some_key": "some_value"}`
- `OVERLEAF_SAML_IDENTIFIER_FORMAT`
* Name identifier format to request from identity provider (default: `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`).
- `OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS`
* Time in milliseconds of skew that is acceptable between client and server when checking OnBefore and NotOnOrAfter assertion
condition validity timestamps. Setting to -1 will disable checking these conditions entirely. Default is 0.
- `OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX`
* `AttributeConsumingServiceIndex` attribute to add to AuthnRequest to instruct the IdP which attribute set to attach
to the response ([link](http://blog.aniljohn.com/2014/01/data-minimization-front-channel-saml-attribute-requests.html)).
- `OVERLEAF_SAML_AUTHN_CONTEXT`
* JSON array of name identifier format values to request auth context. Default: `["urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"]`.
- `OVERLEAF_SAML_FORCE_AUTHN`
* If `true`, the initial SAML request from the service provider specifies that the IdP should force re-authentication of the user,
even if they possess a valid session.
- `OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT`
* If `true`, do not request a specific auth context. For example, you can this this to `true` to allow additional contexts such as password-less logins (`urn:oasis:names:tc:SAML:2.0:ac:classes:X509`). Support for additional contexts is dependant on your IdP.
- `OVERLEAF_SAML_AUTHN_REQUEST_BINDING`
* If set to `HTTP-POST`, will request authentication from IdP via HTTP POST binding, otherwise defaults to HTTP-Redirect.
- `OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO`
* If `always`, then InResponseTo will be validated from incoming SAML responses.
* If `never`, then InResponseTo won't be validated (default).
* If `ifPresent`, then InResponseTo will only be validated if present in the incoming SAML response.
- `OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS`
* Defines the expiration time when a Request ID generated for a SAML request will not be valid if seen
in a SAML response in the `InResponseTo` field. Default: 28800000 (8 hours).
- `OVERLEAF_SAML_LOGOUT_URL`
* base address to call with logout requests (default: `entryPoint`).
- Example: `https://idp.example.com/simplesaml/saml2/idp/SingleLogoutService.php`
- `OVERLEAF_SAML_LOGOUT_CALLBACK_URL`
* Callback URL for IdP initiated logout. Should be the full URL of the `/saml/logot/callback` path.
With this value the `Location` attribute in the `SingleLogoutService` elements in the generated service provider metadata is populated with this value.
- Example: `https://my-overleaf-instance.com/saml/logout/callback`
- `OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS`
* JSON dictionary of additional query params to add to 'logout' requests.
- `OVERLEAF_SAML_IS_ADMIN_FIELD` and `OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE`
* When both environment variables are set, the login process updates `user.isAdmin = true` if the profile returned by the SAML IdP contains the attribute specified by
`OVERLEAF_SAML_IS_ADMIN_FIELD` and its value either matches `OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE` or is an array containing `OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE`,
otherwise `user.isAdmin` is set to `false`. If either of these variables is not set, then the admin status is only set to `true` during admin user.
creation in Launchpad.
#### Metadata for the Identity Provider
The current version of Overleaf CE includes and endpoint to retrieve Service Provider Metadata: `http://my-overleaf-instance.com/saml/meta`
The Identity Provider will need to be configured to recognize the Overleaf server as a "Service Provider". Consult the documentation for your SAML server for instructions on how to do this.
Below is an example of appropriate Service Provider metadata:
<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='Login with My IdP'
OVERLEAF_SAML_EMAIL_FIELD=mail
OVERLEAF_SAML_FIRST_NAME_FIELD=givenName
OVERLEAF_SAML_LAST_NAME_FIELD=sn
OVERLEAF_SAML_ENTRYPOINT=https://idp.example.com/simplesamlphp/saml2/idp/SSOService.php
OVERLEAF_SAML_CALLBACK_URL=https://my-overleaf-instance.com/saml/login/callback
OVERLEAF_SAML_LOGOUT_URL=https://idp.example.com/simplesamlphp/saml2/idp/SingleLogoutService.php
OVERLEAF_SAML_LOGOUT_CALLBACK_URL=https://my-overleaf-instance.com/saml/logout/callback
OVERLEAF_SAML_ISSUER=MyOverleaf
OVERLEAF_SAML_IDP_CERT=/overleaf/certs/idp_cert.pem
OVERLEAF_SAML_PUBLIC_CERT=/overleaf/certs/myol_cert.pem
OVERLEAF_SAML_PRIVATE_KEY=/overleaf/certs/myol_key.pem
OVERLEAF_SAML_DECRYPTION_CERT=/overleaf/certs/myol_decr_cert.pem
OVERLEAF_SAML_DECRYPTION_PVK=/overleaf/certs/myol_decr_key.pem
OVERLEAF_SAML_IS_ADMIN_FIELD=mail
OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE=overleaf.admin@example.com
```
</details>
</details>
## Overleaf Docker Image
@ -60,14 +706,11 @@ in which to run the Overleaf services. Baseimage uses the `runit` service
manager to manage services, and we add our init-scripts from the `server-ce/runit`
folder.
## Contributing
Please see the [CONTRIBUTING](CONTRIBUTING.md) file for information on contributing to the development of Overleaf.
## Authors
[The Overleaf Team](https://www.overleaf.com/about)
<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
SPELLING_HOST=spelling
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

@ -124,6 +124,13 @@ services:
volumes:
- redis-data:/data
references:
build:
context: ..
dockerfile: services/references/Dockerfile
env_file:
- dev.env
spelling:
build:
context: ..
@ -163,6 +170,7 @@ services:
- notifications
- project-history
- real-time
- references
- spelling
webpack:

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,6 +9,7 @@ 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 SPELLING_HOST=127.0.0.1
export WEB_HOST=127.0.0.1
export WEB_API_HOST=127.0.0.1

View file

@ -212,6 +212,11 @@ const settings = {
enabled: process.env.OVERLEAF_CSP_ENABLED !== 'false',
},
rateLimit: {
subnetRateLimiterDisabled:
process.env.SUBNET_RATE_LIMITER_DISABLED !== 'false',
},
// These credentials are used for authenticating api requests
// between services that may need to go over public channels
httpAuthUsers,
@ -449,6 +454,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,5 @@
FROM sharelatex/sharelatex:5.2.0
# Subnet rate limiter fix
COPY pr_21327.patch /
RUN cd / && patch -p0 < pr_21327.patch && rm pr_21327.patch

View file

@ -0,0 +1,36 @@
--- overleaf/services/web/app/src/infrastructure/RateLimiter.js
+++ overleaf/services/web/app/src/infrastructure/RateLimiter.js
@@ -39,7 +39,7 @@ class RateLimiter {
keyPrefix: `rate-limit:${name}`,
storeClient: rclient,
})
- if (opts.subnetPoints) {
+ if (opts.subnetPoints && !Settings.rateLimit?.subnetRateLimiterDisabled) {
this._subnetRateLimiter = new RateLimiterFlexible.RateLimiterRedis({
...opts,
points: opts.subnetPoints,
--- overleaf/services/web/config/settings.defaults.js
+++ overleaf/services/web/config/settings.defaults.js
@@ -777,6 +777,8 @@ module.exports = {
reloadModuleViewsOnEachRequest: process.env.NODE_ENV === 'development',
rateLimit: {
+ subnetRateLimiterDisabled:
+ process.env.SUBNET_RATE_LIMITER_DISABLED === 'true',
autoCompile: {
everyone: process.env.RATE_LIMIT_AUTO_COMPILE_EVERYONE || 100,
standard: process.env.RATE_LIMIT_AUTO_COMPILE_STANDARD || 25,
--- etc/overleaf/settings.js
+++ etc/overleaf/settings.js
@@ -212,6 +212,11 @@ const settings = {
enabled: process.env.OVERLEAF_CSP_ENABLED !== 'false',
},
+ rateLimit: {
+ subnetRateLimiterDisabled:
+ process.env.SUBNET_RATE_LIMITER_DISABLED !== 'false',
+ },
+
// These credentials are used for authenticating api requests
// between services that may need to go over public channels
httpAuthUsers,

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

@ -102,9 +102,9 @@ const AuthenticationController = {
// so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
// and send a `{redir: ""}` response on success
passport.authenticate(
'local',
Settings.ldap?.enable ? ['custom-fail-ldapauth','local'] : ['local'],
{ keepSessionInfo: true },
async function (err, user, info) {
async function (err, user, infoArray) {
if (err) {
return next(err)
}
@ -126,6 +126,7 @@ const AuthenticationController = {
return next(err)
}
} else {
let info = infoArray[0]
if (info.redir != null) {
return res.json({ redir: info.redir })
} else {

View file

@ -134,6 +134,10 @@ async function requestReset(req, res, next) {
return res.status(404).json({
message: req.i18n.translate('secondary_email_password_reset'),
})
} else if (status === 'external') {
return res.status(403).json({
message: req.i18n.translate('password_managed_externally'),
})
} else {
return res.status(404).json({
message: req.i18n.translate('cant_find_email'),

View file

@ -17,6 +17,10 @@ async function generateAndEmailResetToken(email) {
return null
}
if (!user.hashedPassword) {
return 'external'
}
if (user.email !== email) {
return 'secondary'
}

View file

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

@ -396,7 +396,7 @@ async function updateUserSettings(req, res, next) {
if (
newEmail == null ||
newEmail === user.email ||
req.externalAuthenticationSystemUsed()
(req.externalAuthenticationSystemUsed() && !user.hashedPassword)
) {
// end here, don't update email
SessionManager.setInSessionUser(req.session, {
@ -473,6 +473,7 @@ async function doLogout(req) {
}
async function logout(req, res, next) {
if (req?.session.saml_extce) return res.redirect(308, '/saml/logout')
const requestedRedirect = req.body.redirect
? UrlHelper.getSafeRedirectPath(req.body.redirect)
: undefined

View file

@ -39,7 +39,7 @@ class RateLimiter {
keyPrefix: `rate-limit:${name}`,
storeClient: rclient,
})
if (opts.subnetPoints) {
if (opts.subnetPoints && !Settings.rateLimit?.subnetRateLimiterDisabled) {
this._subnetRateLimiter = new RateLimiterFlexible.RateLimiterRedis({
...opts,
points: opts.subnetPoints,

View file

@ -1375,6 +1375,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')
})
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
@ -40,3 +40,12 @@ block content
span(data-ol-inflight="idle") #{translate("login")}
span(hidden data-ol-inflight="pending") #{translate("logging_in")}…
a.pull-right(href='/user/password/reset') #{translate("forgot_your_password")}?
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")}…

View file

@ -9,7 +9,7 @@ block vars
block append meta
meta(name="ol-hasPassword" data-type="boolean" content=hasPassword)
meta(name="ol-shouldAllowEditingDetails" data-type="boolean" content=shouldAllowEditingDetails)
meta(name="ol-shouldAllowEditingDetails" data-type="boolean" content=shouldAllowEditingDetails || hasPassword)
meta(name="ol-oauthProviders", data-type="json", content=oauthProviders)
meta(name="ol-institutionLinked", data-type="json", content=institutionLinked)
meta(name="ol-samlError", data-type="json", content=samlError)
@ -21,7 +21,7 @@ block append meta
meta(name="ol-ssoErrorMessage", content=ssoErrorMessage)
meta(name="ol-thirdPartyIds", data-type="json", content=thirdPartyIds || {})
meta(name="ol-passwordStrengthOptions", data-type="json", content=settings.passwordStrengthOptions || {})
meta(name="ol-isExternalAuthenticationSystemUsed" data-type="boolean" content=externalAuthenticationSystemUsed())
meta(name="ol-isExternalAuthenticationSystemUsed" data-type="boolean" content=externalAuthenticationSystemUsed() && !hasPassword)
meta(name="ol-user" data-type="json" content=user)
meta(name="ol-dropbox" data-type="json" content=dropbox)
meta(name="ol-github" data-type="json" content=github)

View file

@ -259,6 +259,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`,
},
@ -783,6 +786,8 @@ module.exports = {
reloadModuleViewsOnEachRequest: process.env.NODE_ENV === 'development',
rateLimit: {
subnetRateLimiterDisabled:
process.env.SUBNET_RATE_LIMITER_DISABLED === 'true',
autoCompile: {
everyone: process.env.RATE_LIMIT_AUTO_COMPILE_EVERYONE || 100,
standard: process.env.RATE_LIMIT_AUTO_COMPILE_STANDARD || 25,
@ -944,7 +949,7 @@ module.exports = {
pdfPreviewPromotions: [],
diagnosticActions: [],
sourceEditorCompletionSources: [],
sourceEditorSymbolPalette: [],
sourceEditorSymbolPalette: ['@/features/symbol-palette/components/symbol-palette'],
sourceEditorToolbarComponents: [],
editorPromotions: [],
langFeedbackLinkingWidgets: [],
@ -974,6 +979,10 @@ module.exports = {
'launchpad',
'server-ce-scripts',
'user-activate',
'symbol-palette',
'track-changes',
'ldap-authentication',
'saml-authentication',
],
viewIncludes: {},
@ -999,6 +1008,16 @@ module.exports = {
managedUsers: {
enabled: false,
},
allowedImageNames: (() => {
if (process.env.SANDBOXED_COMPILES != 'true') return null
const imageNames = parseTextExtensions(process.env.ALL_TEX_LIVE_DOCKER_IMAGES);
const imageDescs = parseTextExtensions(process.env.ALL_TEX_LIVE_DOCKER_IMAGE_NAMES);
return imageNames.map((imageName, index) => ({
imageName,
imageDesc: imageDescs[index] || imageName.split(':')[1],
}))
})()
}
module.exports.mergeWith = function (overrides) {

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

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

@ -132,6 +132,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",
@ -1149,6 +1150,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>",

View file

@ -145,7 +145,8 @@ const _LaunchpadController = {
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

@ -28,7 +28,7 @@ block vars
block append meta
meta(name="ol-adminUserExists" data-type="boolean" content=adminUserExists)
meta(name="ol-ideJsPath" content=buildJsPath('ide.js'))
meta(name="ol-ideJsPath" content=buildJsPath('ide-detached.js'))
block content
script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js')
@ -125,6 +125,45 @@ block content
span(data-ol-inflight="idle") #{translate("register")}
span(hidden data-ol-inflight="pending") #{translate("registering")}…
h3 #{translate('local_account')}
p
| #{translate('alternatively_create_local_admin_account')}
form(
data-ol-async-form
data-ol-register-admin
action="/launchpad/register_admin"
method="POST"
)
input(name='_csrf', type='hidden', value=csrfToken)
+formMessages()
.form-group
label(for='email') #{translate("email")}
input.form-control(
type='email',
name='email',
placeholder="email@example.com"
autocomplete="username"
required,
autofocus="true"
)
.form-group
label(for='password') #{translate("password")}
input.form-control#passwordField(
type='password',
name='password',
placeholder="********",
autocomplete="new-password"
required,
)
.actions
button.btn-primary.btn(
type='submit'
data-ol-disabled-inflight
)
span(data-ol-inflight="idle") #{translate("register")}
span(hidden data-ol-inflight="pending") #{translate("registering")}…
// Saml Form
if authMethod === 'saml'
h3 #{translate('saml')}
@ -136,6 +175,35 @@ block content
data-ol-register-admin
action="/launchpad/register_saml_admin"
method="POST"
)
input(name='_csrf', type='hidden', value=csrfToken)
+formMessages()
.form-group
label(for='email') #{translate("email")}
input.form-control(
name='email',
placeholder="email@example.com"
autocomplete="username"
required,
autofocus="true"
)
.actions
button.btn-primary.btn(
type='submit'
data-ol-disabled-inflight
)
span(data-ol-inflight="idle") #{translate("register")}
span(hidden data-ol-inflight="pending") #{translate("registering")}…
h3 #{translate('local_account')}
p
| #{translate('alternatively_create_local_admin_account')}
form(
data-ol-async-form
data-ol-register-admin
action="/launchpad/register_admin"
method="POST"
)
input(name='_csrf', type='hidden', value=csrfToken)
+formMessages()
@ -149,6 +217,15 @@ block content
required,
autofocus="true"
)
.form-group
label(for='password') #{translate("password")}
input.form-control#passwordField(
type='password',
name='password',
placeholder="********",
autocomplete="new-password"
required,
)
.actions
button.btn-primary.btn(
type='submit'
@ -219,7 +296,7 @@ block content
p
a(href="/admin").btn.btn-info
| Go To Admin Panel
| &nbsp;
p
a(href="/project").btn.btn-primary
| Start Using #{settings.appName}
br

View file

@ -0,0 +1,64 @@
import logger from '@overleaf/logger'
import LoginRateLimiter from '../../../../app/src/Features/Security/LoginRateLimiter.js'
import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js'
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
import AuthenticationManagerLdap from './AuthenticationManagerLdap.mjs'
const AuthenticationControllerLdap = {
async doPassportLdapLogin(req, ldapUser, done) {
let user, info
try {
;({ user, info } = await AuthenticationControllerLdap._doPassportLdapLogin(
req,
ldapUser
))
} catch (error) {
return done(error)
}
return done(undefined, user, info)
},
async _doPassportLdapLogin(req, ldapUser) {
const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
const auditLog = {
ipAddress: req.ip,
info: { method: 'LDAP password login', fromKnownDevice },
}
let user, isPasswordReused
try {
user = await AuthenticationManagerLdap.promises.findOrCreateLdapUser(ldapUser, auditLog)
} catch (error) {
return {
user: false,
info: handleAuthenticateErrors(error, req),
}
}
if (user && AuthenticationController.captchaRequiredForLogin(req, user)) {
return {
user: false,
info: {
text: req.i18n.translate('cannot_verify_user_not_robot'),
type: 'error',
errorReason: 'cannot_verify_user_not_robot',
status: 400,
},
}
} else if (user) {
// async actions
return { user, info: undefined }
} else { //something wrong
logger.debug({ email : ldapUser.mail }, 'failed LDAP log in')
return {
user: false,
info: {
type: 'error',
status: 500,
},
}
}
},
}
export const {
doPassportLdapLogin,
} = AuthenticationControllerLdap

View file

@ -0,0 +1,80 @@
import Settings from '@overleaf/settings'
import { callbackify } from '@overleaf/promise-utils'
import UserCreator from '../../../../app/src/Features/User/UserCreator.js'
import { User } from '../../../../app/src/models/User.js'
const AuthenticationManagerLdap = {
splitFullName(fullName) {
fullName = fullName.trim();
let lastSpaceIndex = fullName.lastIndexOf(' ');
let firstNames = fullName.substring(0, lastSpaceIndex).trim();
let lastName = fullName.substring(lastSpaceIndex + 1).trim();
return [firstNames, lastName];
},
async findOrCreateLdapUser(profile, auditLog) {
//user is already authenticated in Ldap
const {
attEmail,
attFirstName,
attLastName,
attName,
attAdmin,
valAdmin,
updateUserDetailsOnLogin,
} = Settings.ldap
const email = Array.isArray(profile[attEmail])
? profile[attEmail][0].toLowerCase()
: profile[attEmail].toLowerCase()
let nameParts = ["",""]
if ((!attFirstName || !attLastName) && attName) {
nameParts = this.splitFullName(profile[attName] || "")
}
const firstName = attFirstName ? (profile[attFirstName] || "") : nameParts[0]
let lastName = attLastName ? (profile[attLastName] || "") : nameParts[1]
if (!firstName && !lastName) lastName = email
let isAdmin = false
if( attAdmin && valAdmin ) {
isAdmin = (profile._groups?.length > 0) ||
(Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) :
profile[attAdmin] === valAdmin)
}
let user = await User.findOne({ 'email': email }).exec()
if( !user ) {
user = await UserCreator.promises.createNewUser(
{
email: email,
first_name: firstName,
last_name: lastName,
isAdmin: isAdmin,
holdingAccount: false,
}
)
await User.updateOne(
{ _id: user._id },
{ $set : { 'emails.0.confirmedAt' : Date.now() } }
).exec() //email of ldap user is confirmed
}
let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {}
if( attAdmin && valAdmin ) {
user.isAdmin = isAdmin
userDetails.isAdmin = isAdmin
}
const result = await User.updateOne(
{ _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails },
{}
).exec()
if (result.modifiedCount !== 1) {
throw new ParallelLoginError()
}
return user
},
}
export default {
findOrCreateLdapUser: callbackify(AuthenticationManagerLdap.findOrCreateLdapUser),
promises: AuthenticationManagerLdap,
}
export const {
splitFullName,
} = AuthenticationManagerLdap

View file

@ -0,0 +1,17 @@
import Settings from '@overleaf/settings'
function initLdapSettings() {
Settings.ldap = {
enable: true,
placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username',
attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail',
attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT,
attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT,
attName: process.env.OVERLEAF_LDAP_NAME_ATT,
attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT,
valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE,
updateUserDetailsOnLogin: String(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN ).toLowerCase() === 'true',
}
}
export default initLdapSettings

View file

@ -0,0 +1,136 @@
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import passport from 'passport'
import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js'
import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
import { splitFullName } from './AuthenticationManagerLdap.mjs'
async function fetchLdapContacts(userId, contacts) {
if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) {
return []
}
const ldapOpts = passport._strategy('custom-fail-ldapauth').options.server
const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap
const {
url,
timeout,
connectTimeout,
tlsOptions,
starttls,
bindDN,
bindCredentials,
} = ldapOpts
const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOpts.searchBase
const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub'
const ldapConfig = { url, timeout, connectTimeout, tlsOptions }
let ldapUsers
const client = ldapjs.createClient(ldapConfig)
try {
if (starttls) {
await _upgradeToTLS(client, tlsOptions)
}
await _bindLdap(client, bindDN, bindCredentials)
const filter = await _formContactsSearchFilter(client, ldapOpts, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER)
const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter }
ldapUsers = await _searchLdap(client, searchBase, searchOptions)
} catch (err) {
logger.warn({ err }, 'error in fetchLdapContacts')
return []
} finally {
client.unbind()
}
const newLdapContacts = ldapUsers.reduce((acc, ldapUser) => {
const email = Array.isArray(ldapUser[attEmail])
? ldapUser[attEmail][0]?.toLowerCase()
: ldapUser[attEmail]?.toLowerCase()
if (!email) return acc
if (!contacts.some(contact => contact.email === email)) {
let nameParts = ["",""]
if ((!attFirstName || !attLastName) && attName) {
nameParts = splitFullName(ldapUser[attName] || "")
}
const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0]
const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1]
acc.push({
first_name: firstName,
last_name: lastName,
email: email,
type: 'user',
})
}
return acc
}, [])
return newLdapContacts.sort((a, b) =>
a.last_name.localeCompare(b.last_name) ||
a.first_name.localeCompare(a.first_name) ||
a.email.localeCompare(b.email)
)
}
function _upgradeToTLS(client, tlsOptions) {
return new Promise((resolve, reject) => {
client.on('error', error => reject(new Error(`LDAP client error: ${error}`)))
client.on('connect', () => {
client.starttls(tlsOptions, null, error => {
if (error) {
reject(new Error(`StartTLS error: ${error}`))
} else {
resolve()
}
})
})
})
}
function _bindLdap(client, bindDN, bindCredentials) {
return new Promise((resolve, reject) => {
client.bind(bindDN, bindCredentials, error => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
function _searchLdap(client, baseDN, options) {
return new Promise((resolve, reject) => {
const searchEntries = []
client.search(baseDN, options, (error, res) => {
if (error) {
reject(error)
} else {
res.on('searchEntry', entry => searchEntries.push(entry.object))
res.on('error', reject)
res.on('end', () => resolve(searchEntries))
}
})
})
}
async function _formContactsSearchFilter(client, ldapOpts, userId, contactsFilter) {
const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY
if (!searchProperty) {
return contactsFilter
}
const email = await UserGetter.promises.getUserEmail(userId)
const searchOptions = {
scope: ldapOpts.searchScope,
attributes: [searchProperty],
filter: `(${Settings.ldap.attEmail}=${email})`,
}
const searchBase = ldapOpts.searchBase
const ldapUser = (await _searchLdap(client, searchBase, searchOptions))[0]
const searchPropertyValue = ldapUser ? ldapUser[searchProperty]
: process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING'
return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue)
}
export default fetchLdapContacts

View file

@ -0,0 +1,78 @@
import fs from 'fs'
import passport from 'passport'
import Settings from '@overleaf/settings'
import { doPassportLdapLogin } from './AuthenticationControllerLdap.mjs'
import { Strategy as LdapStrategy } from 'passport-ldapauth'
function _readFilesContentFromEnv(envVar) {
// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]'
if (!envVar) return undefined
try {
const parsedFileNames = JSON.parse(envVar)
return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8'))
} catch (error) {
if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name
return fs.readFileSync(envVar, 'utf8')
} else {
throw error
}
}
}
// custom responses on authentication failure
class CustomFailLdapStrategy extends LdapStrategy {
constructor(options, validate) {
super(options, validate);
this.name = 'custom-fail-ldapauth'
}
authenticate(req, options) {
const defaultFail = this.fail.bind(this)
this.fail = function(info, status) {
info.type = 'error'
info.key = 'invalid-password-retry-or-reset'
info.status = 401
return defaultFail(info, status)
}.bind(this)
super.authenticate(req, options)
}
}
const ldapServerOpts = {
url: process.env.OVERLEAF_LDAP_URL,
bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "",
bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "",
bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY,
searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE,
searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER,
searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub',
searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'),
groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE,
groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER,
groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub',
groupSearchAttributes: ["dn"],
groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY,
cache: String(process.env.OVERLEAF_LDAP_CACHE).toLowerCase() === 'true',
timeout: process.env.OVERLEAF_LDAP_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_TIMEOUT) : undefined,
connectTimeout: process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT) : undefined,
starttls: String(process.env.OVERLEAF_LDAP_STARTTLS).toLowerCase() === 'true',
tlsOptions: {
ca: _readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH),
rejectUnauthorized: String(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH).toLowerCase() === 'true',
}
}
function addLdapStrategy(passport) {
passport.use(
new CustomFailLdapStrategy(
{
server: ldapServerOpts,
passReqToCallback: true,
usernameField: 'email',
passwordField: 'password',
},
doPassportLdapLogin
)
)
}
export default addLdapStrategy

View file

@ -0,0 +1,30 @@
import initLdapSettings from './app/src/InitLdapSettings.mjs'
import addLdapStrategy from './app/src/LdapStrategy.mjs'
import fetchLdapContacts from './app/src/LdapContacts.mjs'
let ldapModule = {};
if (process.env.EXTERNAL_AUTH === 'ldap') {
initLdapSettings()
ldapModule = {
name: 'ldap-authentication',
hooks: {
passportSetup: function (passport, callback) {
try {
addLdapStrategy(passport)
callback(null)
} catch (error) {
callback(error)
}
},
getContacts: async function (userId, contacts, callback) {
try {
const newLdapContacts = await fetchLdapContacts(userId, contacts)
callback(null, newLdapContacts)
} catch (error) {
callback(error)
}
},
}
}
}
export default ldapModule

View file

@ -0,0 +1,160 @@
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import passport from 'passport'
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
import AuthenticationManagerSaml from './AuthenticationManagerSaml.mjs'
import UserController from '../../../../app/src/Features/User/UserController.js'
import UserSessionsManager from '../../../../app/src/Features/User/UserSessionsManager.js'
import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js'
import { xmlResponse } from '../../../../app/src/infrastructure/Response.js'
const AuthenticationControllerSaml = {
passportSamlAuthWithIdP(req, res, next) {
if ( passport._strategy('saml')._saml.options.authnRequestBinding === 'HTTP-POST') {
const csp = res.getHeader('Content-Security-Policy')
if (csp) {
res.setHeader(
'Content-Security-Policy',
csp.replace(/(?:^|\s)(default-src|form-action)[^;]*;?/g, '')
)
}
}
passport.authenticate('saml')(req, res, next)
},
passportSamlLogin(req, res, next) {
// This function is middleware which wraps the passport.authenticate middleware,
// so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
// and send a `{redir: ""}` response on success
passport.authenticate(
'saml',
{ keepSessionInfo: true },
async function (err, user, info) {
if (err) {
return next(err)
}
if (user) {
// `user` is either a user object or false
AuthenticationController.setAuditInfo(req, {
method: 'SAML login',
})
try {
await AuthenticationController.promises.finishLogin(user, req, res)
} catch (err) {
return next(err)
}
} else {
if (info.redir != null) {
return res.json({ redir: info.redir })
} else {
res.status(info.status || 200)
delete info.status
const body = { message: info }
const { errorReason } = info
if (errorReason) {
body.errorReason = errorReason
delete info.errorReason
}
return res.json(body)
}
}
}
)(req, res, next)
},
async doPassportSamlLogin(req, profile, done) {
let user, info
try {
;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogin(
req,
profile
))
} catch (error) {
return done(error)
}
return done(undefined, user, info)
},
async _doPassportSamlLogin(req, profile) {
const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
const auditLog = {
ipAddress: req.ip,
info: { method: 'SAML login', fromKnownDevice },
}
let user
try {
user = await AuthenticationManagerSaml.promises.findOrCreateSamlUser(profile, auditLog)
} catch (error) {
return {
user: false,
info: handleAuthenticateErrors(error, req),
}
}
if (user) {
req.session.saml_extce = {nameID : profile.nameID, sessionIndex : profile.sessionIndex}
return { user, info: undefined }
} else { //something wrong
logger.debug({ email : profile.mail }, 'failed SAML log in')
return {
user: false,
info: {
type: 'error',
text: 'Unknown error',
status: 500,
},
}
}
},
async passportSamlSPLogout(req, res, next) {
passport._strategy('saml').logout(req, async (err, url) => {
if (err) logger.error({ err }, 'can not generate logout url')
await UserController.promises.doLogout(req)
res.redirect(url)
})
},
passportSamlIdPLogout(req, res, next) {
passport.authenticate('saml')(req, res, (err) => {
if (err) return next(err)
res.redirect('/login');
})
},
async doPassportSamlLogout(req, profile, done) {
let user, info
try {
;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogout(
req,
profile
))
} catch (error) {
return done(error)
}
return done(undefined, user, info)
},
async _doPassportSamlLogout(req, profile) {
if (req?.session?.saml_extce?.nameID === profile.nameID &&
req?.session?.saml_extce?.sessionIndex === profile.sessionIndex) {
profile = req.user
}
await UserSessionsManager.promises.untrackSession(req.user, req.sessionID).catch(err => {
logger.warn({ err, userId: req.user._id }, 'failed to untrack session')
})
return { user: profile, info: undefined }
},
passportSamlMetadata(req, res) {
const samlStratery = passport._strategy('saml')
res.setHeader('Content-Disposition', `attachment; filename="${samlStratery._saml.options.issuer}-meta.xml"`)
xmlResponse(res,
samlStratery.generateServiceProviderMetadata(
samlStratery._saml.options.decryptionCert,
samlStratery._saml.options.signingCert
)
)
},
}
export const {
passportSamlAuthWithIdP,
passportSamlLogin,
passportSamlSPLogout,
passportSamlIdPLogout,
doPassportSamlLogin,
doPassportSamlLogout,
passportSamlMetadata,
} = AuthenticationControllerSaml

View file

@ -0,0 +1,60 @@
import Settings from '@overleaf/settings'
import UserCreator from '../../../../app/src/Features/User/UserCreator.js'
import { User } from '../../../../app/src/models/User.js'
const AuthenticationManagerSaml = {
async findOrCreateSamlUser(profile, auditLog) {
const {
attEmail,
attFirstName,
attLastName,
attAdmin,
valAdmin,
updateUserDetailsOnLogin,
} = Settings.saml
const email = Array.isArray(profile[attEmail])
? profile[attEmail][0].toLowerCase()
: profile[attEmail].toLowerCase()
const firstName = attFirstName ? profile[attFirstName] : ""
const lastName = attLastName ? profile[attLastName] : email
let isAdmin = false
if( attAdmin && valAdmin ) {
isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) :
profile[attAdmin] === valAdmin)
}
let user = await User.findOne({ 'email': email }).exec()
if( !user ) {
user = await UserCreator.promises.createNewUser(
{
email: email,
first_name: firstName,
last_name: lastName,
isAdmin: isAdmin,
holdingAccount: false,
}
)
await User.updateOne(
{ _id: user._id },
{ $set : { 'emails.0.confirmedAt' : Date.now() } }
).exec() //email of saml user is confirmed
}
let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {}
if( attAdmin && valAdmin ) {
user.isAdmin = isAdmin
userDetails.isAdmin = isAdmin
}
const result = await User.updateOne(
{ _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails },
{}
).exec()
if (result.modifiedCount !== 1) {
throw new ParallelLoginError()
}
return user
},
}
export default {
promises: AuthenticationManagerSaml,
}

View file

@ -0,0 +1,16 @@
import Settings from '@overleaf/settings'
function initSamlSettings() {
Settings.saml = {
enable: true,
identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Login with SAML IdP',
attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID',
attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName',
attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName',
attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD,
valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE,
updateUserDetailsOnLogin: String(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN).toLowerCase() === 'true',
}
}
export default initSamlSettings

View file

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

View file

@ -0,0 +1,14 @@
import logger from '@overleaf/logger'
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
import { passportSamlAuthWithIdP, passportSamlSPLogout, passportSamlMetadata} from './AuthenticationControllerSaml.mjs'
export default {
apply(webRouter) {
logger.debug({}, 'Init SAML router')
webRouter.get('/saml/login', passportSamlAuthWithIdP)
AuthenticationController.addEndpointToLoginWhitelist('/saml/login')
webRouter.post('/saml/logout', AuthenticationController.requireLogin(), passportSamlSPLogout)
webRouter.get('/saml/meta', passportSamlMetadata)
AuthenticationController.addEndpointToLoginWhitelist('/saml/meta')
},
}

View file

@ -0,0 +1,62 @@
import fs from 'fs'
import passport from 'passport'
import Settings from '@overleaf/settings'
import { doPassportSamlLogin, doPassportSamlLogout } from './AuthenticationControllerSaml.mjs'
import { Strategy as SamlStrategy } from '@node-saml/passport-saml'
function _readFilesContentFromEnv(envVar) {
// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]'
if (!envVar) return undefined
try {
const parsedFileNames = JSON.parse(envVar)
return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8'))
} catch (error) {
if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name
return fs.readFileSync(envVar, 'utf8')
} else {
throw error
}
}
}
const samlOptions = {
entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT,
callbackUrl: process.env.OVERLEAF_SAML_CALLBACK_URL,
issuer: process.env.OVERLEAF_SAML_ISSUER,
audience: process.env.OVERLEAF_SAML_AUDIENCE,
cert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT),
signingCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT),
privateKey: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY),
decryptionCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT),
decryptionPvk: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK),
signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM,
additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'),
additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'),
identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT,
acceptedClockSkewMs: process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS ? Number(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS) : undefined,
attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX,
authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined,
forceAuthn: String(process.env.OVERLEAF_SAML_FORCE_AUTHN).toLowerCase() === 'true',
disableRequestedAuthnContext: String(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT).toLowerCase() === 'true',
skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST
authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING,
validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO,
requestIdExpirationPeriodMs: process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS ? Number(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS) : undefined,
// cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER,
logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL,
logoutCallbackUrl: process.env.OVERLEAF_SAML_LOGOUT_CALLBACK_URL,
additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'),
passReqToCallback: true,
}
function addSamlStrategy(passport) {
passport.use(
new SamlStrategy(
samlOptions,
doPassportSamlLogin,
doPassportSamlLogout
)
)
}
export default addSamlStrategy

View file

@ -0,0 +1,26 @@
import initSamlSettings from './app/src/InitSamlSettings.mjs'
import addSamlStrategy from './app/src/SamlStrategy.mjs'
import SamlRouter from './app/src/SamlRouter.mjs'
import SamlNonCsrfRouter from './app/src/SamlNonCsrfRouter.mjs'
let samlModule = {};
if (process.env.EXTERNAL_AUTH === 'saml') {
initSamlSettings()
samlModule = {
name: 'saml-authentication',
hooks: {
passportSetup: function (passport, callback) {
try {
addSamlStrategy(passport)
callback(null)
} catch (error) {
callback(error)
}
},
},
router: SamlRouter,
nonCsrfRouter: SamlNonCsrfRouter,
}
}
export default samlModule

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

@ -217,6 +217,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#1b1f93c0bcd04293aea6986aa2275185b2c56803",
"@replit/codemirror-vim": "overleaf/codemirror-vim#51ce0933e95705268256467fbbbcce5999ed3624",

View file

@ -19,11 +19,13 @@ describe('RateLimiter', function () {
this.RateLimiterFlexible = {
RateLimiterRedis: sinon.stub(),
}
this.Settings = {}
this.RateLimiter = SandboxedModule.require(modulePath, {
requires: {
'./RedisWrapper': this.RedisWrapper,
'rate-limiter-flexible': this.RateLimiterFlexible,
'@overleaf/settings': this.Settings,
},
})
})
@ -60,4 +62,34 @@ describe('RateLimiter', function () {
).to.throw()
})
})
describe('_subnetRateLimiter', function () {
it('should be defined by default', function () {
const rateLimiter = new this.RateLimiter.RateLimiter('some-limit', {
points: 20,
subnetPoints: 200,
duration: 60,
})
expect(rateLimiter._subnetRateLimiter).not.to.be.undefined
})
it('should be undefined when subnet rate limiting is disabled', function () {
this.Settings.rateLimit = { subnetRateLimiterDisabled: true }
const rateLimiter = new this.RateLimiter.RateLimiter('some-limit', {
points: 20,
subnetPoints: 200,
duration: 60,
})
expect(rateLimiter._subnetRateLimiter).to.be.undefined
})
it('should be undefined when subnetPoints are not passed as an option', function () {
const rateLimiter = new this.RateLimiter.RateLimiter('some-limit', {
points: 20,
duration: 60,
})
expect(rateLimiter._subnetRateLimiter).to.be.undefined
})
})
})