Ldap support #2
@@ -76,3 +76,50 @@ SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM="RustPad <no-reply@example.com>"
|
||||
|
||||
# Authentication mode: local, ldap, or ad
|
||||
# local: built-in registration/login
|
||||
# ldap/ad: organization directory login; local registration and guest access are disabled
|
||||
AUTHORIZATION_TYPE=local
|
||||
|
||||
# Shared LDAP / Active Directory connection settings
|
||||
# LDAP_URL=ldap://10.0.0.22:389
|
||||
# LDAP_STARTTLS=false
|
||||
|
||||
# Verify the LDAP server certificate for LDAPS/StartTLS.
|
||||
# Set false only for trusted internal/test servers with a self-signed certificate.
|
||||
# This allows encrypted TLS without mounting a custom CA certificate.
|
||||
LDAP_TLS_VERIFY=true
|
||||
|
||||
# Connection and LDAP operation timeouts.
|
||||
LDAP_CONNECT_TIMEOUT_SECONDS=5
|
||||
LDAP_OPERATION_TIMEOUT_SECONDS=10
|
||||
# For LDAPS: LDAP_URL=ldaps://ldap.example.org:636 and LDAP_STARTTLS=false
|
||||
# For StartTLS: LDAP_URL=ldap://ldap.example.org:389 and LDAP_STARTTLS=true
|
||||
# LDAP_BIND_DN=cn=admin,dc=example,dc=org
|
||||
# LDAP_BIND_PASSWORD=admin
|
||||
# LDAP_BASE_DN=ou=people,dc=example,dc=org
|
||||
# LDAP_ORGANIZATION=example
|
||||
# LDAP_EMAIL_ATTRIBUTE=mail
|
||||
# LDAP_DISPLAY_NAME_ATTRIBUTE=displayName
|
||||
|
||||
# Stable directory identifier. Defaults:
|
||||
# ldap: entryUUID
|
||||
# ad: objectGUID
|
||||
# LDAP_EXTERNAL_ID_ATTRIBUTE=entryUUID
|
||||
|
||||
# Reject directory users without a valid mail attribute.
|
||||
LDAP_EMAIL_REQUIRED=true
|
||||
|
||||
# Link an existing account with the same e-mail on first directory login.
|
||||
# Keep false unless you intentionally migrate existing local/legacy LDAP accounts.
|
||||
LDAP_LINK_EXISTING_BY_EMAIL=false
|
||||
|
||||
# Optional overrides. Defaults depend on AUTHORIZATION_TYPE:
|
||||
# ldap: LDAP_USER_FILTER=(uid={username}), LDAP_USERNAME_ATTRIBUTE=uid
|
||||
# ad: LDAP_USER_FILTER=(|(sAMAccountName={username})(userPrincipalName={username}))
|
||||
# LDAP_USERNAME_ATTRIBUTE=sAMAccountName
|
||||
# LDAP_USER_FILTER=(uid={username})
|
||||
# LDAP_USERNAME_ATTRIBUTE=uid
|
||||
# To allow login by either username or e-mail:
|
||||
# LDAP_USER_FILTER=(|(uid={username})(mail={username}))
|
||||
Generated
+168
-2
@@ -47,6 +47,45 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.91"
|
||||
@@ -909,6 +948,20 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -1140,6 +1193,21 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
@@ -1213,6 +1281,7 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
@@ -1762,6 +1831,41 @@ dependencies = [
|
||||
"spin 0.9.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lber"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbcf559624bfd9fe8d488329a8959766335a43a9b8b2cdd6a2c379fca02909a5"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ldap3"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01fe89f5e7cfb7e4701e3a38ff9f00358e026a9aee940355d88ee9d81e5c7503"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"lber",
|
||||
"log",
|
||||
"nom 7.1.3",
|
||||
"percent-encoding",
|
||||
"rustls 0.23.42",
|
||||
"rustls-native-certs",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"url",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.22"
|
||||
@@ -1778,7 +1882,7 @@ dependencies = [
|
||||
"httpdate",
|
||||
"idna",
|
||||
"mime",
|
||||
"nom",
|
||||
"nom 8.0.0",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rustls 0.23.42",
|
||||
@@ -1911,6 +2015,12 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
@@ -1939,6 +2049,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
@@ -1957,6 +2077,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -2008,6 +2138,15 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2360,6 +2499,15 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.21.12"
|
||||
@@ -2433,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.0.29"
|
||||
version = "0.0.32"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
@@ -2445,6 +2593,7 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"ldap3",
|
||||
"lettre",
|
||||
"mime_guess",
|
||||
"rand_core 0.6.4",
|
||||
@@ -3750,6 +3899,23 @@ version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"lazy_static",
|
||||
"nom 7.1.3",
|
||||
"oid-registry",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmlparser"
|
||||
version = "0.13.6"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustpad"
|
||||
version = "0.0.29"
|
||||
version = "0.0.32"
|
||||
edition = "2024"
|
||||
rust-version = "1.94"
|
||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||
@@ -18,6 +18,7 @@ dotenvy = "0.15"
|
||||
futures-util = "0.3"
|
||||
mime_guess = "2"
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] }
|
||||
ldap3 = { version = "0.12", default-features = false, features = ["tls-rustls-ring"] }
|
||||
sha2 = "0.10"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -157,3 +157,94 @@ docker compose --profile s3 up -d --build
|
||||
```
|
||||
|
||||
Garage runs as a separate Compose service. Existing PostgreSQL and MySQL profiles remain unchanged. The included single-node setup is intended for local or self-hosted development without redundancy. Production Garage deployments should use a properly designed multi-node configuration.
|
||||
|
||||
## Authentication: local, LDAP, LDAPS, or Active Directory
|
||||
|
||||
Choose exactly one authentication backend:
|
||||
|
||||
```env
|
||||
AUTHORIZATION_TYPE=local
|
||||
```
|
||||
|
||||
Supported values:
|
||||
|
||||
- `local` - built-in registration and password login
|
||||
- `ldap` - OpenLDAP-compatible directory
|
||||
- `ad` - Microsoft Active Directory defaults
|
||||
|
||||
For `ldap` and `ad`, RustPad searches the directory with the service account, validates the password by binding as the user, and automatically provisions a local account. Existing sessions, ownership, sharing, and other account functions continue to use the existing `users` table. Local registration and guest nickname access are disabled.
|
||||
|
||||
The nickname is generated as `LDAP_ORGANIZATION/displayName`, for example `example/Mateusz Testowy`. Directory accounts are stored using a stable `entryUUID` (LDAP) or `objectGUID` (AD), while e-mail, nickname, and DN are synchronized after every successful login.
|
||||
|
||||
### OpenLDAP
|
||||
|
||||
```env
|
||||
AUTHORIZATION_TYPE=ldap
|
||||
LDAP_URL=ldap://10.87.2.6:389
|
||||
LDAP_STARTTLS=false
|
||||
LDAP_BIND_DN=cn=admin,dc=example,dc=org
|
||||
LDAP_BIND_PASSWORD=admin
|
||||
LDAP_BASE_DN=ou=people,dc=example,dc=org
|
||||
LDAP_ORGANIZATION=example
|
||||
```
|
||||
|
||||
In `ldap` mode the defaults are:
|
||||
|
||||
```env
|
||||
LDAP_USER_FILTER=(uid={username})
|
||||
LDAP_USERNAME_ATTRIBUTE=uid
|
||||
LDAP_EMAIL_ATTRIBUTE=mail
|
||||
LDAP_DISPLAY_NAME_ATTRIBUTE=displayName
|
||||
```
|
||||
|
||||
### Active Directory
|
||||
|
||||
```env
|
||||
AUTHORIZATION_TYPE=ad
|
||||
LDAP_URL=ldaps://ad.example.org:636
|
||||
LDAP_STARTTLS=false
|
||||
LDAP_BASE_DN=DC=example,DC=org
|
||||
LDAP_BIND_DN=CN=rustpad-bind,OU=Service Accounts,DC=example,DC=org
|
||||
LDAP_BIND_PASSWORD=secret
|
||||
LDAP_ORGANIZATION=example
|
||||
```
|
||||
|
||||
In `ad` mode the defaults are:
|
||||
|
||||
```env
|
||||
LDAP_USER_FILTER=(|(sAMAccountName={username})(userPrincipalName={username}))
|
||||
LDAP_USERNAME_ATTRIBUTE=sAMAccountName
|
||||
LDAP_EMAIL_ATTRIBUTE=mail
|
||||
LDAP_DISPLAY_NAME_ATTRIBUTE=displayName
|
||||
```
|
||||
|
||||
All LDAP attributes and filters can still be overridden explicitly. By default TLS certificates are verified. For a trusted internal or test server using a self-signed certificate, `LDAP_TLS_VERIFY=false` keeps LDAPS/StartTLS encryption enabled without requiring a custom CA file. Do not disable verification on untrusted networks.
|
||||
|
||||
Additional directory options:
|
||||
|
||||
```env
|
||||
LDAP_EXTERNAL_ID_ATTRIBUTE=entryUUID
|
||||
LDAP_EMAIL_REQUIRED=true
|
||||
LDAP_LINK_EXISTING_BY_EMAIL=false
|
||||
LDAP_TLS_VERIFY=true
|
||||
LDAP_CONNECT_TIMEOUT_SECONDS=5
|
||||
LDAP_OPERATION_TIMEOUT_SECONDS=10
|
||||
```
|
||||
|
||||
For Active Directory, the default external identifier is `objectGUID`. Set `LDAP_LINK_EXISTING_BY_EMAIL=true` only during an intentional migration of existing local or legacy LDAP accounts; otherwise an e-mail collision is rejected.
|
||||
|
||||
### Test LDAP on 10.87.2.6
|
||||
|
||||
```bash
|
||||
cd docker/ldap
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Test users:
|
||||
|
||||
- `mateusz` / `test1234`
|
||||
- `anna` / `test1234`
|
||||
|
||||
phpLDAPadmin: `http://10.87.2.6:8088`
|
||||
|
||||
Administrator: `cn=admin,dc=example,dc=org` / `admin`
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-false}
|
||||
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20}
|
||||
REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-false}
|
||||
AUTHORIZATION_TYPE: ${AUTHORIZATION_TYPE:-local}
|
||||
ACCOUNT_CONFIRMATION_REQUIRED: ${ACCOUNT_CONFIRMATION_REQUIRED:-false}
|
||||
ANONYMOUS_ACCESS_TOKEN_TTL_DAYS: ${ANONYMOUS_ACCESS_TOKEN_TTL_DAYS:-7}
|
||||
USER_SESSION_TTL_DAYS: ${USER_SESSION_TTL_DAYS:-30}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
dn: ou=people,dc=organization,dc=local
|
||||
objectClass: organizationalUnit
|
||||
ou: people
|
||||
|
||||
dn: uid=admin,ou=people,dc=organization,dc=local
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: organizationalPerson
|
||||
objectClass: person
|
||||
objectClass: top
|
||||
uid: admin
|
||||
cn: Organization Administrator
|
||||
sn: Administrator
|
||||
displayName: Organization Administrator
|
||||
mail: admin@organization.local
|
||||
userPassword: test1234!
|
||||
|
||||
dn: uid=user,ou=people,dc=organization,dc=local
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: organizationalPerson
|
||||
objectClass: person
|
||||
objectClass: top
|
||||
uid: user
|
||||
cn: Organization User
|
||||
sn: User
|
||||
displayName: Organization User
|
||||
mail: user@organization.local
|
||||
userPassword: test1234!
|
||||
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
ldap:
|
||||
image: osixia/openldap:1.5.0
|
||||
container_name: rustpad-test-ldap
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LDAP_ORGANISATION: RustPad Test
|
||||
LDAP_DOMAIN: organization.local
|
||||
LDAP_ADMIN_PASSWORD: admin
|
||||
LDAP_CONFIG_PASSWORD: admin
|
||||
LDAP_TLS: "false"
|
||||
ports:
|
||||
- "12389:389"
|
||||
volumes:
|
||||
- ./bootstrap:/container/service/slapd/assets/config/bootstrap/ldif/custom:ro
|
||||
command: --copy-service
|
||||
|
||||
ldap-admin:
|
||||
image: osixia/phpldapadmin:0.9.0
|
||||
container_name: rustpad-test-ldap-admin
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PHPLDAPADMIN_LDAP_HOSTS: ldap
|
||||
PHPLDAPADMIN_HTTPS: "false"
|
||||
ports:
|
||||
- "12390:80"
|
||||
depends_on:
|
||||
- ldap
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN auth_provider VARCHAR(16) NOT NULL DEFAULT 'local',
|
||||
ADD COLUMN external_id VARCHAR(512) NULL,
|
||||
ADD COLUMN external_dn TEXT NULL,
|
||||
ADD UNIQUE INDEX idx_users_external_identity (auth_provider, external_id);
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local';
|
||||
ALTER TABLE users ADD COLUMN external_id TEXT;
|
||||
ALTER TABLE users ADD COLUMN external_dn TEXT;
|
||||
CREATE UNIQUE INDEX idx_users_external_identity ON users(auth_provider, external_id) WHERE external_id IS NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local';
|
||||
ALTER TABLE users ADD COLUMN external_id TEXT;
|
||||
ALTER TABLE users ADD COLUMN external_dn TEXT;
|
||||
CREATE UNIQUE INDEX idx_users_external_identity ON users(auth_provider, external_id) WHERE external_id IS NOT NULL;
|
||||
+9
-1
@@ -180,6 +180,7 @@ async fn home(State(state): State<SharedState>) -> Response {
|
||||
include_str!("../static/home.html"),
|
||||
&state.asset_version,
|
||||
state.registration_enabled,
|
||||
state.ldap.is_some(),
|
||||
&state.frontend_log_level,
|
||||
state.upload_max_size_bytes,
|
||||
"home",
|
||||
@@ -195,6 +196,7 @@ async fn pad(State(state): State<SharedState>, Path(slug): Path<String>) -> Resp
|
||||
&html,
|
||||
&state.asset_version,
|
||||
state.registration_enabled,
|
||||
state.ldap.is_some(),
|
||||
&state.frontend_log_level,
|
||||
state.upload_max_size_bytes,
|
||||
"pad",
|
||||
@@ -222,6 +224,7 @@ async fn public_page(State(state): State<SharedState>, Path(token): Path<String>
|
||||
include_str!("../static/public.html"),
|
||||
&state.asset_version,
|
||||
state.registration_enabled,
|
||||
state.ldap.is_some(),
|
||||
&state.frontend_log_level,
|
||||
state.upload_max_size_bytes,
|
||||
"public",
|
||||
@@ -254,6 +257,7 @@ async fn workspace(
|
||||
&html,
|
||||
&state.asset_version,
|
||||
state.registration_enabled,
|
||||
state.ldap.is_some(),
|
||||
&state.frontend_log_level,
|
||||
state.upload_max_size_bytes,
|
||||
"workspace",
|
||||
@@ -308,6 +312,7 @@ async fn note(
|
||||
&html,
|
||||
&state.asset_version,
|
||||
state.registration_enabled,
|
||||
state.ldap.is_some(),
|
||||
&state.frontend_log_level,
|
||||
state.upload_max_size_bytes,
|
||||
"note",
|
||||
@@ -387,7 +392,10 @@ fn error_response(
|
||||
asset_version: &str,
|
||||
) -> Response {
|
||||
let html = include_str!("../static/error.html")
|
||||
.replace("__APP_STYLESHEET__", &assets::stylesheet_tag(asset_version, "styles"))
|
||||
.replace(
|
||||
"__APP_STYLESHEET__",
|
||||
&assets::stylesheet_tag(asset_version, "styles"),
|
||||
)
|
||||
.replace("__ERROR_CODE__", &escape_html(code))
|
||||
.replace("__ERROR_TITLE__", &escape_html(title))
|
||||
.replace("__ERROR_MESSAGE__", &escape_html(message))
|
||||
|
||||
+9
-3
@@ -27,12 +27,13 @@ pub fn render_html(
|
||||
template: &str,
|
||||
asset_version: &str,
|
||||
registration_enabled: bool,
|
||||
external_auth: bool,
|
||||
frontend_log_level: &str,
|
||||
upload_max_size_bytes: usize,
|
||||
entrypoint: &str,
|
||||
) -> Response {
|
||||
let urls = AssetUrls::new(asset_version);
|
||||
let frontend_config = frontend_config(frontend_log_level, upload_max_size_bytes);
|
||||
let frontend_config = frontend_config(frontend_log_level, upload_max_size_bytes, external_auth);
|
||||
let html = template
|
||||
.replace("__APP_STYLESHEET__", &urls.stylesheet("styles"))
|
||||
.replace("__APP_IMPORT_MAP__", &urls.import_map())
|
||||
@@ -59,11 +60,16 @@ pub fn stylesheet_tag(asset_version: &str, name: &str) -> String {
|
||||
AssetUrls::new(asset_version).stylesheet(name)
|
||||
}
|
||||
|
||||
fn frontend_config(frontend_log_level: &str, upload_max_size_bytes: usize) -> String {
|
||||
fn frontend_config(
|
||||
frontend_log_level: &str,
|
||||
upload_max_size_bytes: usize,
|
||||
external_auth: bool,
|
||||
) -> String {
|
||||
format!(
|
||||
r#"<script>window.__RUSTPAD_CONFIG__=Object.freeze({{frontendLogLevel:"{}",uploadMaxSizeBytes:{}}});</script>"#,
|
||||
r#"<script>window.__RUSTPAD_CONFIG__=Object.freeze({{frontendLogLevel:"{}",uploadMaxSizeBytes:{},externalAuth:{}}});</script>"#,
|
||||
escape_js_string(frontend_log_level),
|
||||
upload_max_size_bytes,
|
||||
external_auth,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
use chrono::Utc;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::{queries, state::SharedState};
|
||||
|
||||
use super::{
|
||||
AuthError, MAX_NICKNAME, SessionResponse, User, create_session, find_user_by_email,
|
||||
find_user_by_external_id, find_user_by_nickname, hash_password, normalize, random_token,
|
||||
validate_email,
|
||||
};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use ldap3::{LdapConnAsync, LdapConnSettings, Scope, SearchEntry};
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LdapConfig {
|
||||
pub url: String,
|
||||
pub starttls: bool,
|
||||
pub bind_dn: String,
|
||||
pub bind_password: String,
|
||||
pub base_dn: String,
|
||||
pub user_filter: String,
|
||||
pub username_attribute: String,
|
||||
pub email_attribute: String,
|
||||
pub display_name_attribute: String,
|
||||
pub external_id_attribute: String,
|
||||
pub organization: String,
|
||||
pub provider: String,
|
||||
pub email_required: bool,
|
||||
pub link_existing_by_email: bool,
|
||||
pub tls_verify: bool,
|
||||
pub connect_timeout_seconds: u64,
|
||||
pub operation_timeout_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LdapIdentity {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub nickname: String,
|
||||
pub provider: String,
|
||||
pub external_id: String,
|
||||
pub external_dn: String,
|
||||
}
|
||||
|
||||
pub async fn authenticate(
|
||||
config: &LdapConfig,
|
||||
login: &str,
|
||||
password: &str,
|
||||
) -> Result<Option<LdapIdentity>, String> {
|
||||
if login.trim().is_empty() || password.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let settings = LdapConnSettings::new()
|
||||
.set_starttls(config.starttls)
|
||||
.set_no_tls_verify(!config.tls_verify);
|
||||
let connect_timeout = Duration::from_secs(config.connect_timeout_seconds);
|
||||
let operation_timeout = Duration::from_secs(config.operation_timeout_seconds);
|
||||
let (connection, mut ldap) = timeout(
|
||||
connect_timeout,
|
||||
LdapConnAsync::with_settings(settings, &config.url),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "LDAP connection timed out".to_owned())?
|
||||
.map_err(|error| format!("LDAP connection failed: {error}"))?;
|
||||
ldap3::drive!(connection);
|
||||
|
||||
if !config.bind_dn.trim().is_empty() {
|
||||
let result = timeout(
|
||||
operation_timeout,
|
||||
ldap.simple_bind(&config.bind_dn, &config.bind_password),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "LDAP service bind timed out".to_owned())?
|
||||
.map_err(|error| format!("LDAP service bind failed: {error}"))?;
|
||||
result
|
||||
.success()
|
||||
.map_err(|error| format!("LDAP service bind rejected: {error}"))?;
|
||||
}
|
||||
|
||||
let escaped = escape_filter(login.trim());
|
||||
let filter = config.user_filter.replace("{username}", &escaped);
|
||||
let attributes = vec![
|
||||
config.username_attribute.as_str(),
|
||||
config.email_attribute.as_str(),
|
||||
config.display_name_attribute.as_str(),
|
||||
config.external_id_attribute.as_str(),
|
||||
];
|
||||
let (mut entries, _) = timeout(
|
||||
operation_timeout,
|
||||
ldap.search(&config.base_dn, Scope::Subtree, &filter, attributes.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "LDAP search timed out".to_owned())?
|
||||
.map_err(|error| format!("LDAP search failed: {error}"))?
|
||||
.success()
|
||||
.map_err(|error| format!("LDAP search rejected: {error}"))?;
|
||||
|
||||
if entries.is_empty() && login.trim().contains('@') {
|
||||
let email_filter = format!("({}={})", config.email_attribute, escaped);
|
||||
let (email_entries, _) = timeout(
|
||||
operation_timeout,
|
||||
ldap.search(&config.base_dn, Scope::Subtree, &email_filter, attributes),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "LDAP e-mail search timed out".to_owned())?
|
||||
.map_err(|error| format!("LDAP e-mail search failed: {error}"))?
|
||||
.success()
|
||||
.map_err(|error| format!("LDAP e-mail search rejected: {error}"))?;
|
||||
entries = email_entries;
|
||||
}
|
||||
|
||||
if entries.len() != 1 {
|
||||
let _ = ldap.unbind().await;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let entry = SearchEntry::construct(entries.into_iter().next().unwrap());
|
||||
let user_dn = entry.dn.clone();
|
||||
let username =
|
||||
first_attr(&entry, &config.username_attribute).unwrap_or_else(|| login.trim().to_owned());
|
||||
let email = first_attr(&entry, &config.email_attribute)
|
||||
.filter(|value| value.contains('@'))
|
||||
.or_else(|| {
|
||||
(!config.email_required && login.trim().contains('@')).then(|| login.trim().to_owned())
|
||||
});
|
||||
if email.is_none() {
|
||||
let _ = ldap.unbind().await;
|
||||
return Err(format!(
|
||||
"LDAP user is missing required e-mail attribute {}",
|
||||
config.email_attribute
|
||||
));
|
||||
}
|
||||
let email = email.unwrap_or_default();
|
||||
let external_id = first_attr_or_binary(&entry, &config.external_id_attribute)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"LDAP user is missing required external identifier attribute {}",
|
||||
config.external_id_attribute
|
||||
)
|
||||
})?;
|
||||
let display_name = first_attr(&entry, &config.display_name_attribute)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| username.clone());
|
||||
|
||||
let result = timeout(operation_timeout, ldap.simple_bind(&user_dn, password))
|
||||
.await
|
||||
.map_err(|_| "LDAP user bind timed out".to_owned())?
|
||||
.map_err(|error| format!("LDAP user bind failed: {error}"))?;
|
||||
if result.success().is_err() {
|
||||
let _ = ldap.unbind().await;
|
||||
return Ok(None);
|
||||
}
|
||||
let _ = ldap.unbind().await;
|
||||
|
||||
let organization = config.organization.trim();
|
||||
let nickname = if organization.is_empty() {
|
||||
display_name
|
||||
} else {
|
||||
format!("{organization}/{display_name}")
|
||||
};
|
||||
|
||||
Ok(Some(LdapIdentity {
|
||||
username,
|
||||
email,
|
||||
nickname,
|
||||
provider: config.provider.clone(),
|
||||
external_id,
|
||||
external_dn: user_dn,
|
||||
}))
|
||||
}
|
||||
|
||||
fn first_attr(entry: &SearchEntry, name: &str) -> Option<String> {
|
||||
entry
|
||||
.attrs
|
||||
.get(name)
|
||||
.and_then(|values| values.first())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn first_attr_or_binary(entry: &SearchEntry, name: &str) -> Option<String> {
|
||||
first_attr(entry, name).or_else(|| {
|
||||
entry
|
||||
.bin_attrs
|
||||
.get(name)
|
||||
.and_then(|values| values.first())
|
||||
.map(|value| {
|
||||
let mut output = String::with_capacity(value.len() * 2);
|
||||
for byte in value {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(&mut output, "{byte:02x}");
|
||||
}
|
||||
output
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn escape_filter(value: &str) -> String {
|
||||
let mut result = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
match byte {
|
||||
b'*' => result.push_str("\\2a"),
|
||||
b'(' => result.push_str("\\28"),
|
||||
b')' => result.push_str("\\29"),
|
||||
b'\\' => result.push_str("\\5c"),
|
||||
0 => result.push_str("\\00"),
|
||||
value if value < 0x20 || value >= 0x7f => result.push_str(&format!("\\{value:02x}")),
|
||||
value => result.push(value as char),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) async fn login(
|
||||
state: &SharedState,
|
||||
login: &str,
|
||||
password: &str,
|
||||
) -> Result<SessionResponse, AuthError> {
|
||||
let config = state
|
||||
.ldap
|
||||
.as_ref()
|
||||
.ok_or_else(|| AuthError::internal("LDAP authentication is not configured."))?;
|
||||
debug!(login = %login.trim(), "LDAP login requested");
|
||||
let identity = authenticate(config, login, password)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
tracing::error!(error = %error, "LDAP authentication error");
|
||||
AuthError::service_unavailable("The organization directory is currently unavailable.")
|
||||
})?
|
||||
.ok_or_else(|| AuthError::unauthorized("Invalid organization login or password."))?;
|
||||
let user = provision_ldap_user(state, identity).await?;
|
||||
let session = create_session(state, &user).await?;
|
||||
info!(user_id = user.id, nickname = %user.nickname, "LDAP login successful");
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
async fn provision_ldap_user(
|
||||
state: &SharedState,
|
||||
identity: LdapIdentity,
|
||||
) -> Result<User, AuthError> {
|
||||
let email = validate_email(&identity.email)?;
|
||||
if let Some(user) =
|
||||
find_user_by_external_id(state, &identity.provider, &identity.external_id).await?
|
||||
{
|
||||
return sync_directory_user(state, user, &identity, &email).await;
|
||||
}
|
||||
|
||||
if let Some(existing) = find_user_by_email(state, &email).await? {
|
||||
let link_by_email = state
|
||||
.ldap
|
||||
.as_ref()
|
||||
.map(|config| config.link_existing_by_email)
|
||||
.unwrap_or(false);
|
||||
if !link_by_email {
|
||||
return Err(AuthError::conflict(
|
||||
"This e-mail address already belongs to another account. Ask an administrator to link it or enable LDAP_LINK_EXISTING_BY_EMAIL.",
|
||||
));
|
||||
}
|
||||
return sync_directory_user(state, existing, &identity, &email).await;
|
||||
}
|
||||
|
||||
let nickname = available_directory_nickname(state, &identity.nickname, None).await?;
|
||||
let password_hash = hash_password(&random_token())?;
|
||||
let confirmed_at = Utc::now().to_rfc3339();
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_INSERT_DIRECTORY_USER,
|
||||
))
|
||||
.bind(&nickname)
|
||||
.bind(normalize(&nickname))
|
||||
.bind(&email)
|
||||
.bind(normalize(&email))
|
||||
.bind(password_hash)
|
||||
.bind(Some(confirmed_at))
|
||||
.bind(&identity.provider)
|
||||
.bind(&identity.external_id)
|
||||
.bind(&identity.external_dn)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
info!(ldap_username = %identity.username, nickname = %nickname, provider = %identity.provider, "provisioned directory user");
|
||||
find_user_by_external_id(state, &identity.provider, &identity.external_id)
|
||||
.await?
|
||||
.ok_or_else(|| AuthError::internal("Failed to provision the organization account."))
|
||||
}
|
||||
|
||||
async fn sync_directory_user(
|
||||
state: &SharedState,
|
||||
user: User,
|
||||
identity: &LdapIdentity,
|
||||
email: &str,
|
||||
) -> Result<User, AuthError> {
|
||||
let nickname = available_directory_nickname(state, &identity.nickname, Some(user.id)).await?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_UPDATE_DIRECTORY_USER,
|
||||
))
|
||||
.bind(&nickname)
|
||||
.bind(normalize(&nickname))
|
||||
.bind(email)
|
||||
.bind(normalize(email))
|
||||
.bind(&identity.provider)
|
||||
.bind(&identity.external_id)
|
||||
.bind(&identity.external_dn)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.bind(user.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
find_user_by_external_id(state, &identity.provider, &identity.external_id)
|
||||
.await?
|
||||
.ok_or_else(|| AuthError::internal("Failed to synchronize the organization account."))
|
||||
}
|
||||
|
||||
async fn available_directory_nickname(
|
||||
state: &SharedState,
|
||||
requested: &str,
|
||||
current_user_id: Option<i64>,
|
||||
) -> Result<String, AuthError> {
|
||||
let base = truncate_nickname(requested);
|
||||
let mut nickname = base.clone();
|
||||
let mut suffix = 1usize;
|
||||
loop {
|
||||
match find_user_by_nickname(state, &nickname).await? {
|
||||
None => return Ok(nickname),
|
||||
Some(user) if Some(user.id) == current_user_id => return Ok(nickname),
|
||||
Some(_) => {
|
||||
suffix += 1;
|
||||
let marker = format!("-{suffix}");
|
||||
nickname = format!(
|
||||
"{}{}",
|
||||
truncate_to_chars(&base, MAX_NICKNAME - marker.chars().count()),
|
||||
marker
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_nickname(value: &str) -> String {
|
||||
let value = value.trim();
|
||||
let value = if value.is_empty() { "ldap-user" } else { value };
|
||||
truncate_to_chars(value, MAX_NICKNAME)
|
||||
}
|
||||
|
||||
fn truncate_to_chars(value: &str, max: usize) -> String {
|
||||
value.chars().take(max).collect()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::state::SharedState;
|
||||
|
||||
use super::{
|
||||
AuthError, SessionResponse, create_session, email_domain, find_user_by_email, validate_email,
|
||||
verify_password,
|
||||
};
|
||||
|
||||
pub(super) async fn login(
|
||||
state: &SharedState,
|
||||
login: &str,
|
||||
password: &str,
|
||||
) -> Result<SessionResponse, AuthError> {
|
||||
let email = validate_email(login)?;
|
||||
debug!(email_domain = %email_domain(&email), "login requested");
|
||||
let user = find_user_by_email(state, &email)
|
||||
.await?
|
||||
.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?;
|
||||
if !verify_password(&user.password_hash, password) {
|
||||
warn!(user_id = user.id, "login rejected: invalid password");
|
||||
return Err(AuthError::unauthorized(
|
||||
"Invalid e-mail address or password.",
|
||||
));
|
||||
}
|
||||
if state.account_confirmation_required && user.confirmed_at.is_none() {
|
||||
return Err(AuthError::forbidden(
|
||||
"Confirm the account using the link sent by e-mail before logging in.",
|
||||
));
|
||||
}
|
||||
let session = create_session(state, &user).await?;
|
||||
info!(user_id = user.id, nickname = %user.nickname, "login successful");
|
||||
Ok(session)
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
pub(crate) mod ldap;
|
||||
mod local;
|
||||
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
@@ -161,6 +164,11 @@ pub async fn identity(
|
||||
State(state): State<SharedState>,
|
||||
Json(req): Json<IdentityRequest>,
|
||||
) -> Result<Json<IdentityResponse>, AuthError> {
|
||||
if state.ldap.is_some() && req.session_token.is_none() {
|
||||
return Err(AuthError::unauthorized(
|
||||
"Log in with your organization account.",
|
||||
));
|
||||
}
|
||||
let nickname = validate_nickname(&req.nickname)?;
|
||||
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
|
||||
match find_user_by_nickname(&state, &nickname).await? {
|
||||
@@ -196,6 +204,11 @@ pub async fn register(
|
||||
State(state): State<SharedState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<RegisterResponse>), AuthError> {
|
||||
if state.ldap.is_some() {
|
||||
return Err(AuthError::forbidden(
|
||||
"Local registration is disabled while LDAP authentication is enabled.",
|
||||
));
|
||||
}
|
||||
if !state.registration_enabled {
|
||||
warn!("registration attempt rejected because registration is disabled");
|
||||
return Err(AuthError::forbidden("Registration is disabled."));
|
||||
@@ -324,25 +337,15 @@ pub async fn login(
|
||||
State(state): State<SharedState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<SessionResponse>, AuthError> {
|
||||
let email = validate_email(&req.email)?;
|
||||
debug!(email_domain = %email_domain(&email), "login requested");
|
||||
let user = find_user_by_email(&state, &email)
|
||||
.await?
|
||||
.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?;
|
||||
if !verify_password(&user.password_hash, &req.password) {
|
||||
warn!(user_id = user.id, "login rejected: invalid password");
|
||||
return Err(AuthError::unauthorized(
|
||||
"Invalid e-mail address or password.",
|
||||
));
|
||||
if state.ldap.is_some() {
|
||||
ldap::login(&state, &req.email, &req.password)
|
||||
.await
|
||||
.map(Json)
|
||||
} else {
|
||||
local::login(&state, &req.email, &req.password)
|
||||
.await
|
||||
.map(Json)
|
||||
}
|
||||
if state.account_confirmation_required && user.confirmed_at.is_none() {
|
||||
return Err(AuthError::forbidden(
|
||||
"Confirm the account using the link sent by e-mail before logging in.",
|
||||
));
|
||||
}
|
||||
let session = create_session(&state, &user).await?;
|
||||
info!(user_id = user.id, nickname = %user.nickname, "login successful");
|
||||
Ok(Json(session))
|
||||
}
|
||||
|
||||
pub async fn confirm_account(
|
||||
@@ -1265,6 +1268,22 @@ async fn find_user_by_nickname(
|
||||
.await
|
||||
.map_err(AuthError::database)
|
||||
}
|
||||
async fn find_user_by_external_id(
|
||||
state: &SharedState,
|
||||
provider: &str,
|
||||
external_id: &str,
|
||||
) -> Result<Option<User>, AuthError> {
|
||||
sqlx::query_as::<_, User>(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_USER_BY_EXTERNAL_ID,
|
||||
))
|
||||
.bind(provider)
|
||||
.bind(external_id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)
|
||||
}
|
||||
|
||||
async fn find_user_by_email(state: &SharedState, email: &str) -> Result<Option<User>, AuthError> {
|
||||
sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_EMAIL))
|
||||
.bind(normalize(email))
|
||||
@@ -1,5 +1,35 @@
|
||||
use std::{env, net::IpAddr};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthorizationType {
|
||||
Local,
|
||||
Ldap,
|
||||
Ad,
|
||||
}
|
||||
|
||||
impl AuthorizationType {
|
||||
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
match env_var("AUTHORIZATION_TYPE", "local")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"local" => Ok(Self::Local),
|
||||
"ldap" => Ok(Self::Ldap),
|
||||
"ad" => Ok(Self::Ad),
|
||||
_ => Err("AUTHORIZATION_TYPE must be one of: local, ldap, ad".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Local => "local",
|
||||
Self::Ldap => "ldap",
|
||||
Self::Ad => "ad",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub host: IpAddr,
|
||||
@@ -20,6 +50,8 @@ pub struct Config {
|
||||
pub frontend_log_level: String,
|
||||
pub anonymous_access_token_ttl_days: i64,
|
||||
pub user_session_ttl_days: i64,
|
||||
pub authorization_type: AuthorizationType,
|
||||
pub ldap: Option<crate::auth::ldap::LdapConfig>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -56,6 +88,54 @@ impl Config {
|
||||
return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into());
|
||||
}
|
||||
|
||||
let authorization_type = AuthorizationType::from_env()?;
|
||||
let ldap = match authorization_type {
|
||||
AuthorizationType::Local => None,
|
||||
AuthorizationType::Ldap | AuthorizationType::Ad => {
|
||||
let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str());
|
||||
let (default_filter, default_username_attribute) = match authorization_type {
|
||||
AuthorizationType::Ldap => ("(uid={username})", "uid"),
|
||||
AuthorizationType::Ad => (
|
||||
"(|(sAMAccountName={username})(userPrincipalName={username}))",
|
||||
"sAMAccountName",
|
||||
),
|
||||
AuthorizationType::Local => unreachable!(),
|
||||
};
|
||||
Some(crate::auth::ldap::LdapConfig {
|
||||
url: required_nonempty_env("LDAP_URL", &context)?,
|
||||
starttls: env_bool("LDAP_STARTTLS", false)?,
|
||||
bind_dn: env::var("LDAP_BIND_DN").unwrap_or_default(),
|
||||
bind_password: env::var("LDAP_BIND_PASSWORD").unwrap_or_default(),
|
||||
base_dn: required_nonempty_env("LDAP_BASE_DN", &context)?,
|
||||
user_filter: env_var("LDAP_USER_FILTER", default_filter),
|
||||
username_attribute: env_var(
|
||||
"LDAP_USERNAME_ATTRIBUTE",
|
||||
default_username_attribute,
|
||||
),
|
||||
email_attribute: env_var("LDAP_EMAIL_ATTRIBUTE", "mail"),
|
||||
display_name_attribute: env_var("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"),
|
||||
external_id_attribute: env_var(
|
||||
"LDAP_EXTERNAL_ID_ATTRIBUTE",
|
||||
match authorization_type {
|
||||
AuthorizationType::Ldap => "entryUUID",
|
||||
AuthorizationType::Ad => "objectGUID",
|
||||
AuthorizationType::Local => unreachable!(),
|
||||
},
|
||||
),
|
||||
organization: env_var("LDAP_ORGANIZATION", "organization"),
|
||||
provider: authorization_type.as_str().to_owned(),
|
||||
email_required: env_bool("LDAP_EMAIL_REQUIRED", true)?,
|
||||
link_existing_by_email: env_bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?,
|
||||
tls_verify: env_bool("LDAP_TLS_VERIFY", true)?,
|
||||
connect_timeout_seconds: env_positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?,
|
||||
operation_timeout_seconds: env_positive_u64(
|
||||
"LDAP_OPERATION_TIMEOUT_SECONDS",
|
||||
10,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let smtp_host = std::env::var("SMTP_HOST")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty());
|
||||
@@ -95,6 +175,8 @@ impl Config {
|
||||
frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?,
|
||||
anonymous_access_token_ttl_days,
|
||||
user_session_ttl_days,
|
||||
authorization_type,
|
||||
ldap,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -130,6 +212,14 @@ fn env_positive_i64(name: &str, default: i64) -> Result<i64, Box<dyn std::error:
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn env_positive_u64(name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
let value: u64 = env_var(name, &default.to_string()).parse()?;
|
||||
if value == 0 {
|
||||
return Err(format!("{name} must be greater than 0").into());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn env_nonnegative_u64(name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
Ok(env_var(name, &default.to_string()).parse()?)
|
||||
}
|
||||
@@ -141,3 +231,11 @@ fn required_env(name: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn required_nonempty_env(name: &str, context: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let value = env::var(name).map_err(|_| format!("{name} is required when {context}"))?;
|
||||
if value.trim().is_empty() {
|
||||
return Err(format!("{name} cannot be empty when {context}").into());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
use ldap3::{LdapConnAsync, LdapConnSettings, Scope, SearchEntry};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LdapConfig {
|
||||
pub url: String,
|
||||
pub starttls: bool,
|
||||
pub bind_dn: String,
|
||||
pub bind_password: String,
|
||||
pub base_dn: String,
|
||||
pub user_filter: String,
|
||||
pub username_attribute: String,
|
||||
pub email_attribute: String,
|
||||
pub display_name_attribute: String,
|
||||
pub organization: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LdapIdentity {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub nickname: String,
|
||||
}
|
||||
|
||||
pub async fn authenticate(
|
||||
config: &LdapConfig,
|
||||
login: &str,
|
||||
password: &str,
|
||||
) -> Result<Option<LdapIdentity>, String> {
|
||||
if login.trim().is_empty() || password.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let settings = LdapConnSettings::new().set_starttls(config.starttls);
|
||||
let (connection, mut ldap) = LdapConnAsync::with_settings(settings, &config.url)
|
||||
.await
|
||||
.map_err(|error| format!("LDAP connection failed: {error}"))?;
|
||||
ldap3::drive!(connection);
|
||||
|
||||
if !config.bind_dn.trim().is_empty() {
|
||||
let result = ldap
|
||||
.simple_bind(&config.bind_dn, &config.bind_password)
|
||||
.await
|
||||
.map_err(|error| format!("LDAP service bind failed: {error}"))?;
|
||||
result
|
||||
.success()
|
||||
.map_err(|error| format!("LDAP service bind rejected: {error}"))?;
|
||||
}
|
||||
|
||||
let escaped = escape_filter(login.trim());
|
||||
let filter = config.user_filter.replace("{username}", &escaped);
|
||||
let attributes = vec![
|
||||
config.username_attribute.as_str(),
|
||||
config.email_attribute.as_str(),
|
||||
config.display_name_attribute.as_str(),
|
||||
];
|
||||
let (mut entries, _) = ldap
|
||||
.search(&config.base_dn, Scope::Subtree, &filter, attributes.clone())
|
||||
.await
|
||||
.map_err(|error| format!("LDAP search failed: {error}"))?
|
||||
.success()
|
||||
.map_err(|error| format!("LDAP search rejected: {error}"))?;
|
||||
|
||||
// Allow users to sign in with their directory e-mail even when the configured
|
||||
// primary filter searches by uid/sAMAccountName only.
|
||||
if entries.is_empty() && login.trim().contains('@') {
|
||||
let email_filter = format!("({}={})", config.email_attribute, escaped);
|
||||
let (email_entries, _) = ldap
|
||||
.search(&config.base_dn, Scope::Subtree, &email_filter, attributes)
|
||||
.await
|
||||
.map_err(|error| format!("LDAP e-mail search failed: {error}"))?
|
||||
.success()
|
||||
.map_err(|error| format!("LDAP e-mail search rejected: {error}"))?;
|
||||
entries = email_entries;
|
||||
}
|
||||
|
||||
if entries.len() != 1 {
|
||||
let _ = ldap.unbind().await;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let entry = SearchEntry::construct(entries.into_iter().next().unwrap());
|
||||
let user_dn = entry.dn.clone();
|
||||
let username = first_attr(&entry, &config.username_attribute)
|
||||
.unwrap_or_else(|| login.trim().to_owned());
|
||||
let email = first_attr(&entry, &config.email_attribute)
|
||||
.filter(|value| value.contains('@'))
|
||||
.unwrap_or_else(|| format!("{}@ldap.local", safe_identifier(&username)));
|
||||
let display_name = first_attr(&entry, &config.display_name_attribute)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| username.clone());
|
||||
|
||||
let result = ldap
|
||||
.simple_bind(&user_dn, password)
|
||||
.await
|
||||
.map_err(|error| format!("LDAP user bind failed: {error}"))?;
|
||||
if result.success().is_err() {
|
||||
let _ = ldap.unbind().await;
|
||||
return Ok(None);
|
||||
}
|
||||
let _ = ldap.unbind().await;
|
||||
|
||||
let organization = config.organization.trim();
|
||||
let nickname = if organization.is_empty() {
|
||||
display_name
|
||||
} else {
|
||||
format!("{organization}/{display_name}")
|
||||
};
|
||||
|
||||
Ok(Some(LdapIdentity {
|
||||
username,
|
||||
email,
|
||||
nickname,
|
||||
}))
|
||||
}
|
||||
|
||||
fn first_attr(entry: &SearchEntry, name: &str) -> Option<String> {
|
||||
entry.attrs.get(name).and_then(|values| values.first()).cloned()
|
||||
}
|
||||
|
||||
fn safe_identifier(value: &str) -> String {
|
||||
let result: String = value
|
||||
.chars()
|
||||
.map(|ch| if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { ch } else { '_' })
|
||||
.collect();
|
||||
if result.is_empty() { "user".into() } else { result }
|
||||
}
|
||||
|
||||
fn escape_filter(value: &str) -> String {
|
||||
let mut result = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
match byte {
|
||||
b'*' => result.push_str("\\2a"),
|
||||
b'(' => result.push_str("\\28"),
|
||||
b')' => result.push_str("\\29"),
|
||||
b'\\' => result.push_str("\\5c"),
|
||||
0 => result.push_str("\\00"),
|
||||
value if value < 0x20 || value >= 0x7f => result.push_str(&format!("\\{value:02x}")),
|
||||
value => result.push(value as char),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
+3
-1
@@ -43,6 +43,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
anonymous_access_token_ttl_days = config.anonymous_access_token_ttl_days,
|
||||
user_session_ttl_days = config.user_session_ttl_days,
|
||||
smtp_configured = config.smtp.is_some(),
|
||||
authorization_type = config.authorization_type.as_str(),
|
||||
asset_version = %config.asset_version,
|
||||
"configuration loaded"
|
||||
);
|
||||
@@ -73,12 +74,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
config.upload_max_size_bytes,
|
||||
config.file_cache_max_age_seconds,
|
||||
config.smtp.clone(),
|
||||
config.registration_enabled,
|
||||
config.registration_enabled && config.ldap.is_none(),
|
||||
config.account_confirmation_required,
|
||||
config.share_confirmation_required,
|
||||
config.frontend_log_level.clone(),
|
||||
config.anonymous_access_token_ttl_days,
|
||||
config.user_session_ttl_days,
|
||||
config.ldap.clone(),
|
||||
));
|
||||
let app = app::router(
|
||||
state,
|
||||
|
||||
@@ -17,6 +17,9 @@ pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str =
|
||||
|
||||
// Authentication queries.
|
||||
pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)";
|
||||
pub const AUTH_INSERT_DIRECTORY_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at, auth_provider, external_id, external_dn) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
pub const AUTH_UPDATE_DIRECTORY_USER: &str = "UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, auth_provider = ?, external_id = ?, external_dn = ?, updated_at = ? WHERE id = ?";
|
||||
pub const AUTH_USER_BY_EXTERNAL_ID: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE auth_provider = ? AND external_id = ?";
|
||||
pub const AUTH_DELETE_USER: &str = "DELETE FROM users WHERE id = ?";
|
||||
pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?";
|
||||
pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?";
|
||||
|
||||
@@ -63,6 +63,7 @@ pub struct AppState {
|
||||
pub frontend_log_level: String,
|
||||
pub anonymous_access_token_ttl_days: i64,
|
||||
pub user_session_ttl_days: i64,
|
||||
pub ldap: Option<crate::auth::ldap::LdapConfig>,
|
||||
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
|
||||
presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>,
|
||||
next_connection_id: AtomicU64,
|
||||
@@ -82,6 +83,7 @@ impl AppState {
|
||||
frontend_log_level: String,
|
||||
anonymous_access_token_ttl_days: i64,
|
||||
user_session_ttl_days: i64,
|
||||
ldap: Option<crate::auth::ldap::LdapConfig>,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
@@ -96,6 +98,7 @@ impl AppState {
|
||||
frontend_log_level,
|
||||
anonymous_access_token_ttl_days,
|
||||
user_session_ttl_days,
|
||||
ldap,
|
||||
channels: RwLock::new(HashMap::new()),
|
||||
presence: RwLock::new(HashMap::new()),
|
||||
next_connection_id: AtomicU64::new(1),
|
||||
|
||||
@@ -4139,3 +4139,8 @@ dialog::backdrop {
|
||||
max-width: 150px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Keep public TOC targets visible below the sticky page header. */
|
||||
.public-content :is(h1, h2, h3, h4, h5, h6)[id] {
|
||||
scroll-margin-top: 88px;
|
||||
}
|
||||
|
||||
+2
-2
@@ -97,8 +97,8 @@
|
||||
<div class="identity-fields">
|
||||
<label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="off" data-bwignore="true"
|
||||
placeholder="Your nickname"></label>
|
||||
<label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320"
|
||||
autocomplete="username" required placeholder="you@example.com"></label>
|
||||
<label id="auth-email-field">E-mail / LDAP login<input id="auth-email" name="username" type="email"
|
||||
maxlength="320" autocomplete="username" required placeholder="you@example.com"></label>
|
||||
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128"
|
||||
autocomplete="current-password" required placeholder="At least 8 characters"></label>
|
||||
</div>
|
||||
|
||||
+15
-5
@@ -24,6 +24,7 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
|
||||
const resetButton = dialog.querySelector("#show-reset");
|
||||
const backButton = dialog.querySelector("#reset-back");
|
||||
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
|
||||
const externalAuth = window.__RUSTPAD_CONFIG__?.externalAuth === true;
|
||||
let mode = initialMode;
|
||||
|
||||
const setMode = (nextMode) => {
|
||||
@@ -33,10 +34,10 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
|
||||
|
||||
title.textContent = resetting ? "Reset password" : registering ? "Register nickname" : "Log in";
|
||||
copy.textContent = resetting
|
||||
? "Enter the e-mail address assigned to your account."
|
||||
? "Enter the e-mail address assigned to your local account."
|
||||
: registering
|
||||
? "Reserve your nickname with an e-mail address and password."
|
||||
: "Use the e-mail address and password assigned to your account.";
|
||||
: "Use your e-mail address or organization login and password.";
|
||||
|
||||
nickname.closest("label").hidden = !registering;
|
||||
nickname.disabled = !registering;
|
||||
@@ -47,9 +48,12 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
|
||||
submit.textContent = resetting ? "Send reset link" : registering ? "Create account" : "Log in";
|
||||
switchMode.hidden = resetting || !registrationEnabled;
|
||||
switchMode.textContent = registering ? "Already registered? Log in" : "Create an account";
|
||||
resetButton.hidden = resetting || registering;
|
||||
resetButton.hidden = resetting || registering || externalAuth;
|
||||
backButton.hidden = !resetting;
|
||||
const loginMode = mode === "login";
|
||||
email.type = externalAuth && loginMode ? "text" : "email";
|
||||
if (externalAuth && loginMode) password.removeAttribute("minlength");
|
||||
else password.minLength = 8;
|
||||
form.autocomplete = loginMode ? "on" : "off";
|
||||
nickname.autocomplete = "off";
|
||||
nickname.dataset.bwignore = "true";
|
||||
@@ -143,6 +147,7 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
|
||||
const backButton = dialog.querySelector("#auth-back");
|
||||
const logoutButton = dialog.querySelector("#logout-account");
|
||||
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
|
||||
const externalAuth = window.__RUSTPAD_CONFIG__?.externalAuth === true;
|
||||
let mode = "login";
|
||||
|
||||
const updateActions = () => {
|
||||
@@ -170,6 +175,9 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
|
||||
modeTitle.textContent = mode === "register" ? "Register nickname" : "Log in";
|
||||
authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue";
|
||||
const loginMode = mode === "login";
|
||||
email.type = externalAuth && loginMode ? "text" : "email";
|
||||
if (externalAuth && loginMode) password.removeAttribute("minlength");
|
||||
else password.minLength = 8;
|
||||
form.autocomplete = loginMode ? "on" : "off";
|
||||
nickname.autocomplete = "off";
|
||||
nickname.dataset.bwignore = "true";
|
||||
@@ -194,10 +202,12 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
|
||||
dialog.addEventListener("close", collapse);
|
||||
dialog.addEventListener("cancel", collapse);
|
||||
|
||||
dialog.querySelector("#show-reset")?.addEventListener("click", async () => {
|
||||
const legacyResetButton = dialog.querySelector("#show-reset");
|
||||
if (legacyResetButton) legacyResetButton.hidden = externalAuth;
|
||||
legacyResetButton?.addEventListener("click", async () => {
|
||||
const value = email.value.trim() || await askInput({
|
||||
title: "Reset password",
|
||||
message: "Enter the e-mail address assigned to your account.",
|
||||
message: "Enter the e-mail address assigned to your local account.",
|
||||
label: "E-mail",
|
||||
type: "email",
|
||||
autocomplete: "off",
|
||||
|
||||
+1
-1
@@ -110,7 +110,7 @@ async function loadResources() {
|
||||
const sharedLabel = !item.owned ? `<span class="resource-shared-badge">Shared by ${escapeHtml(item.shared_by || "another user")}</span>` : "";
|
||||
const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only";
|
||||
row.classList.toggle("resource-row--shared", !Boolean(item.owned));
|
||||
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${item.url}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button type="button" data-share>Share</button><button type="button" data-password>Change password</button><button type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
|
||||
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${item.url}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button type="button" data-share>Share</button><button type="button" data-password>Change password</button><button class="danger-button" type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
|
||||
|
||||
const inline = row.querySelector("[data-inline]");
|
||||
const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; };
|
||||
|
||||
+20
-1
@@ -16,7 +16,26 @@ function lockPublicContent(allowTaskUpdates) {
|
||||
content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable'));
|
||||
content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = !allowTaskUpdates; box.title = allowTaskUpdates ? 'Update this task' : 'Task updates are disabled by the owner'; });
|
||||
}
|
||||
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`); document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); } catch (error) { content.innerHTML = `<p class="error">${String(error.message)}</p>`; } }
|
||||
function scrollToPublicAnchor(hash, behavior = "auto") {
|
||||
const rawId = String(hash || "").replace(/^#/, "");
|
||||
if (!rawId) return false;
|
||||
let id;
|
||||
try { id = decodeURIComponent(rawId); } catch { id = rawId; }
|
||||
const target = document.getElementById(id);
|
||||
if (!target || !content.contains(target)) return false;
|
||||
target.scrollIntoView({ behavior, block: "start" });
|
||||
return true;
|
||||
}
|
||||
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`); document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { content.innerHTML = `<p class="error">${String(error.message)}</p>`; } }
|
||||
content.addEventListener("click", event => {
|
||||
const link = event.target.closest('.markdown-toc a[href^="#"]');
|
||||
if (!link) return;
|
||||
const hash = link.getAttribute("href");
|
||||
if (!scrollToPublicAnchor(hash, "smooth")) return;
|
||||
event.preventDefault();
|
||||
history.replaceState(null, "", `${location.pathname}${location.search}${hash}`);
|
||||
});
|
||||
window.addEventListener("hashchange", () => scrollToPublicAnchor(location.hash, "smooth"));
|
||||
content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } });
|
||||
lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); });
|
||||
document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast("Link copied"); } catch (error) { toast(error.message); } });
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@
|
||||
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
|
||||
id="show-login" class="text-button" type="button">Log in</button></div>
|
||||
<section id="auth-panel" class="auth-panel" hidden>
|
||||
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email"
|
||||
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / organization login<input id="auth-email"
|
||||
name="username" type="email" maxlength="320" autocomplete="username"
|
||||
placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password"
|
||||
type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@
|
||||
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
|
||||
id="show-login" class="text-button" type="button">Log in</button></div>
|
||||
<section id="auth-panel" class="auth-panel" hidden>
|
||||
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email"
|
||||
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / organization login<input id="auth-email"
|
||||
name="username" type="email" maxlength="320" autocomplete="username"
|
||||
placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password"
|
||||
type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button
|
||||
|
||||
Reference in New Issue
Block a user