mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2025-07-29 23:00:08 +02:00
Compare commits
9 commits
ext-ce
...
v5.2.1-ext
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f300441211 | ||
![]() |
e76ca07da2 | ||
![]() |
3e7d1d3160 | ||
![]() |
12690a9b06 | ||
![]() |
5d68b00a7c | ||
![]() |
409293cb15 | ||
![]() |
b44addd5a1 | ||
![]() |
4345ff5a22 | ||
![]() |
f0022392ac |
65 changed files with 3537 additions and 46 deletions
675
README.md
675
README.md
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 |
23
patches/@node-saml+node-saml+4.0.5.patch
Normal file
23
patches/@node-saml+node-saml+4.0.5.patch
Normal 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);
|
64
patches/ldapauth-fork+4.3.3.patch
Normal file
64
patches/ldapauth-fork+4.3.3.patch
Normal 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'));
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
5
server-ce/hotfix/5.2.1/Dockerfile
Normal file
5
server-ce/hotfix/5.2.1/Dockerfile
Normal 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
|
36
server-ce/hotfix/5.2.1/pr_21327.patch
Normal file
36
server-ce/hotfix/5.2.1/pr_21327.patch
Normal 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,
|
12
server-ce/runit/references-overleaf/run
Executable file
12
server-ce/runit/references-overleaf/run
Executable 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
|
|
@ -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,
|
||||
|
|
10
services/references/README.md
Normal file
10
services/references/README.md
Normal 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
|
40
services/references/app.js
Normal file
40
services/references/app.js
Normal 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()
|
||||
})
|
||||
})
|
42
services/references/app/js/ReferencesAPIController.js
Normal file
42
services/references/app/js/ReferencesAPIController.js
Normal 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." })
|
||||
}
|
||||
}
|
||||
}
|
9
services/references/config/settings.defaults.cjs
Normal file
9
services/references/config/settings.defaults.cjs
Normal file
|
@ -0,0 +1,9 @@
|
|||
module.exports = {
|
||||
internal: {
|
||||
references: {
|
||||
port: 3056,
|
||||
host: process.env.REFERENCES_HOST || '127.0.0.1',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
26
services/references/package.json
Normal file
26
services/references/package.json
Normal 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"
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -17,6 +17,10 @@ async function generateAndEmailResetToken(email) {
|
|||
return null
|
||||
}
|
||||
|
||||
if (!user.hashedPassword) {
|
||||
return 'external'
|
||||
}
|
||||
|
||||
if (user.email !== email) {
|
||||
return 'secondary'
|
||||
}
|
||||
|
|
|
@ -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 } =
|
||||
|
|
|
@ -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, {})
|
||||
},
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")}…
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
/>,
|
||||
]}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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} />
|
||||
}
|
|
@ -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": ""
|
||||
}
|
||||
]
|
|
@ -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
|
||||
}
|
|
@ -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>",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -28,7 +28,7 @@ block vars
|
|||
|
||||
block append meta
|
||||
meta(name="ol-adminUserExists" data-type="boolean" content=adminUserExists)
|
||||
meta(name="ol-ideJsPath" content=buildJsPath('ide.js'))
|
||||
meta(name="ol-ideJsPath" content=buildJsPath('ide-detached.js'))
|
||||
|
||||
block content
|
||||
script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js')
|
||||
|
@ -125,6 +125,45 @@ block content
|
|||
span(data-ol-inflight="idle") #{translate("register")}
|
||||
span(hidden data-ol-inflight="pending") #{translate("registering")}…
|
||||
|
||||
h3 #{translate('local_account')}
|
||||
p
|
||||
| #{translate('alternatively_create_local_admin_account')}
|
||||
|
||||
form(
|
||||
data-ol-async-form
|
||||
data-ol-register-admin
|
||||
action="/launchpad/register_admin"
|
||||
method="POST"
|
||||
)
|
||||
input(name='_csrf', type='hidden', value=csrfToken)
|
||||
+formMessages()
|
||||
.form-group
|
||||
label(for='email') #{translate("email")}
|
||||
input.form-control(
|
||||
type='email',
|
||||
name='email',
|
||||
placeholder="email@example.com"
|
||||
autocomplete="username"
|
||||
required,
|
||||
autofocus="true"
|
||||
)
|
||||
.form-group
|
||||
label(for='password') #{translate("password")}
|
||||
input.form-control#passwordField(
|
||||
type='password',
|
||||
name='password',
|
||||
placeholder="********",
|
||||
autocomplete="new-password"
|
||||
required,
|
||||
)
|
||||
.actions
|
||||
button.btn-primary.btn(
|
||||
type='submit'
|
||||
data-ol-disabled-inflight
|
||||
)
|
||||
span(data-ol-inflight="idle") #{translate("register")}
|
||||
span(hidden data-ol-inflight="pending") #{translate("registering")}…
|
||||
|
||||
// Saml Form
|
||||
if authMethod === 'saml'
|
||||
h3 #{translate('saml')}
|
||||
|
@ -136,6 +175,35 @@ block content
|
|||
data-ol-register-admin
|
||||
action="/launchpad/register_saml_admin"
|
||||
method="POST"
|
||||
)
|
||||
input(name='_csrf', type='hidden', value=csrfToken)
|
||||
+formMessages()
|
||||
.form-group
|
||||
label(for='email') #{translate("email")}
|
||||
input.form-control(
|
||||
name='email',
|
||||
placeholder="email@example.com"
|
||||
autocomplete="username"
|
||||
required,
|
||||
autofocus="true"
|
||||
)
|
||||
.actions
|
||||
button.btn-primary.btn(
|
||||
type='submit'
|
||||
data-ol-disabled-inflight
|
||||
)
|
||||
span(data-ol-inflight="idle") #{translate("register")}
|
||||
span(hidden data-ol-inflight="pending") #{translate("registering")}…
|
||||
|
||||
h3 #{translate('local_account')}
|
||||
p
|
||||
| #{translate('alternatively_create_local_admin_account')}
|
||||
|
||||
form(
|
||||
data-ol-async-form
|
||||
data-ol-register-admin
|
||||
action="/launchpad/register_admin"
|
||||
method="POST"
|
||||
)
|
||||
input(name='_csrf', type='hidden', value=csrfToken)
|
||||
+formMessages()
|
||||
|
@ -149,6 +217,15 @@ block content
|
|||
required,
|
||||
autofocus="true"
|
||||
)
|
||||
.form-group
|
||||
label(for='password') #{translate("password")}
|
||||
input.form-control#passwordField(
|
||||
type='password',
|
||||
name='password',
|
||||
placeholder="********",
|
||||
autocomplete="new-password"
|
||||
required,
|
||||
)
|
||||
.actions
|
||||
button.btn-primary.btn(
|
||||
type='submit'
|
||||
|
@ -219,7 +296,7 @@ block content
|
|||
p
|
||||
a(href="/admin").btn.btn-info
|
||||
| Go To Admin Panel
|
||||
|
|
||||
p
|
||||
a(href="/project").btn.btn-primary
|
||||
| Start Using #{settings.appName}
|
||||
br
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
30
services/web/modules/ldap-authentication/index.mjs
Normal file
30
services/web/modules/ldap-authentication/index.mjs
Normal 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
|
|
@ -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
|
|
@ -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,
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
},
|
||||
}
|
|
@ -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')
|
||||
},
|
||||
}
|
|
@ -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
|
26
services/web/modules/saml-authentication/index.mjs
Normal file
26
services/web/modules/saml-authentication/index.mjs
Normal 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
|
2
services/web/modules/symbol-palette/index.mjs
Normal file
2
services/web/modules/symbol-palette/index.mjs
Normal file
|
@ -0,0 +1,2 @@
|
|||
import logger from '@overleaf/logger'
|
||||
logger.debug({}, 'Enable Symbol Palette')
|
|
@ -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
|
|
@ -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
|
||||
)
|
||||
},
|
||||
}
|
2
services/web/modules/track-changes/index.js
Normal file
2
services/web/modules/track-changes/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
const TrackChangesRouter = require('./app/src/TrackChangesRouter')
|
||||
module.exports = { router : TrackChangesRouter }
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue