From fddc8e71be5904333acd135734cd1e8f4d8f9c20 Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:52:49 +0200 Subject: [PATCH 01/30] Support the vault banner policy (#7748) --- src/api/core/events.rs | 20 ++++++++++++++++++++ src/db/models/event.rs | 2 +- src/db/models/org_policy.rs | 1 + 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 2c437a36..a5b5b6b1 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -199,6 +199,26 @@ async fn post_events_collect(data: Json>, headers: Headers, .await; } } + // Only the vault notification banner click is accepted from clients. The rest of + // the 1500..=1599 range is written server-side and must not be forgeable by a client. + t if t == EventType::OrganizationUserNotificationBannerActionClicked as i32 => { + if let Some(org_id) = &event.organization_id + && let Some(membership) = + Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await + { + log_event_impl( + event.r#type, + &membership.uuid, + org_id, + &headers.user.uuid, + headers.device.atype, + Some(event_date), + &headers.ip.ip, + &conn, + ) + .await; + } + } _ => { // The cipher determines the organization the event is logged to, so make sure the // user can actually access it instead of trusting the provided cipher uuid. diff --git a/src/db/models/event.rs b/src/db/models/event.rs index 2d9ed8b2..cc0eb504 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -113,7 +113,7 @@ pub enum EventType { OrganizationUserAdminResetTwoFactor = 1519, // OrganizationUserRevoked_TwoFactorNonCompliance = 1520, // OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521, - // OrganizationUserNotificationBannerActionClicked = 1522, + OrganizationUserNotificationBannerActionClicked = 1522, // Organization OrganizationUpdated = 1600, diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 2b45cd86..bf927d5f 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -49,6 +49,7 @@ pub enum OrgPolicyType { // AutotypeDefaultSetting = 17, // Not supported yet // AutoConfirm = 18, // Not supported (not implemented yet) // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) + OrganizationUserNotification = 20, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5 From 8719f86fcf7a58a145eb89ae10987ac11f3135b1 Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:52:57 +0200 Subject: [PATCH 02/30] Add basic auth response client feature flag (#7745) --- .env.template | 1 + src/config.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/.env.template b/.env.template index d22145b8..73226055 100644 --- a/.env.template +++ b/.env.template @@ -406,6 +406,7 @@ ## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0) +## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0) # EXPERIMENTAL_CLIENT_FEATURE_FLAGS= ## Require new device emails. When a user logs in an email is required to be sent. diff --git a/src/config.rs b/src/config.rs index 37fc3e85..18cc6504 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1427,6 +1427,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "pm-5594-safari-account-switching", "pm-32413-multi-client-password-management", // Autofill Team + "enable-basic-auth-response", "ssh-agent", "ssh-agent-v2", // Key Management Team From 9c8aa2359ff3e38f5b179b68bba34baeb9d72778 Mon Sep 17 00:00:00 2001 From: Timshel Date: Fri, 18 Sep 2026 14:53:06 +0000 Subject: [PATCH 03/30] User key id (#7693) Co-authored-by: Timshel --- .../2026-09-02-120000_add_key_id/down.sql | 0 .../2026-09-02-120000_add_key_id/up.sql | 1 + .../2026-09-02-120000_add_key_id/down.sql | 0 .../mysql/2026-09-02-120000_add_key_id/up.sql | 1 + .../2026-09-02-120000_add_key_id/down.sql | 0 .../2026-09-02-120000_add_key_id/up.sql | 1 + .../2026-09-02-120000_add_key_id/down.sql | 0 .../2026-09-02-120000_add_key_id/up.sql | 1 + src/api/core/accounts.rs | 23 +++++++++++++++-- src/api/core/ciphers.rs | 25 +++++++++++++++++-- src/db/models/mod.rs | 2 +- src/db/models/user.rs | 24 ++++++++++++++++++ src/db/schema.rs | 1 + 13 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql create mode 100644 migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql create mode 100644 migrations/mysql/2026-09-02-120000_add_key_id/down.sql create mode 100644 migrations/mysql/2026-09-02-120000_add_key_id/up.sql create mode 100644 migrations/postgresql/2026-09-02-120000_add_key_id/down.sql create mode 100644 migrations/postgresql/2026-09-02-120000_add_key_id/up.sql create mode 100644 migrations/sqlite/2026-09-02-120000_add_key_id/down.sql create mode 100644 migrations/sqlite/2026-09-02-120000_add_key_id/up.sql diff --git a/migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql b/migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql b/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql new file mode 100644 index 00000000..d25396cf --- /dev/null +++ b/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/mysql/2026-09-02-120000_add_key_id/down.sql b/migrations/mysql/2026-09-02-120000_add_key_id/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/mysql/2026-09-02-120000_add_key_id/up.sql b/migrations/mysql/2026-09-02-120000_add_key_id/up.sql new file mode 100644 index 00000000..d25396cf --- /dev/null +++ b/migrations/mysql/2026-09-02-120000_add_key_id/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/postgresql/2026-09-02-120000_add_key_id/down.sql b/migrations/postgresql/2026-09-02-120000_add_key_id/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/postgresql/2026-09-02-120000_add_key_id/up.sql b/migrations/postgresql/2026-09-02-120000_add_key_id/up.sql new file mode 100644 index 00000000..d25396cf --- /dev/null +++ b/migrations/postgresql/2026-09-02-120000_add_key_id/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/sqlite/2026-09-02-120000_add_key_id/down.sql b/migrations/sqlite/2026-09-02-120000_add_key_id/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/sqlite/2026-09-02-120000_add_key_id/up.sql b/migrations/sqlite/2026-09-02-120000_add_key_id/up.sql new file mode 100644 index 00000000..d25396cf --- /dev/null +++ b/migrations/sqlite/2026-09-02-120000_add_key_id/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 69be1334..8cc5e55b 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -21,8 +21,9 @@ use crate::{ DbConn, DbPool, models::{ AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, - EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, - OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType, + EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership, + MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, + UserKdfType, }, }, mail, @@ -46,6 +47,7 @@ pub fn routes() -> Vec { post_set_password, post_kdf, post_rotatekey, + post_user_key, post_sstamp, post_email_token, post_email, @@ -1025,6 +1027,23 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: save_result } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct KeyIdData { + user_key_id: KeyId, +} + +#[post("/accounts/key-management/user-key-id", data = "")] +async fn post_user_key(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let mut user = headers.user; + if user.key_id.is_some() { + err_code!("Unexpected data", Status::UnprocessableEntity.code); + } + + user.key_id = Some(data.into_inner().user_key_id); + user.save(&conn).await +} + #[post("/accounts/security-stamp", data = "")] async fn post_sstamp(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let data: PasswordOrOtpData = data.into_inner(); diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 50be6732..d8081cba 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -6,6 +6,7 @@ use rocket::{ Route, form::{Form, FromForm}, fs::TempFile, + http::Status, serde::json::Json, }; use serde_json::Value; @@ -21,8 +22,8 @@ use crate::{ DbConn, DbPool, models::{ Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, - CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership, - MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, + CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId, + Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, }, }, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, @@ -198,6 +199,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option, + pub encrypted_for: UserId, // Added in web-v2025.6.0 + // Added in web-v2025.8.1, Optional for compat + pub encrypted_by_key_id: Option, + /* Login = 1, SecureNote = 2, @@ -333,6 +339,10 @@ async fn post_ciphers_create( ) -> JsonResult { let mut data: ShareCipherData = data.into_inner(); + if data.cipher.encrypted_for != headers.user.uuid { + err_code!("Invalid user cipher", Status::UnprocessableEntity.code); + } + // This check is usually only needed in update_cipher_from_data(), but we // need it here as well to avoid creating an empty cipher in the call to // cipher.save() below. @@ -362,6 +372,17 @@ async fn post_ciphers_create( async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { let mut data: CipherData = data.into_inner(); + if data.encrypted_for != headers.user.uuid { + err_code!("Invalid user cipher", Status::UnprocessableEntity.code); + } + + if let Some(cipher_key_id) = &data.encrypted_by_key_id + && let Some(user_key_id) = &headers.user.key_id + && cipher_key_id != user_key_id + { + err_code!("Invalid key cipher", Status::UnprocessableEntity.code); + } + // The web/browser clients set this field to null as expected, but the // mobile clients seem to set the invalid value `0001-01-01T00:00:00`, // which results in a warning message being logged. This field isn't diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0ed8ef91..0e4073a5 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -39,4 +39,4 @@ pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; -pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException}; +pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException}; diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 81cb8d84..36089d1a 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -69,6 +69,8 @@ pub struct User { pub avatar_color: Option, pub external_id: Option, // Todo: Needs to be removed in the future, this is not used anymore. + + pub key_id: Option, } #[derive(Identifiable, Queryable, Insertable)] @@ -154,6 +156,8 @@ impl User { avatar_color: None, external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. + + key_id: None, } } @@ -527,6 +531,26 @@ impl Invitation { #[from(forward)] pub struct UserId(String); +#[derive( + Clone, + Debug, + DieselNewType, + FromForm, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + AsRef, + Deref, + Display, + From, + UuidFromParam, +)] +#[deref(forward)] +#[from(forward)] +pub struct KeyId(String); + impl SsoUser { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..98b1eda6 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -217,6 +217,7 @@ table! { api_key -> Nullable, avatar_color -> Nullable, external_id -> Nullable, + key_id -> Nullable, } } From c3f5477525aed713ed340358d3ca982309979032 Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:53:17 +0200 Subject: [PATCH 04/30] Add accepted organization data to sync response (#7666) --- src/api/core/ciphers.rs | 7 +++++++ src/db/models/org_policy.rs | 20 ++++++++++++++++++++ src/db/models/organization.rs | 15 +++++++++++++++ src/db/models/user.rs | 6 ++++++ 4 files changed, 48 insertions(+) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index d8081cba..af79a9ca 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -162,6 +162,12 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option = OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect(); + let policies_new_json: Vec = OrgPolicy::find_accepted_and_confirmed_by_user(&headers.user.uuid, &conn) + .await + .iter() + .map(OrgPolicy::to_json) + .collect(); + let domains_json = if data.exclude_domains { Value::Null } else { @@ -194,6 +200,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option Vec { + conn.run(move |conn| { + org_policies::table + .inner_join( + users_organizations::table.on(users_organizations::org_uuid + .eq(org_policies::org_uuid) + .and(users_organizations::user_uuid.eq(user_uuid))), + ) + .filter( + users_organizations::status + .eq(MembershipStatus::Accepted as i32) + .or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)), + ) + .select(org_policies::all_columns) + .load::(conn) + .expect("Error loading org_policy") + }) + .await + } + pub async fn find_by_org_and_type( org_uuid: &OrganizationId, policy_type: OrgPolicyType, diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 29016865..d615a3fc 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -844,6 +844,21 @@ impl Membership { .await } + pub async fn find_accepted_and_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + conn.run(move |conn| { + users_organizations::table + .filter(users_organizations::user_uuid.eq(user_uuid)) + .filter( + users_organizations::status + .eq(MembershipStatus::Accepted as i32) + .or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)), + ) + .load::(conn) + .unwrap_or_default() + }) + .await + } + pub async fn find_invited_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { conn.run(move |conn| { users_organizations::table diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 36089d1a..3412b142 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -263,6 +263,11 @@ impl User { orgs_json.push(c.to_json(conn).await); } + let mut orgs_new_json = Vec::new(); + for c in Membership::find_accepted_and_confirmed_by_user(&self.uuid, conn).await { + orgs_new_json.push(c.to_json(conn).await); + } + let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).await.is_empty(); // TODO: Might want to save the status field in the DB @@ -303,6 +308,7 @@ impl User { "privateKey": self.private_key, "securityStamp": self.security_stamp, "organizations": orgs_json, + "organizationsNew": orgs_new_json, "providers": [], "providerOrganizations": [], "forcePasswordReset": false, From 64c56411f6a16ac4466c12199587101566cf7efa Mon Sep 17 00:00:00 2001 From: "Berk D. Demir" <11135+bdd@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:53:27 -0700 Subject: [PATCH 05/30] Add `pm-32009-new-item-types` feature flag (#7478) Introduced in 2026.4.0 for all clients, enables new item types bank account, driver's license, and passport. --- .env.template | 1 + src/api/core/ciphers.rs | 9 +++++++++ src/config.rs | 2 ++ 3 files changed, 12 insertions(+) diff --git a/.env.template b/.env.template index 73226055..8ed6c5c2 100644 --- a/.env.template +++ b/.env.template @@ -402,6 +402,7 @@ ## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2) ## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2) ## - "pm-30529-webauthn-related-origins": +## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0) ## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index af79a9ca..a5b7e58b 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -279,6 +279,9 @@ pub struct CipherData { Card = 3, Identity = 4, SshKey = 5 + BankAccount = 6 + DriversLicense = 7 + Passport = 8 */ pub r#type: i32, pub name: String, @@ -291,6 +294,9 @@ pub struct CipherData { card: Option, identity: Option, ssh_key: Option, + bank_account: Option, + drivers_license: Option, + passport: Option, favorite: Option, reprompt: Option, @@ -538,6 +544,9 @@ pub async fn update_cipher_from_data( 3 => data.card, 4 => data.identity, 5 => data.ssh_key, + 6 => data.bank_account, + 7 => data.drivers_license, + 8 => data.passport, _ => err!("Invalid type"), }; diff --git a/src/config.rs b/src/config.rs index 18cc6504..0bb22f7f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1442,6 +1442,8 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "cxp-export-mobile", // Platform Team "pm-30529-webauthn-related-origins", + // Vault Team + "pm-32009-new-item-types", ]; impl Config { From a24683636a448949b0bf535fb2c88ec7f3814b2c Mon Sep 17 00:00:00 2001 From: Mathijs van Veluw Date: Fri, 18 Sep 2026 18:44:50 +0200 Subject: [PATCH 06/30] Update Crates, GHA and JS (#7751) Signed-off-by: BlackDex --- .github/workflows/hadolint.yml | 2 +- .github/workflows/release.yml | 6 +- .github/workflows/trivy.yml | 2 +- .github/workflows/typos.yml | 2 +- .pre-commit-config.yaml | 2 +- Cargo.lock | 271 +- Cargo.toml | 14 +- macros/Cargo.toml | 2 +- src/static/scripts/datatables.css | 16 +- src/static/scripts/datatables.js | 9864 +++++++++++++++-------------- 10 files changed, 5101 insertions(+), 5080 deletions(-) diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml index a429d10f..c4b83aa6 100644 --- a/.github/workflows/hadolint.yml +++ b/.github/workflows/hadolint.yml @@ -20,7 +20,7 @@ jobs: steps: # Start Docker Buildx - name: Setup Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 # https://github.com/moby/buildkit/issues/3969 # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 311891b7..903a5e22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,13 +58,13 @@ jobs: steps: - name: Initialize QEMU binfmt support - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 with: platforms: "arm64,arm" # Start Docker Buildx - name: Setup Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 # https://github.com/moby/buildkit/issues/3969 # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills with: @@ -185,7 +185,7 @@ jobs: - name: Bake ${{ matrix.base_image }} containers id: bake_vw - uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0 + uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 env: BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}" SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}" diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 7f41885d..f0fbf192 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -50,6 +50,6 @@ jobs: severity: CRITICAL,HIGH - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index 00fcbab4..3c880e91 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -23,4 +23,4 @@ jobs: # When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too - name: Spell Check Repo - uses: crate-ci/typos@d43b6c087ac471e2ea7b8af622ff15f05c0c365b # v1.50.1 + uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e8319414..d2a509e2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: # When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too - repo: https://github.com/crate-ci/typos - rev: d43b6c087ac471e2ea7b8af622ff15f05c0c365b # v1.50.1 + rev: 512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2 hooks: - id: typos always_run: true diff --git a/Cargo.lock b/Cargo.lock index 55a7233b..2ccd4b1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -151,9 +151,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.45" +version = "0.4.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a8ec73eb862508b7041723c89386365894b4e7d9f6998bf1b8529e5b0ee254" +checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" dependencies = [ "compression-codecs", "compression-core", @@ -318,18 +318,14 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] name = "asyncband" -version = "0.6.7" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a214ba60d6231afd0e805e3c27c45a1626d9debaa5a5061c45a1ea1b2f1ed0" -dependencies = [ - "hashbrown 0.17.1", - "slab", -] +checksum = "f2d85fd3d291fabcc40c7232c92c280ec1754fd7b5d7ea769f143222143e179a" [[package]] name = "atomic" @@ -403,9 +399,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.9.2" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef47857a1d4488b528f4a5d5715fa7c3300820897824152234d3fa22b1426657" +checksum = "25b43ad47adc2517efe3d706559d94b97e50e80e0321b3cadbc9f77cee88adcd" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -428,9 +424,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.109.0" +version = "1.111.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3cfe74df5d9ad2fedd691973ad3521ebf4f27a3c68c792556686aedb5519bab" +checksum = "65700d75f6da78c89a61a49f1539979400a4609d3329376b1dc2133d7a1b2b90" dependencies = [ "arc-swap", "aws-credential-types", @@ -454,9 +450,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.111.0" +version = "1.113.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81b0ec31ed6191bd11350aae4b2004198f2db21350cb0a20c57e0a92e55dd161" +checksum = "eb1147c254af7b6cee3ace90ae7da6f21366ce9a9b899035fa8a0fdf586eb75a" dependencies = [ "arc-swap", "aws-credential-types", @@ -480,9 +476,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.114.0" +version = "1.116.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef45745026107ec30c4ef86bd8ae4b002e7e5f6a86e4225240bdf6b06a0b944a" +checksum = "035c7392e751d20bf1060d625d023587eb14099646110a9cff7c697bfe4b253a" dependencies = [ "arc-swap", "aws-credential-types", @@ -507,9 +503,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.5.1" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" +checksum = "31d955e76ff96acd555bf06fa0fa6d5bf9335fa84ae7c64481b20ae61d231f70" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -561,9 +557,9 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.63.0" +version = "0.63.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef" +checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -581,9 +577,9 @@ dependencies = [ [[package]] name = "aws-smithy-query" -version = "0.62.0" +version = "0.62.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512346c7212ab7436df2d77a16d976a468ae44a418835511d2a69269810aaf62" +checksum = "f1d1d71f6562be974caa85442ecd90194c40fdb5df045f182a6c2e872ce95056" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -594,9 +590,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.14.0" +version = "1.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b82e438d30e02a825d363bd639a9efaed68a8089d86101054b0081e7e0d3e606" +checksum = "3296253d3a91b3f938a3f2bcce4daebadcb4aa4228153fcec90f9d23532b4484" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -619,9 +615,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.16.0" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c054752dd9e4dc73d0b75748c99ac2d0feafbf2f25c7b0516f03a3534161223" +checksum = "6d881a7b7ad179fd6611680c9de89f716fb00ab40299a9a7b8c6913e8f7511a8" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", @@ -648,9 +644,9 @@ dependencies = [ [[package]] name = "aws-smithy-schema" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" +checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -659,9 +655,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.6.3" +version = "1.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f94d16e797ec62cd999fc9d5942b48fa7050c3093ddadff48e4d7528d16fcb9" +checksum = "0b791f3ac597193fe1d08b82366986eb1f5bc31f2ac6c194c0855276116c76cd" dependencies = [ "base64-simd", "bytes", @@ -682,9 +678,9 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.62.0" +version = "0.62.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce84f71c72fee2cbbadde6e7d082f5fb466e3a84733855295fa7aafd1b31b7d8" +checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -785,9 +781,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "blake2" @@ -930,9 +926,9 @@ checksum = "f5813789573ae815c8b4be58c4428e0e7ae05f0227678ba9de332ded585b9159" [[package]] name = "cc" -version = "1.4.5" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "jobserver", @@ -942,9 +938,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -1011,9 +1007,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.40" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "100590da849306918656ffbb22576bbdfc1382b50ad10d1d50ec177d3b205fb2" +checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" dependencies = [ "brotli", "compression-core", @@ -1170,9 +1166,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] @@ -1360,7 +1356,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1393,7 +1389,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core 0.24.1", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1567,7 +1563,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "proc-macro2", "proc-macro2-diagnostics", "quote", @@ -1581,7 +1577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3b934ddbdcb2abb9f9fc9c30bd47bcc5618b615eea1d334cda5fdf8ff9b072a" dependencies = [ "bigdecimal", - "bitflags 2.13.1", + "bitflags 2.13.2", "byteorder", "chrono", "diesel_derives", @@ -1676,7 +1672,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1920,9 +1916,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.12" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "flate2" @@ -2040,7 +2036,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -2311,9 +2307,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hickory-net" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084e7bd6a377435d568f652153e571b50970d7ccc1d1eeec0519f834632287e1" +checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" dependencies = [ "async-trait", "cfg-if", @@ -2335,9 +2331,9 @@ dependencies = [ [[package]] name = "hickory-proto" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" dependencies = [ "data-encoding", "idna", @@ -2355,9 +2351,9 @@ dependencies = [ [[package]] name = "hickory-resolver" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4f9f4603319422d482738f3f6fe5aac03157fdbfed1cd85a3ff45adb09072f" +checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981" dependencies = [ "cfg-if", "futures-util", @@ -2555,7 +2551,7 @@ dependencies = [ "http 1.5.0", "hyper 1.11.1", "hyper-util", - "rustls 0.23.44", + "rustls 0.23.45", "tokio", "tokio-rustls 0.26.5", "tower-service", @@ -2805,9 +2801,9 @@ checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" [[package]] name = "jiff" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" dependencies = [ "defmt", "jiff-core", @@ -2824,18 +2820,19 @@ dependencies = [ [[package]] name = "jiff-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" dependencies = [ "defmt", + "log", ] [[package]] name = "jiff-static" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" dependencies = [ "jiff-core", "proc-macro2", @@ -2941,9 +2938,9 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "11.0.0" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" dependencies = [ "base64 0.22.1", "ed25519-dalek", @@ -3011,7 +3008,7 @@ dependencies = [ "nom 8.0.0", "percent-encoding", "quoted_printable", - "rustls 0.23.44", + "rustls 0.23.45", "rustls-native-certs", "serde", "socket2 0.6.5", @@ -3109,7 +3106,7 @@ name = "macros" version = "0.1.0" dependencies = [ "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -3267,7 +3264,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -3299,7 +3296,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -3379,7 +3376,7 @@ checksum = "e4e98dc3b890f6c23a0f9d3d491a2823d0dea0fa656302a13dd225fa924112a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -3485,9 +3482,9 @@ dependencies = [ [[package]] name = "opendal" -version = "0.59.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950151f9587a51a7bed70a15fa0cff464eae96e41ae7499f97067bdafdf43eb" +checksum = "9fe43e16d96bed57937eb7c4a28559bf4c5fc4a3951656505d1fdd4f856349b4" dependencies = [ "opendal-core", "opendal-service-fs", @@ -3496,9 +3493,9 @@ dependencies = [ [[package]] name = "opendal-core" -version = "0.59.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a43405d217dfdfb543f58847336d3af672897dd1939bb7dcf314b63cf364f1c9" +checksum = "de4566a412776e3f65d53dd9fceced5b432a0a9c9a1e8d50ccf58823795e5c5b" dependencies = [ "anyhow", "asyncband", @@ -3522,9 +3519,9 @@ dependencies = [ [[package]] name = "opendal-http-transport-reqwest" -version = "0.59.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "401999057db611e592f883fcf2cbd6754ff37af587deaadd07b8c1398b2b6b06" +checksum = "f772ce6c137ab43647726116d505a649a88d41928fcd813438eb30b02312bcbb" dependencies = [ "bytes", "futures", @@ -3536,9 +3533,9 @@ dependencies = [ [[package]] name = "opendal-service-fs" -version = "0.59.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb9caf04d6d38713299dd4abac984b95ab16ee20e1ff09160f495c5a64644083" +checksum = "a0e7d8974aebd33be899200c9d0d50b2141fd5fa5c462b29e20653e3ddbfe339" dependencies = [ "bytes", "log", @@ -3550,9 +3547,9 @@ dependencies = [ [[package]] name = "opendal-service-s3" -version = "0.59.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388b1d39b62535c62803754ebef89808859558697366dbedd0299345887ba461" +checksum = "2942f2d8d3d4953c0e8d07cd1244879628d084948e48ce6b6d808c5938f7858d" dependencies = [ "base64 0.23.1", "bytes", @@ -3606,7 +3603,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "foreign-types", "libc", @@ -4060,7 +4057,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", + "toml_edit 0.25.15+spec-1.1.0", ] [[package]] @@ -4252,7 +4249,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -4261,7 +4258,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -4281,7 +4278,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -4424,7 +4421,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustls 0.23.44", + "rustls 0.23.45", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -4668,11 +4665,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -4693,9 +4690,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.44" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -4747,7 +4744,7 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.44", + "rustls 0.23.45", "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki 0.103.15", @@ -4889,7 +4886,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4959,7 +4956,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -5055,7 +5052,7 @@ dependencies = [ "darling 0.24.1", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -5195,9 +5192,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" @@ -5324,9 +5321,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -5353,6 +5350,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "syslog" version = "7.0.0" @@ -5371,7 +5379,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5442,7 +5450,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -5507,18 +5515,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" @@ -5545,7 +5544,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -5564,7 +5563,7 @@ version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ - "rustls 0.23.44", + "rustls 0.23.45", "tokio", ] @@ -5674,9 +5673,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", @@ -5733,7 +5732,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-core", "futures-util", @@ -5881,9 +5880,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" @@ -5936,9 +5935,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -6019,7 +6018,7 @@ dependencies = [ "rocket", "rocket_ws", "rpassword", - "rustls 0.23.44", + "rustls 0.23.45", "semver", "serde", "serde_json", @@ -6134,7 +6133,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", "wasm-bindgen-shared", ] @@ -6612,14 +6611,14 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] @@ -6666,14 +6665,14 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] @@ -6726,14 +6725,14 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] name = "zlib-rs" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index d3a3d5e9..7dcae503 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,11 +125,11 @@ libsqlite3-sys = { version = "0.38.2", optional = true } # Crypto-related libraries rand = "0.10.2" ring = "0.17.14" -rustls = { version = "0.23.44", features = ["ring", "std"], default-features = false } +rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false } subtle = "2.6.1" # UUID generation -uuid = { version = "1.26.0", features = ["v4"] } +uuid = { version = "1.26.1", features = ["v4"] } # Date and time libraries chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } @@ -143,7 +143,7 @@ job_scheduler_ng = "2.5.0" data-encoding = "2.11.1" # JWT library -jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust_crypto", "use_pem"] } +jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto", "use_pem"] } # TOTP library totp-lite = "2.0.1" @@ -201,7 +201,7 @@ reqwest = { version = "0.13.5", default-features = false, features = [ "socks", "system-proxy", ] } -hickory-resolver = "0.26.2" +hickory-resolver = "0.26.3" # Favicon extraction libraries html5gum = "0.8.4" @@ -257,8 +257,8 @@ rpassword = "7.5.4" grass_compiler = { version = "0.13.4", default-features = false } # File are accessed through Apache OpenDAL -opendal = { version = "0.59.1", default-features = false, features = ["services-fs"] } -opendal-http-transport-reqwest = { version = "0.59.1", default-features = false, features = ["rustls-no-provider"], optional = true } +opendal = { version = "0.59.2", default-features = false, features = ["services-fs"] } +opendal-http-transport-reqwest = { version = "0.59.2", default-features = false, features = ["rustls-no-provider"], optional = true } # For retrieving AWS credentials, including temporary SSO credentials aws-config = { version = "1.12.0", optional = true, default-features = false, features = [ @@ -268,7 +268,7 @@ aws-config = { version = "1.12.0", optional = true, default-features = false, fe "sso", ] } aws-credential-types = { version = "1.3.0", optional = true } -aws-smithy-runtime-api = { version = "1.16.0", optional = true } +aws-smithy-runtime-api = { version = "1.16.2", optional = true } http = { version = "1.5.0", optional = true } reqsign-aws-v4 = { version = "3.3.0", optional = true } reqsign-core = { version = "3.3.1", optional = true } diff --git a/macros/Cargo.toml b/macros/Cargo.toml index 34cb913d..b397fbe1 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -14,7 +14,7 @@ proc-macro = true [dependencies] quote = "1.0.47" -syn = "3.0.5" +syn = "3.0.6" [lints] workspace = true diff --git a/src/static/scripts/datatables.css b/src/static/scripts/datatables.css index 48b7400c..3a32d7ed 100644 --- a/src/static/scripts/datatables.css +++ b/src/static/scripts/datatables.css @@ -4,10 +4,10 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-3.0.3 + * https://datatables.net/download/#bs5/dt-3.0.4 * * Included libraries: - * DataTables 3.0.3 + * DataTables 3.0.4 */ /*! DataTables Bootstrap 5 integration @@ -636,6 +636,18 @@ table.dataTable.table-sm > thead > tr td.dt-type-date .dt-column-order, table.dataTable.table-sm > thead > tr td.dt-type-numeric .dt-column-order { left: 0.25rem; } +table.dataTable.table-sm > thead > tr th.dt-left .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-head-left .dt-column-order, +table.dataTable.table-sm > thead > tr td.dt-left .dt-column-order, +table.dataTable.table-sm > thead > tr td.dt-head-left .dt-column-order { + left: auto; + right: 0.25rem; +} +table.dataTable.table-sm > thead > tr th.dt-right .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-head-right .dt-column-order, +table.dataTable.table-sm > thead > tr td.dt-right .dt-column-order, +table.dataTable.table-sm > thead > tr td.dt-head-right .dt-column-order { + left: 0.25rem; + right: auto; +} div.dt-scroll-head table.table-bordered { border-bottom-width: 0; diff --git a/src/static/scripts/datatables.js b/src/static/scripts/datatables.js index 1ae94cd2..3d4c727d 100644 --- a/src/static/scripts/datatables.js +++ b/src/static/scripts/datatables.js @@ -4,13 +4,13 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-3.0.3 + * https://datatables.net/download/#bs5/dt-3.0.4 * * Included libraries: - * DataTables 3.0.3 + * DataTables 3.0.4 */ -/*! DataTables 3.0.3 +/*! DataTables 3.0.4 * Copyright (c) SpryMedia Ltd - datatables.net/license */ @@ -476,7 +476,7 @@ function assignDeep(out, ...inputs) { * Deep merge objects, but shallow copy arrays. The reason we need to do this, * is that we don't want to deep copy array init values (such as aaSorting) * since the dev wouldn't be able to override them, but we do want to deep copy - * arrays. + * objects. * * @param out Object to extend * @param extender Object from which the properties will be applied to out @@ -485,7 +485,6 @@ function assignDeep(out, ...inputs) { * present. This is so you can pass in a collection to DataTables and have * that used as your data source without breaking the references * @returns out Reference, just for convenience - out === the return. - * @todo This doesn't take account of arrays inside the deep copied objects. */ function assignDeepObjects(out, extender, breakRefs = false) { let val; @@ -496,7 +495,7 @@ function assignDeepObjects(out, extender, breakRefs = false) { if (!plainObject(out[prop])) { out[prop] = {}; } - assignDeep(out[prop], val); + assignDeepObjects(out[prop], val, breakRefs); } else if (breakRefs && prop !== 'data' && @@ -3241,5511 +3240,5542 @@ var pager = { numbers_length: 7 }; -const footer = (settings, cell, classes) => { - cell.classAdd(classes.tfoot.cell); -}; -const header = (settings, cell, classes) => { - cell.classAdd(classes.thead.cell); - if (!settings.features.ordering) { - cell.classAdd(classes.order.none); - } - var titleRow = settings.titleRow; - var headerRows = cell.closest('thead').find('tr'); - var rowIdx = cell.parent().index(); - // Conditions to not apply the ordering icons - if ( - // Cells and rows which have the attribute to disable the icons - cell.attr('data-dt-order') === 'disable' || - cell.parent().attr('data-dt-order') === 'disable' || - // titleRow support, for defining a specific row in the header - (titleRow === true && rowIdx !== 0) || - (titleRow === false && rowIdx !== headerRows.count() - 1) || - (typeof titleRow === 'number' && rowIdx !== titleRow)) { - return; - } - // No additional mark-up required. Attach a sort listener to update on sort - // - note that using the `DT` namespace will allow the event to be removed - // automatically on destroy, while the `dt` namespaced event is the one we - // are listening for - Dom.s(settings.table).on('order.dt.DT column-visibility.dt.DT', function (e, ctx, column) { - if (settings !== ctx) { - // need to check if this is the host - return; // table, not a nested one - } - var sorting = ctx.sortDetails; - if (!sorting) { - return; - } - var orderedColumns = pluck(sorting, 'col'); - // This handler is only needed on column visibility if the column is - // part of the ordering. If it isn't, then we can bail out to save - // performance. It could be a separate event handler, but this is a - // balance between code reuse / size and performance console.log(e, - // e.name, column, orderedColumns, orderedColumns.includes(column)) - if (e.type === 'column-visibility' && - !orderedColumns.includes(column)) { - return; - } - var i; - var orderClasses = classes.order; - var columns = ctx.api.columns(cell); - var col = settings.columns[columns.flatten()[0]]; - var orderable = columns.orderable().includes(true); - var ariaType = ''; - var indexes = columns.indexes(); - var sortDirs = columns.orderable(true).flatten(); - var tabIndex = settings.tabIndex; - var canOrder = ctx.orderHandler && orderable; - cell.classRemove(orderClasses.isAsc + ' ' + orderClasses.isDesc) - .classToggle(orderClasses.none, !orderable) - .classToggle(orderClasses.canAsc, canOrder && sortDirs.includes('asc')) - .classToggle(orderClasses.canDesc, canOrder && sortDirs.includes('desc')); - // Determine if all of the columns that this cell covers are - // included in the current ordering - var isOrdering = true; - for (i = 0; i < indexes.length; i++) { - if (!orderedColumns.includes(indexes[i])) { - isOrdering = false; - } - } - if (isOrdering) { - // Get the ordering direction for the columns under this cell - // Note that it is possible for a cell to be asc and desc - // sorting (column spanning cells) - var orderDirs = columns.order(); - cell.classAdd((orderDirs.includes('asc') ? orderClasses.isAsc : '') + - (orderDirs.includes('desc') ? orderClasses.isDesc : '')); - } - // Find the first visible column that has ordering applied to it - - // it get's the aria information, as the ARIA spec says that only - // one column should be marked with aria-sort - var firstVis = -1; // column index - for (i = 0; i < orderedColumns.length; i++) { - if (settings.columns[orderedColumns[i]].visible) { - firstVis = orderedColumns[i]; - break; - } - } - if (indexes[0] == firstVis) { - var firstSort = sorting[0]; - var sortOrder = col.orderSequence; - cell.attr('aria-sort', firstSort.dir === 'asc' ? 'ascending' : 'descending'); - // Determine if the next click will remove sorting or change the - // sort - ariaType = - sortOrder && !sortOrder[firstSort.index + 1] - ? 'Remove' - : 'Reverse'; - } - else { - cell.attrRemove('aria-sort'); - } - // Make the headers tab-able for keyboard navigation - if (orderable) { - var orderSpan = cell.find('.dt-column-order'); - orderSpan - .attr('role', 'button') - .attr('aria-label', orderable - ? col.ariaTitle + - ctx.api.i18n('aria.orderable' + ariaType) - : col.ariaTitle); - if (tabIndex !== -1) { - orderSpan.attr('tabindex', tabIndex); - } - } - }); +const defaults$4 = { + addedClasses: [], + cells: [], + data: [], + details: undefined, + detailsShow: undefined, + displayData: null, + idx: -1, + orderCache: null, + searchCellCache: null, + searchRowCache: null, + src: 'dom', + tr: null }; -const layout = (settings, container, items) => { - let classes = settings.classes.layout; - let row = Dom - .c('div') - .attr('id', items.id || null) - .classAdd(items.className || classes.row) - .appendTo(container); - displayRowCells(items, function (key, val) { - var klass = ''; - if (val.table) { - row.classAdd(classes.tableRow); - klass += classes.tableCell + ' '; - } - if (key === 'start') { - klass += classes.start; - } - else if (key === 'end') { - klass += classes.end; - } - else { - klass += classes.full; - } - Dom.c('div') - .attr({ - id: val.id || null, - class: val.className - ? val.className - : classes.cell + ' ' + klass - }) - .append(val.contents) - .appendTo(row); +/** + * Create a new object that is a row model + * + * @param parts Values to assign, otherwise the defaults will be used + * @returns New object + */ +function create$2(parts = {}) { + return util.object.assignDeep({}, defaults$4, parts); +} + +/** + * Add a data array to the table, creating DOM node etc. This is the parallel to + * gatherData, but for adding rows from a JavaScript source, rather than a + * DOM source. + * + * @param settings DataTables settings object + * @param dataIn data array to be added + * @param tr TR element to add to the table - optional. If not given, DataTables + * will create a row automatically + * @param tds Array of TD|TH elements for the row - must be given if tr is. + * @returns >=0 if successful (index of new data entry), -1 if failed + */ +function addData(settings, dataIn, tr, tds) { + /* Create the object for storing information about this new row */ + var rowIdx = settings.data.length; + var row = create$2({ + src: tr ? 'dom' : 'data', + idx: rowIdx }); -}; -const pagingButton = (settings, buttonType, content, active, disabled) => { - var classes = settings.classes.paging; - var btnClasses = [classes.button]; - var btn; - if (active) { - btnClasses.push(classes.active); - } - if (disabled) { - btnClasses.push(classes.disabled); + row.data = dataIn; + settings.data.push(row); + var columns = settings.columns; + for (var i = 0, iLen = columns.length; i < iLen; i++) { + // Invalidate the column types as the new data needs to be revalidated + columns[i].type = null; } - if (buttonType === 'ellipsis') { - btn = Dom.c('span').classAdd('ellipsis').html(content).get(0); + /* Add to the display array */ + settings.displayMaster.push(rowIdx); + var id = settings.rowIdFn(dataIn); + if (id !== undefined) { + settings.ids[id] = row; } - else { - btn = Dom - .c('button') - .classAdd(btnClasses.join(' ')) - .attr('role', 'link') - .attr('type', 'button') - .html(content) - .get(0); + /* Create the DOM information, or register it if already present */ + if (tr || !settings.features.deferRender) { + createTr(settings, rowIdx, tr, tds); } - return { - display: btn, - clicker: btn - }; -}; -const pagingContainer = (settings, buttons) => { - // No wrapping element - just append directly to the host - return buttons; -}; -function displayRowCells(items, fn) { - if (items.start) { - fn('start', items.start); + return rowIdx; +} +/** + * Add one or more TR elements to the table. Generally we'd expect to + * use this for reading data from a DOM sourced table, but it could be + * used for an TR element. Note that if a TR is given, it is used (i.e. + * it is not cloned). + * + * @param settings DataTables settings object + * @param rows The TR element(s) to add to the table + * @returns Array of indexes for the added rows + */ +function addTr(settings, rows) { + return rows.mapTo(el => { + let row = getRowElementsFromNode(settings, el); + return addData(settings, row.data, el, row.cells); + }); +} +/** + * Get the data for a given cell from the internal cache, taking into account + * data mapping + * + * @param settings DataTables settings object + * @param rowIdx data row id + * @param colIdx Column index + * @param type data get type ('display', 'type' 'filter|search' 'sort|order') + * @returns Cell data + */ +function getCellData(settings, rowIdx, colIdx, type) { + if (type === 'search') { + type = 'filter'; } - if (items.end) { - fn('end', items.end); + else if (type === 'order') { + type = 'sort'; } - if (items.full) { - fn('full', items.full); + var row = settings.data[rowIdx]; + if (!row) { + return undefined; } -} - -const store = { - className: {}, - detect: [], - render: {}, - search: {}, - order: {} -}; -// Common function to remove new lines, strip HTML and diacritic control -function _filterString(stripHtml, normalize) { - return function (str) { - if (util.is.empty(str) || typeof str !== 'string') { - return str; - } - str = str.replace(util.regex.reNewLines, ' '); - if (stripHtml) { - str = util.stripHtml(str); - } - { - str = util.diacritics(str, false); + var draw = settings.drawCount; + var col = settings.columns[colIdx]; + var rowData = row.data; + var defaultContent = col.defaultContent; + var cellData = col.dataGet(rowData, type, { + settings: settings, + row: rowIdx, + col: colIdx + }); + // Allow for a node being returned for non-display types + if (type !== 'display' && + cellData && + typeof cellData === 'object' && + cellData.nodeName) { + cellData = cellData.innerHTML; + } + if (cellData === undefined) { + if (settings.drawError != draw && defaultContent === null) { + log(settings, 0, 'Requested unknown parameter ' + + (typeof col.data == 'function' + ? '{function}' + : "'" + col.data + "'") + + ' for row ' + + rowIdx + + ', column ' + + colIdx, 4); + settings.drawError = draw; } - return str; - }; -} -function __numericReplace(d, decimalPlace, re1, re2) { - if (d !== 0 && (!d || d === '-')) { - return -Infinity; + return defaultContent; } - if (typeof d === 'number' || typeof d === 'bigint') { - return d; + // When the data source is null and a specific data type is requested (i.e. + // not the original data), we can use default column data + if ((cellData === rowData || cellData === null) && + defaultContent !== null && + type !== undefined) { + cellData = defaultContent; } - // If a decimal place other than `.` is used, it needs to be given to the - // function so we can detect it and replace with a `.` which is the only - // decimal place JavaScript recognises - it is not locale aware. - if (decimalPlace) { - d = util.conv.numToDecimal(d, decimalPlace); + else if (typeof cellData === 'function') { + // If the data source is a function, then we run it and use the return, + // executing in the scope of the data object (for instances) + return cellData.call(rowData); } - if (typeof d === 'string') { - if (re1) { - d = d.replace(re1, ''); - } - if (re2) { - d = d.replace(re2, ''); + if (cellData === null && type === 'display') { + return ''; + } + if (type === 'filter') { + var formatters = ext.type.search; + if (col.type && formatters[col.type]) { + cellData = formatters[col.type](cellData); } } - return d * 1; + return cellData; } -function register$1(name, prop, val) { - if (!prop) { - return { - className: store.className[name], - detect: store.detect.find(function (fn) { - return fn._name === name; - }), - order: { - pre: store.order[name + '-pre'], - asc: store.order[name + '-asc'], - desc: store.order[name + '-desc'] - }, - render: store.render[name], - search: store.search[name] - }; - } - var setProp = function (prop2, propVal) { - store[prop2][name] = propVal; - }; - var setDetect = function (detect) { - // `detect` can be a function or an object - we set a name - // property for either - that is used for the detection - Object.defineProperty(detect, '_name', { value: name }); - var idx = store.detect.findIndex(function (item) { - return item._name === name; +/** + * Set the value for a specific cell, into the internal data cache + * + * @param settings DataTables settings object + * @param rowIdx data row id + * @param colIdx Column index + * @param val Value to set + */ +function setCellData(settings, rowIdx, colIdx, val) { + let row = settings.data[rowIdx]; + if (row) { + let col = settings.columns[colIdx]; + let rowData = row.data; + col.dataSet(rowData, val, { + settings: settings, + row: rowIdx, + col: colIdx }); - if (idx === -1) { - store.detect.unshift(detect); - } - else { - store.detect.splice(idx, 1, detect); - } - }; - var setOrder = function (obj) { - store.order[name + '-pre'] = obj.pre; // can be undefined - store.order[name + '-asc'] = obj.asc; // can be undefined - store.order[name + '-desc'] = obj.desc; // can be undefined - }; - // prop is optional - if (val === undefined) { - val = prop; - prop = undefined; - } - if (prop === 'className') { - setProp('className', val); } - else if (prop === 'detect') { - setDetect(val); +} +/** + * Write a value to a cell + * + * @param td Cell + * @param val Value + */ +function writeCell(td, val) { + let cell = Dom.s(td); + if (val && typeof val === 'object' && val.nodeName) { + cell.empty().append(val); } - else if (prop === 'order') { - setOrder(val); + else { + cell.html(val); } - else if (prop === 'render') { - setProp('render', val); +} +/** + * Return an array with the full table data + * + * @param settings DataTables settings object + * @returns array {array} aData Master data array + */ +function getDataMaster(settings) { + return util.array.pluck(settings.data, 'data'); +} +/** + * Nuke the table + * + * @param settings DataTables settings object + */ +function clearTable(settings) { + settings.data.length = 0; + settings.displayMaster.length = 0; + settings.display.length = 0; + settings.ids = {}; +} +/** + * Mark cached data as invalid such that a re-read of the data will occur when + * the cached data is next requested. Also update from the data source object. + * + * @param settings DataTables settings object + * @param rowIdx Row index to invalidate + * @param src Source to invalidate from: undefined, 'auto', 'dom' or 'data' + * @param colIdx Column index to invalidate. If undefined the whole row will be + * invalidated + */ +function invalidateRow(settings, rowIdx, src, colIdx) { + var row = settings.data[rowIdx]; + var i, iLen; + if (!row) { + return; } - else if (prop === 'search') { - setProp('search', val); + // Remove the cached data for the row + row.orderCache = null; + row.searchCellCache = null; + row.displayData = null; + // Are we reading last data from DOM or the data object? + if (src === 'dom' || ((!src || src === 'auto') && row.src === 'dom')) { + // Read the data from the DOM + row.data = getRowElementsFromModel(settings, row, colIdx).data; } - else if (!prop) { - if (val.className) { - setProp('className', val.className); - } - if (val.detect !== undefined) { - setDetect(val.detect); - } - if (val.order) { - setOrder(val.order); - } - if (val.render !== undefined) { - setProp('render', val.render); - } - if (val.search !== undefined) { - setProp('search', val.search); + else { + // Reading from data object, update the DOM + var cells = row.cells; + var display = getRowDisplay(settings, rowIdx); + if (cells.length) { + if (colIdx !== undefined) { + writeCell(cells[colIdx], display[colIdx]); + } + else { + for (i = 0, iLen = cells.length; i < iLen; i++) { + writeCell(cells[i], display[i]); + } + } } } + invalidColumn(settings, colIdx); + // Update DataTables special `DT_*` attributes for the row + rowAttributes(settings, row); + callbackFire(settings, null, 'rowInvalidate', [settings, rowIdx, colIdx], false); } -// Get a list of types -function types() { - return store.detect.map(function (detect) { - return detect._name; - }); -} -var __diacriticSort = function (a, b) { - a = a !== null && a !== undefined ? a.toString().toLowerCase() : ''; - b = b !== null && b !== undefined ? b.toString().toLowerCase() : ''; - // Checked for `navigator.languages` support in `oneOf` so this code can't execute in old - // Safari and thus can disable this check - // eslint-disable-next-line compat/compat - return a.localeCompare(b, navigator.languages[0] || navigator.language, { - numeric: true, - ignorePunctuation: true - }); -}; -var __diacriticHtmlSort = function (a, b) { - a = util.stripHtml(a); - b = util.stripHtml(b); - return __diacriticSort(a, b); -}; -// -// Built in data types -// -register$1('string', { - detect: function () { - return 'string'; - }, - order: { - pre: function (a) { - // This is a little complex, but faster than always calling toString, - // http://jsperf.com/tostring-v-check - return util.is.empty(a) && typeof a !== 'boolean' - ? '' - : typeof a === 'string' - ? a.toLowerCase() - : !a.toString - ? '' - : a.toString(); - } - }, - search: _filterString(false) -}); -register$1('string-utf8', { - detect: { - allOf: function () { - return true; - }, - oneOf: function (d) { - // At least one data point must contain a non-ASCII character - // This line will also check if navigator.languages is supported or not. If not (Safari 10.0-) - // this data type won't be supported. - // eslint-disable-next-line compat/compat - return (!util.is.empty(d) && - navigator.languages && - typeof d === 'string' && - !!d.match(/[^\x00-\x7F]/)); - } - }, - order: { - asc: __diacriticSort, - desc: function (a, b) { - return __diacriticSort(a, b) * -1; - } - }, - search: _filterString(false) -}); -register$1('html', { - detect: { - allOf: function (d) { - return (util.is.empty(d) || - (typeof d === 'string' && d.indexOf('<') !== -1)); - }, - oneOf: function (d) { - // At least one data point must contain a `<` - return (!util.is.empty(d) && - typeof d === 'string' && - d.indexOf('<') !== -1); - } - }, - order: { - pre: function (a) { - return util.is.empty(a) - ? '' - : a.replace - ? util.stripHtml(a).trim().toLowerCase() - : a + ''; - } - }, - search: _filterString(true) -}); -register$1('html-utf8', { - detect: { - allOf: function (d) { - return (util.is.empty(d) || - (typeof d === 'string' && d.indexOf('<') !== -1)); - }, - oneOf: function (d) { - // At least one data point must contain a `<` and a non-ASCII character - // eslint-disable-next-line compat/compat - return (navigator.languages && - !util.is.empty(d) && - typeof d === 'string' && - d.indexOf('<') !== -1 && - typeof d === 'string' && - !!d.match(/[^\x00-\x7F]/)); - } - }, - order: { - asc: __diacriticHtmlSort, - desc: function (a, b) { - return __diacriticHtmlSort(a, b) * -1; - } - }, - search: _filterString(true) -}); -register$1('date', { - className: 'dt-type-date', - detect: { - allOf: function (d) { - // V8 tries _very_ hard to make a string passed into `Date.parse()` - // valid, so we need to use a regex to restrict date formats. Use a - // plug-in for anything other than ISO8601 style strings - if (d && !(d instanceof Date) && !util.regex.reDate.test(d)) { - return null; - } - var parsed = Date.parse(d); - return (parsed !== null && !isNaN(parsed)) || util.is.empty(d); - }, - oneOf: function (d) { - // At least one entry must be a date or a string with a date - return (d instanceof Date || - (typeof d === 'string' && util.regex.reDate.test(d))); - } - }, - order: { - pre: function (d) { - var ts = Date.parse(d); - return isNaN(ts) ? -Infinity : ts; - } +/** + * Column specific invalidation + * + * @param settings DataTables settings object + * @param colIdx Column index to invalidate, or all columns if not given + */ +function invalidColumn(settings, colIdx) { + // Column specific invalidation + var cols = settings.columns; + if (colIdx !== undefined) { + // Type - the data might have changed + cols[colIdx].type = null; + // Max length string. Its a fairly cheep recalculation, so not worth + // something more complicated + cols[colIdx].wideStrings = null; } -}); -register$1('html-num-fmt', { - className: 'dt-type-numeric', - detect: { - allOf: function (d, settings) { - var decimal = settings.language.decimal; - return util.is.htmlNum(d, decimal, true, false); - }, - oneOf: function (d, settings) { - // At least one data point must contain a numeric value - var decimal = settings.language.decimal; - return util.is.htmlNum(d, decimal, true, false); - } - }, - order: { - pre: function (d, s) { - var dp = s.language.decimal; - return __numericReplace(d, dp, util.regex.reHtml, util.regex.reFormattedNumeric); - } - }, - search: _filterString(true) -}); -register$1('html-num', { - className: 'dt-type-numeric', - detect: { - allOf: function (d, settings) { - var decimal = settings.language.decimal; - return util.is.htmlNum(d, decimal, false, true); - }, - oneOf: function (d, settings) { - // At least one data point must contain a numeric value - var decimal = settings.language.decimal; - return util.is.htmlNum(d, decimal, false, false); - } - }, - order: { - pre: function (d, s) { - var dp = s.language.decimal; - return __numericReplace(d, dp, util.regex.reHtml); - } - }, - search: _filterString(true) -}); -register$1('num-fmt', { - className: 'dt-type-numeric', - detect: { - allOf: function (d, settings) { - var decimal = settings.language.decimal; - return util.is.num(d, decimal, true, true); - }, - oneOf: function (d, settings) { - // At least one data point must contain a numeric value - var decimal = settings.language.decimal; - return util.is.num(d, decimal, true, false); - } - }, - order: { - pre: function (d, s) { - var dp = s.language.decimal; - return __numericReplace(d, dp, util.regex.reFormattedNumeric); + else { + for (let i = 0, iLen = cols.length; i < iLen; i++) { + cols[i].type = null; + cols[i].wideStrings = null; } } -}); -register$1('num', { - className: 'dt-type-numeric', - detect: { - allOf: function (d, settings) { - var decimal = settings.language.decimal; - return util.is.num(d, decimal, false, true); - }, - oneOf: function (d, settings) { - // At least one data point must contain a numeric value - var decimal = settings.language.decimal; - return util.is.num(d, decimal, false, false); + settings.containerWidth = -1; +} +/** + * Get the cells and data for a given row - from a element + * + * @param settings DataTables settings object + * @param row TR element from which to read data or existing row object from + * which to re-read the data from the cells + */ +function getRowElementsFromNode(settings, row) { + let data = settings.rowReadObject ? {} : []; + let cells = Dom.s(row).children('th, td'); + let id = row.getAttribute('id'); + cells.each((el, idx) => { + readCellData(settings, el, data, idx); + }); + if (id) { + util.set(settings.rowId)(data, id); + } + return { + data: data, + cells: cells.get() + }; +} +/** + * Get the cells and data for a given row - from an existing row model + * + * @param settings DataTables settings object + * @param row Existing row object from which to re-read the data from the cells + * @param colIdx Optional column index + */ +function getRowElementsFromModel(settings, row, colIdx) { + let tds = row.cells; + for (let i = 0; i < tds.length; i++) { + if (colIdx === undefined || colIdx === i) { + readCellData(settings, tds[i], row.data, i); } - }, - order: { - pre: function (d, s) { - var dp = s.language.decimal; - return __numericReplace(d, dp); + } + // Read the ID from the DOM if present + if (row.tr) { + let id = row.tr.getAttribute('id'); + if (id) { + util.set(settings.rowId)(row.data, id); } } -}); - + return { + data: row.data, + cells: tds + }; +} /** - * DataTables extensions - * - * This namespace acts as a collection area for plug-ins that can be used to - * extend DataTables capabilities. Indeed many of the build in methods - * use this method to provide their own capabilities (sorting methods for - * example). + * Read data from a cell into the data source object * - * Note that this namespace is aliased to `jQuery.fn.dataTableExt` for legacy - * reasons + * @param settings DataTables settings object + * @param cell The HTML cell element to read from + * @param data Data object / array to store data into + * @param colIdx The column index for the cell */ -const ext = { - /** - * DataTables build type (expanded by the download builder) - */ - builder: 'bs5/dt-3.0.3', - /** - * Buttons. For use with the Buttons extension for DataTables. This is - * defined here so other extensions can define buttons regardless of load - * order. It is _not_ used by DataTables core. - */ - buttons: {}, - /** - * ColumnControl buttons and content - */ - ccContent: {}, - /** - * Element class names - */ - classes: classes$1, - /** - * Error reporting. - * - * How should DataTables report an error. Can take the value 'alert', - * 'throw', 'none' or a function. - */ - errMode: 'alert', - /** HTML entity escaping */ - escape: { - /** When reading data-* attributes for initialisation options */ - attributes: false - }, - /** - * Legacy so v1 plug-ins don't throw js errors on load - */ - feature: legacy, - /** - * Feature plug-ins. - * - * This is an object of callbacks which provide the features for DataTables - * to be initialised via the `layout` option. - */ - features: features, - /** - * Row searching. - * - * This method of searching is complimentary to the default type based - * searching, and a lot more comprehensive as it allows you complete control - * over the searching logic. Each element in this array is a function - * (parameters described below) that is called for every row in the table, - * and your logic decides if it should be included in the searching data set - * or not. - */ - search: [], - /** - * Selector extensions - * - * The `selector` option can be used to extend the options available for the - * selector modifier options (`selector-modifier` object data type) that - * each of the three built in selector types offer (row, column and cell + - * their plural counterparts). For example the Select extension uses this - * mechanism to provide an option to select only rows, columns and cells - * that have been marked as selected by the end user (`{selected: true}`), - * which can be used in conjunction with the existing built in selector - * options. - */ - selector: { - cell: [], - column: [], - row: [] - }, - settings: [], - /** - * Legacy configuration options. Enable and disable legacy options that - * are available in DataTables. - * - * @type object - */ - legacy: { - /** - * Enable / disable DataTables 1.9 compatible server-side processing - * requests - */ - ajax: null - }, - /** - * Pagination plug-in methods. - * - * Each entry in this object is a function and defines which buttons should - * be shown by the pagination rendering method that is used for the table. - * The renderer addresses how the buttons are displayed in the document, - * while the functions here tell it what buttons to display. This is done by - * returning an array of button descriptions (what each button will do). - */ - pager: pager, - renderer: { - footer: { - _: footer - }, - header: { - _: header - }, - layout: { - _: layout - }, - pagingButton: { - _: pagingButton - }, - pagingContainer: { - _: pagingContainer +function readCellData(settings, cell, data, colIdx) { + let column = settings.columns[colIdx]; + let contents = cell.innerHTML.trim(); + if (column.attrSrc) { + // If we are working with attributes from the cell as values + let dataPoint = column.data; + let setter = util.set(dataPoint._); + let attr = function (str, cell) { + if (typeof str === 'string') { + let idx = str.indexOf('@'); + if (idx !== -1) { + let att = str.substring(idx + 1); + let setter = util.set(str); + setter(data, cell.getAttribute(att)); + } + } + }; + setter(data, contents); + attr(dataPoint.sort, cell); + attr(dataPoint.type, cell); + attr(dataPoint.filter, cell); + } + else { + if (!column.setter) { + // Cache the setter function + column.setter = util.set(column.data); } - }, - /** - * Rendering helper function exposed for use by the styling integrations. - */ - rendererDisplayRowCells: displayRowCells, - /** - * Ordering plug-ins - custom data source - * - * The extension options for ordering of data available here is - * complimentary to the default type based ordering that DataTables - * typically uses. It allows much greater control over the data that is - * being used to order a column, but is necessarily therefore more complex. - */ - order: {}, - /** - * Type based plug-ins. - * - * Each column in DataTables has a type assigned to it, either by automatic - * detection or by direct assignment using the `type` option for the column. - * The type of a column will effect how it is ordering and search (plug-ins - * can also make use of the column type if required). - */ - type: store, - /** - * Unique DataTables instance counter - * - * @type int - * @private - */ - _unique: 0, - // - // Depreciated - // The following properties are retained for backwards compatibility only. - // The should not be used in new projects and will be removed in a future - // version - // - /** - * Software version - * @type string - */ - version: '3.0.3' -}; -// -// Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts -// -Object.assign(ext, { - afnFiltering: ext.search, - aTypes: ext.type.detect, - ofnSearch: ext.type.search, - oSort: ext.type.order, - afnSortData: ext.order, - aoFeatures: ext.feature, - oStdClasses: ext.classes, - oPagination: ext.pager, - sVersion: ext.version, - fnVersionCheck: check$1 -}); + column.setter(data, contents); + } +} /** - * Log an error message + * Generate the node required for the processing node + * + * @param ctx DataTables settings object + */ +function processingHtml(ctx) { + var table = ctx.table; + var scrolling = ctx.scroll.x !== '' || ctx.scroll.y !== ''; + if (ctx.features.processing) { + var n = Dom + .c('div') + .attr('id', ctx.tableId + '_processing') + .attr('role', 'status') + .classAdd(ctx.classes.processing.container) + .html(ctx.language.processing) + .append(Dom + .c('div') + .append(Dom.c('div')) + .append(Dom.c('div')) + .append(Dom.c('div')) + .append(Dom.c('div'))); + // Different positioning depending on if scrolling is enabled or not + if (scrolling) { + n.prependTo(Dom.s(ctx.tableWrapper).find('div.dt-scroll').get(0)); + } + else { + n.insertBefore(table); + } + Dom.s(table).on('processing.dt.DT', (e, s, show) => { + n.css('display', show ? 'block' : 'none'); + }); + } +} +/** + * Display or hide the processing indicator * * @param ctx DataTables settings object - * @param level log error messages, or display them to the user - * @param msg error message - * @param tn Technical note id to get more information about the error. + * @param show Show the processing indicator (true) or not (false) */ -function log(ctx, level, msg, tn) { - msg = - 'DataTables warning: ' + - (ctx ? 'table id=' + ctx.tableId + ' - ' : '') + - msg; - if (tn) { - msg += - '. For more information about this error, please see ' + - 'https://datatables.net/tn/' + - tn; +function processingDisplay(ctx, show) { + // Ignore cases when we are still redrawing + if (ctx.doingDraw && show === false) { + return; } - { - // Backwards compatibility pre 1.10 - var type = ext.sErrMode || ext.errMode; - if (ctx) { - callbackFire(ctx, null, 'dt-error', [ctx, tn, msg], true); - } - if (type == 'alert') { - alert(msg); - } - else if (type == 'throw') { - throw new Error(msg); - } - else if (typeof type == 'function') { - type(ctx, tn, msg); - } + callbackFire(ctx, null, 'processing', [ctx, show]); +} +/** + * Show the processing element if an action takes longer than a given time + * + * @param ctx DataTables settings object + * @param enable Do (true) or not (false) async processing (local feature enablement) + * @param run Function to run + */ +function processingRun(ctx, enable, run) { + if (!enable) { + // Immediate execution, synchronous + run(); + } + else { + processingDisplay(ctx, true); + // Allow the processing display to show if needed + setTimeout(function () { + run(); + processingDisplay(ctx, false); + }, 0); + } +} + +function renderer(ctx, type) { + var render = ctx.renderer; + var host = ext.renderer[type]; + if (plainObject(render) && render[type]) { + // Specific renderer for this type. If available use it, otherwise use + // the default. + return host[render[type]] || host._; + } + else if (typeof render === 'string') { + // Common renderer - if there is one available for this type use it, + // otherwise use the default + return host[render] || host._; + } + // Use the default + return host._; +} + +/** + * Recalculate the column widths, if needed (by a column having been + * invalidated) + * + * @param settings DataTables settings object + */ +function columnWidths(settings) { + if (settings.columns.map(c => c.wideStrings).includes(null)) { + calculateColumnWidths(settings); } } /** - * See if a property is defined on one object, if so assign it to the other - * object + * Calculate the width of columns for the table * - * @param ret target object - * @param src source object - * @param name property - * @param mappedName name to map too - optional, name used if not given + * @param settings DataTables settings object */ -function map(ret, src, name, mappedName) { - if (Array.isArray(name)) { - for (let i = 0; i < name.length; i++) { - let val = name[i]; - if (Array.isArray(val)) { - map(ret, src, val[0], val[1]); +function calculateColumnWidths(settings) { + // Not interested in doing column width calculation if auto-width is disabled + if (!settings.features.autoWidth) { + return; + } + var table = settings.table, columns = settings.columns, scroll = settings.scroll, scrollY = scroll.y, scrollX = scroll.x, visibleColumns = getColumns(settings, 'visible'), tableWidthAttr = table.getAttribute('width'), // from DOM element + tableContainer = table.parentElement, i, j, column, columnIdx; + var styleWidth = table.style.width; + var containerWidth = wrapperWidth(settings); + // Don't re-run for the same width as the last time + if (containerWidth === settings.containerWidth) { + return false; + } + settings.containerWidth = containerWidth; + // If there is no width applied as a CSS style or as an attribute, we assume that + // the width is intended to be 100%, which is usually is in CSS, but it is very + // difficult to correctly parse the rules to get the final result. + if (!styleWidth && !tableWidthAttr) { + table.style.width = '100%'; + styleWidth = '100%'; + } + if (styleWidth && styleWidth.indexOf('%') !== -1) { + tableWidthAttr = styleWidth; + } + // Let plug-ins know that we are doing a recalc, in case they have changed any of the + // visible columns their own way (e.g. Responsive uses display:none). + callbackFire(settings, null, 'column-calc', [{ visible: visibleColumns }], false); + // Construct a worst case table with the widest, assign any user defined + // widths, then insert it into the DOM and allow the browser to do all + // the hard work of calculating table widths + var tmpTable = Dom + .s(table.cloneNode()) + .css('visibility', 'hidden') + .css('margin', '0') + .attrRemove('id'); + // Clean up the table body + tmpTable.append(Dom.c('tbody')); + // Clone the table header and footer - we can't use the header / footer + // from the cloned table, since if scrolling is active, the table's + // real header and footer are contained in different table tags + tmpTable + .append(settings.thead.cloneNode(true)) + .append(settings.tfoot.cloneNode(true)); + // Remove any assigned widths from the footer (from scrolling) + tmpTable.find('tfoot th, tfoot td').css('width', ''); + // Apply custom sizing to the cloned header + tmpTable.find('thead th, thead td').each(cell => { + // Get the `width` from the header layout + var width = columnsSumWidth(settings, cell, true); + if (width) { + cell.style.width = width; + // For scrollX we need to force the column width otherwise the + // browser will collapse it. If this width is smaller than the + // width the column requires, then it will have no effect + if (scrollX) { + cell.style.minWidth = width; + Dom.s(cell).append(Dom.c('div').css({ + width: width, + margin: '0', + padding: '0', + border: '0', + height: '1px' + })); } - else { - map(ret, src, val); + } + else { + cell.style.width = ''; + } + }); + // Get the widest strings for each of the visible columns and add them to + // our table to create a "worst case" + var longestData = []; + for (i = 0; i < visibleColumns.length; i++) { + longestData.push(getWideStrings(settings, visibleColumns[i])); + } + if (longestData.length) { + for (i = 0; i < longestData[0].length; i++) { + var tr = Dom.c('tr').appendTo(tmpTable.find('tbody')); + for (j = 0; j < visibleColumns.length; j++) { + columnIdx = visibleColumns[j]; + column = columns[columnIdx]; + var longest = longestData[j][i] || ''; + var autoClass = ext.type.className[column.type]; + var padding = column.contentPadding || (scrollX ? '-' : ''); + var text = longest + padding; + var cell = Dom + .c('td') + .classAdd(autoClass) + .classAdd(column.className) + .appendTo(tr); + if (longest.indexOf('<') === -1 && + longest.indexOf('&') === -1) { + cell.text(text); + } + else { + cell.html(text); + } } } - return; } - if (mappedName === undefined) { - mappedName = name; + // Tidy the temporary table - remove name attributes so there aren't + // duplicated in the dom (radio elements for example) + tmpTable.find('[name]').attrRemove('name'); + // Table has been built, attach to the document so we can work with it. + // A holding element is used, positioned at the top of the container + // with minimal height, so it has no effect on if the container scrolls + // or not. Otherwise it might trigger scrolling when it actually isn't + // needed + var holder = Dom + .c('div') + .css(scrollX || scrollY + ? { + position: 'absolute', + top: '0', + left: '0', + height: '1px', + right: '0', + overflow: 'hidden' + } + : {}) + .append(tmpTable) + .appendTo(tableContainer); + // When scrolling (X or Y) we want to set the width of the table as + // appropriate. However, when not scrolling leave the table width as it + // is. This results in slightly different, but I think correct behaviour + if (scrollX) { + tmpTable.css('width', 'auto').attrRemove('width'); + // If there is no width attribute or style, then allow the table to + // collapse + if (tmpTable.width() < tableContainer.clientWidth && tableWidthAttr) { + tmpTable.width(tableContainer.clientWidth); + } } - if (src[name] !== undefined) { - ret[mappedName] = src[name]; + else if (scrollY) { + tmpTable.width(tableContainer.clientWidth); } -} -/** - * Bind an event handler to allow a click or return key to activate the callback. - * This is good for accessibility since a return on the keyboard will have the - * same effect as a click, if the element has focus. - * - * @param n Element to bind the action to - * @param selector Selector (for delegated events) - * @param fn Callback function for when the event is triggered - */ -function bindAction(n, selector, fn) { - Dom.s(n) - .on('click.DT', selector, function (e) { - fn(e); - }) - .on('keypress.DT', selector, function (e) { - if (e.which === 13) { - e.preventDefault(); - fn(e); - } - }) - .on('selectstart.DT', selector, function () { - // Don't want a double click resulting in text selection - return false; - }); -} -/** - * Register a callback function. Easily allows a callback function to be added - * to an array store of callback functions that can then all be called together. - * - * @param settings dataTables settings object - * @param store Name of the array storage for the callbacks in settings - * @param fn Function to be called back - */ -function callbackReg(ctx, store, fn) { - if (fn) { - ctx.callbacks[store].push(fn); + else if (tableWidthAttr) { + tmpTable.width(tableWidthAttr); } -} -/** - * Fire callback functions and trigger events. Note that the loop over the - * callback array store is done backwards! Further note that you do not want to - * fire off triggers in time sensitive applications (for example cell creation) - * as its slow. - * - * @param ctx DataTables settings object - * @param callbackArr Name of the array storage for the callbacks in the context - * @param eventName Name of the custom event to trigger. If null no trigger is - * fired - * @param args Array of arguments to pass to the callback function / trigger - * @param bubbles True if the event should bubble - */ -function callbackFire(ctx, callbackArr, eventName, args, bubbles = false) { - var ret = []; - if (callbackArr) { - ret = ctx.callbacks[callbackArr] - .slice() - .reverse() - .map(function (val) { - return val.apply(ctx.instance, args); - }); + // Get the width of each column in the constructed table + var total = 0; + var bodyCells = tmpTable.find('tbody tr').eq(0).children(); + for (i = 0; i < visibleColumns.length; i++) { + // Use getBounding for sub-pixel accuracy, which we then want to round + // up! + var bounding = bodyCells.get(i).getBoundingClientRect().width; + // Total is tracked to remove any sub-pixel errors as the outerWidth + // of the table might not equal the total given here + total += bounding; + // Width for each column to use + columns[visibleColumns[i]].width = stringToCss(bounding); } - if (eventName !== null) { - let table = Dom.s(ctx.table); - let result = table.trigger(eventName + '.dt', bubbles, args, { - dt: ctx.api + table.style.width = stringToCss(total); + // Finished with the table - ditch it + holder.remove(); + // If there is a width attr, we want to attach an event listener which + // allows the table sizing to automatically adjust when the window is + // resized. Use the width attr rather than CSS, since we can't know if the + // CSS is a relative value or absolute - DOM read is always px. + if (tableWidthAttr) { + table.style.width = stringToCss(tableWidthAttr); + } + if ((tableWidthAttr || scrollX) && !settings.reszEvt) { + var resize = util.throttle(function () { + var newWidth = wrapperWidth(settings); + // Don't do it if destroying or the container width is 0 + if (!settings.destroying && newWidth !== 0) { + adjustColumnSizing(settings); + } }); - // If not yet attached to the document, trigger the event - // on the body directly to sort of simulate the bubble - if (bubbles && table.closest('body').count() === 0) { - Dom.s('body').trigger(eventName + '.dt', bubbles, args, { - dt: ctx.api + // For browsers that support it (~2020 onwards for wide support) we can watch for the + // container changing width. + if (window.ResizeObserver) { + // This is a tricky beast - if the element is visible when `.observe()` is called, + // then the callback is immediately run. Which we don't want. If the element isn't + // visible, then it isn't run, but we want it to run when it is then made visible. + // This flag allows the above to be satisfied. + var first = Dom.s(settings.tableWrapper).isVisible(); + // Use an empty div to attach the observer so it isn't impacted by height changes + var resizer = Dom + .c('div') + .css({ + width: '100%', + height: '0' + }) + .classAdd('dt-autosize') + .appendTo(settings.tableWrapper); + settings.resizeObserver = new ResizeObserver(function (e) { + if (first) { + first = false; + } + else { + resize(); + } }); + settings.resizeObserver.observe(resizer.get(0)); } - ret.push(result[0]); - } - return ret; -} -function lengthOverflow(ctx) { - var start = ctx.displayStart, end = displayEnd(ctx), len = ctx.pageLength; - // If we have space to show extra rows (backing up from the end point - then - // do so - if (start >= end) { - start = end - len; - } - // Keep the start record on the current page - start -= start % len; - if (len === -1 || start < 0) { - start = 0; + else { + // For old browsers, the best we can do is listen for a window + // resize + window.addEventListener('resize', resize); + settings.windowResizeCb = resize; // For removal in `destroy` + } + settings.reszEvt = true; } - ctx.displayStart = start; } /** - * Detect the data source being used for the table. Used to simplify the code a - * little (ajax) and to make it compress a little smaller. + * Get the width of the DataTables wrapper element * - * @param ctx DataTables settings object - * @returns Data source + * @param settings DataTables settings object + * @returns Width */ -function dataSource(ctx) { - if (ctx.features.serverSide) { - return 'ssp'; - } - else if (ctx.ajax) { - return 'ajax'; - } - return 'dom'; +function wrapperWidth(settings) { + let wrapper = Dom.s(settings.tableWrapper); + return wrapper.isVisible() ? wrapper.width() : 0; } /** - * Common replacement for language strings + * Get the widest strings for each column. * - * @param ctx DataTables settings object - * @param str String with values to replace - * @param entries Plural number for _ENTRIES_ - can be undefined - * @returns String - */ -function macros(ctx, str, entries) { - // When infinite scrolling, we are always starting at 1. _iDisplayStart is - // used only internally - var formatter = ctx.formatNumber, start = ctx.displayStart + 1, len = ctx.pageLength, vis = recordsDisplay(ctx), max = recordsTotal(ctx), all = len === -1; - return str - .replace(/_START_/g, formatter(start, ctx)) - .replace(/_END_/g, formatter(displayEnd(ctx), ctx)) - .replace(/_MAX_/g, formatter(max, ctx)) - .replace(/_TOTAL_/g, formatter(vis, ctx)) - .replace(/_PAGE_/g, formatter(all ? 1 : Math.ceil(start / len), ctx)) - .replace(/_PAGES_/g, formatter(all ? 1 : Math.ceil(vis / len), ctx)) - .replace(/_ENTRIES_/g, ctx.api.i18n('entries', '', entries)) - .replace(/_ENTRIES-MAX_/g, ctx.api.i18n('entries', '', max)) - .replace(/_ENTRIES-TOTAL_/g, ctx.api.i18n('entries', '', vis)); -} -/** - * Add elements to an array as quickly as possible, but stack safe. + * It is very difficult to determine what the widest string actually is due to variable character + * width and kerning. Doing an exact calculation with the DOM or even Canvas would kill performance + * and this is a critical point, so we use two techniques to determine a collection of the longest + * strings from the column, which will likely contain the widest strings: * - * @param arr Array to add the data to - * @param data Data array that is to be added + * 1) Get the top three longest strings from the column + * 2) Get the top three widest words (i.e. an unbreakable phrase) + * + * @param settings DataTables settings object + * @param colIdx column of interest + * @returns Array of the longest strings */ -function arrayApply(arr, data) { - if (!data) { - return; - } - // Chrome can throw a max stack error if apply is called with - // too large an array, but apply is faster. - if (data.length < 10000) { - arr.push.apply(arr, data); - } - else { - for (var i = 0; i < data.length; i++) { - arr.push(data[i]); +function getWideStrings(settings, colIdx) { + var column = settings.columns[colIdx]; + // Do we need to recalculate (i.e. was invalidated), or just use the cached data? + if (!column.wideStrings) { + var allStrings = []; + var collection = []; + // Create an array with the string information for the column + for (var i = 0, iLen = settings.displayMaster.length; i < iLen; i++) { + var rowIdx = settings.displayMaster[i]; + var data = getRowDisplay(settings, rowIdx)[colIdx]; + var cellString = data && typeof data === 'object' && data.nodeType + ? data.innerHTML + : data + ''; + // Remove id / name attributes from elements so they + // don't interfere with existing elements + cellString = cellString + .replace(/id=".*?"/g, '') + .replace(/name=".*?"/g, ''); + // Don't want script, dialog or template tags in the width + // calculations as they are hidden content + cellString = cellString + .replace(/]*)?>/gi, ' ') + .replace(/]*)?>/gi, ' ') + .replace(/]*)?>/gi, ' '); + var noHtml = util.string + .stripHtml(cellString, ' ') + .replace(/ /g, ' '); + collection.push({ + str: cellString, + len: noHtml.length + }); + allStrings.push(noHtml); + } + // Order and then cut down to the size we need + collection + .sort(function (a, b) { + return b.len - a.len; + }) + .splice(3); + column.wideStrings = collection.map(function (item) { + return item.str; + }); + // Longest unbroken string + const parts = allStrings.join(' ').split(' '); + parts.sort(function (a, b) { + return b.length - a.length; + }); + if (parts.length) { + column.wideStrings.push(parts[0]); + } + if (parts.length > 1) { + column.wideStrings.push(parts[1]); + } + if (parts.length > 2) { + column.wideStrings.push(parts[3]); } } + return column.wideStrings; } /** - * Add one or more listeners to the table + * Append a CSS unit (only if required) to a string * - * @param that JQ for the table - * @param name Event name - * @param src Listener(s) + * @param s Value to css-ify + * @returns Value with css unit */ -function listener(that, name, src) { - let srcArr = Array.isArray(src) ? src : [src]; - for (var i = 0; i < srcArr.length; i++) { - that.on(name + '.dt.DT', srcArr[i]); +function stringToCss(s) { + if (s === null) { + return '0px'; } + if (typeof s == 'number') { + return s < 0 ? '0px' : s + 'px'; + } + // Check it has a unit character already + return s.match(/\d$/) ? s + 'px' : s; } /** - * Escape HTML entities in strings, in an object + * Re-insert the `col` elements for current visibility + * + * @param settings DT settings */ -function escapeObject(obj) { - if (ext.escape.attributes) { - each(obj, function (key, val) { - obj[key] = escapeHtml(val); - }); +function colGroup(settings) { + var cols = settings.columns; + settings.colgroup.empty(); + for (var i = 0; i < cols.length; i++) { + if (cols[i].visible) { + settings.colgroup.append(cols[i].colEl); + } } - return obj; } -/* - * Public helper functions. These aren't used internally by DataTables, or - * called by any of the options passed into DataTables, but they can be used - * externally by developers working with DataTables. They are helper functions - * to make working with DataTables a little bit easier. - */ /** - * Common logic for moment, luxon or a date action. + * Scrolling setup * - * Happens after __mldObj, so don't need to call `resolveWindowsLibs` again + * @param settings DataTables settings object + * @returns Node to add to the DOM */ -function __mld(dtLib, momentFn, luxonFn, dateFn, arg1) { - if (__moment) { - return dtLib[momentFn](arg1); +function featureTable(settings) { + let table = Dom.s(settings.table); + let scroll = settings.scroll; + let scrollX = scroll.x; + let scrollY = scroll.y; + // No scrolling or x-scrolling only + if (scrollY === '' && scrollX === '') { + return table.get(0); } - else if (__luxon) { - return dtLib[luxonFn](arg1); + let classes = settings.classes.scrolling; + let caption = settings.captionNode; + let captionSide = caption + ? caption._captionSide + : null; + let tableCloneHeader = table.clone(false); + let tableCloneFooter = table.clone(false); + let footer = table.children('tfoot'); + let size = function (s) { + return !s ? '100%' : stringToCss(s); + }; + /* + * The HTML structure that we want to generate in this function is: + * div - scroller + * div - scroll head + * div - scroll head inner + * table - scroll head table + * thead - thead + * div - scroll body + * table - table (master table) + * thead - thead clone for sizing + * tbody - tbody + * div - scroll foot + * div - scroll foot inner + * table - scroll foot table + * tfoot - tfoot + */ + let scroller = Dom.c('div') + .classAdd(classes.container) + .attr('role', 'table') + .append(Dom.c('div') + .classAdd(classes.header.self) + .css({ + overflow: 'hidden', + position: 'relative', + border: '0', + width: scrollX ? size(scrollX) : '100%' + }) + .attr('role', 'none') + .append(Dom.c('div') + .classAdd(classes.header.inner) + .css({ + 'box-sizing': 'content-box', + width: scroll.xInner || '100%' + }) + .attr('role', 'none') + .append(tableCloneHeader + .attrRemove('id') + .css('margin-left', '0') + .append(captionSide === 'top' ? caption : null) + .append(table.children('thead'))))) + .append(Dom.c('div') + .classAdd(classes.body) + .css({ + position: 'relative', + overflow: 'auto', + width: size(scrollX) + }) + .attr('role', 'none') + .append(table)); + if (footer.count()) { + scroller.append(Dom.c('div') + .classAdd(classes.footer.self) + .css({ + overflow: 'hidden', + border: '0', + width: scrollX ? size(scrollX) : '100%' + }) + .attr('role', 'none') + .append(Dom.c('div') + .classAdd(classes.footer.inner) + .attr('role', 'none') + .append(tableCloneFooter + .attrRemove('id') + .css('margin-left', '0') + .append(captionSide === 'bottom' ? caption : null) + .append(table.children('tfoot'))))); } - return dateFn ? dtLib[dateFn](arg1) : dtLib; + let children = scroller.children(); + let scrollHead = children.eq(0); + let scrollBody = children.eq(1); + let scrollFoot = children.eq(2); + // When the body is scrolled, then we also want to scroll the header and + // footer. Note that each element has its own scroll listener, and that in + // turn sets the scroll for the other elements. However this doesn't lead to + // an infinite loop as `scroll` is only triggered if the value changes. + scrollBody.on('scroll.DT', () => { + let scrollLeft = scrollBody.scrollLeft(); + scrollHead.scrollLeft(scrollLeft); + scrollFoot.scrollLeft(scrollLeft); + }); + scrollHead.on('scroll.DT', () => { + let scrollLeft = scrollHead.scrollLeft(); + scrollBody.scrollLeft(scrollLeft); + scrollFoot.scrollLeft(scrollLeft); + }); + scrollFoot.on('scroll.DT', () => { + let scrollLeft = scrollFoot.scrollLeft(); + scrollHead.scrollLeft(scrollLeft); + scrollBody.scrollLeft(scrollLeft); + }); + scrollBody.css('max-height', size(scrollY)); + if (!scroll.collapse) { + scrollBody.css('height', size(scrollY)); + } + settings.scrollHead = scrollHead; + settings.scrollBody = scrollBody; + settings.scrollFoot = scrollFoot; + // On redraw - align columns + settings.callbacks.draw.push(scrollDraw); + // Aria roles - because we break the table up into parts we need to be very + // explicit with the roles to create the accessability tree for the table, + // otherwise browser's attempt to "fix" the tree by filling in what it + // thinks are gaps. The static elements that we can assign roles to are done + // here. Dynamic ones are done in the draw function below. + table.attr('role', 'none'); + table.find('tbody').attr('role', 'rowgroup'); + tableCloneHeader.attr('role', 'none'); + tableCloneFooter.attr('role', 'none'); + settings.colgroup.find('colgroup').attr('role', 'none'); + // Move the info feature's aria desc by to the new "table" + let describedBy = table.attr('aria-describedby'); + if (describedBy) { + scroller.attr('aria-describedby', describedBy); + table.attrRemove('aria-describedby'); + } + return scroller.get(0); } -var __mlWarning = false; -var __luxon; -var __moment; /** + * Update the header, footer and body tables for resizing - i.e. column + * alignment. + * + * Welcome to the most horrible function DataTables. The process that this + * function follows is basically: + * 1. Re-create the table inside the scrolling div + * 2. Correct colgroup > col values if needed + * 3. Copy colgroup > col over to header and footer + * 4. Clean up * + * @param settings DataTables settings object */ -function resolveWindowLibs() { - __luxon = util.external('luxon'); - __moment = util.external('moment'); -} -function __mldObj(d, format, locale) { - var dt; - resolveWindowLibs(); - if (__moment) { - dt = __moment(d, format, locale, true); - if (!dt.isValid()) { - return null; - } - } - else if (__luxon) { - dt = - format && typeof d === 'string' - ? __luxon.DateTime.fromFormat(d, format) - : __luxon.DateTime.fromISO(d); - if (!dt.isValid) { - return null; - } - dt = dt.setLocale(locale); - } - else if (!format) { - // No format given, must be ISO - dt = new Date(d); +function scrollDraw(settings) { + // Given that this is such a monster function, a lot of variables are use + // to try and keep the minimised size as small as possible + let scroll = settings.scroll, barWidth = scroll.barWidth, divHeader = settings.scrollHead, divHeaderInner = divHeader.children('div'), divHeaderTable = divHeaderInner.children('table'), divBodyEl = settings.scrollBody, divBody = divBodyEl, divFooter = settings.scrollFoot, divFooterInner = divFooter.children('div'), divFooterTable = divFooterInner.children('table'), header = Dom.s(settings.thead), table = Dom.s(settings.table), footer = Dom.s(settings.tfoot), browser = settings.browser, headerCopy, footerCopy; + // If the scrollbar visibility has changed from the last draw, we need to + // adjust the column sizes as the table width will have changed to account + // for the scrollbar + let scrollBarVis = divBodyEl.get(0).scrollHeight > divBodyEl.get(0).clientHeight; + if (settings.scrollBarVis !== scrollBarVis && + settings.scrollBarVis !== undefined) { + settings.scrollBarVis = scrollBarVis; + adjustColumnSizing(settings); + return; // adjust column sizing will call this function again } else { - if (!__mlWarning) { - alert('DataTables warning: Formatted date without Moment.js or Luxon - https://datatables.net/tn/17'); - } - __mlWarning = true; + settings.scrollBarVis = scrollBarVis; } - return dt; -} -// Wrapper for date, datetime and time which all operate the same way with the -// exception of the output string for auto locale support -function __mlHelper(localeString) { - return function (from, to, locale, def) { - // Luxon and Moment support - // Argument shifting - if (arguments.length === 0) { - locale = 'en'; - to = null; // means toLocaleString - from = null; // means iso8601 - } - else if (arguments.length === 1) { - locale = 'en'; - to = from; - from = null; - } - else if (arguments.length === 2) { - locale = to; - to = from; - from = null; - } - var typeName = 'datetime' + (to ? '-' + to : ''); - // Add type detection and sorting specific to this date format - we need - // to be able to identify date type columns as such, rather than as - // numbers in extensions. Hence the need for this. - if (!store.order[typeName + '-pre']) { - register$1(typeName, { - detect: function (d) { - // The renderer will give the value to type detect as the - // type! - return d === typeName ? typeName : false; - }, - order: { - pre: function (d) { - // The renderer gives us Moment, Luxon or Date objects - // for the sorting, all of which have a `valueOf` which - // gives milliseconds epoch - return d.valueOf(); - } + header.find('thead').attr('role', 'rowgroup'); + footer.find('tfoot').attr('role', 'rowgroup'); + // 1. Re-create the table inside the scrolling div + // Remove the old minimised thead and tfoot elements in the inner table + table.children('thead, tfoot').remove(); + // Clone the current header and footer elements and then place it into the + // inner table + headerCopy = header.clone(true).prependTo(table); + headerCopy.find('th, td').attrRemove('tabindex'); + headerCopy.find('[id]').attrRemove('id'); + if (footer.count()) { + footerCopy = footer.clone(true).prependTo(table); + footerCopy.find('[id]').attrRemove('id'); + } + // 2. Correct colgroup > col values if needed + // It is possible that the cell sizes are smaller than the content, so we need to + // correct colgroup>col for such cases. This can happen if the auto width detection + // uses a cell which has a longer string, but isn't the widest! For example + // "Chief Executive Officer (CEO)" is the longest string in the demo, but + // "Systems Administrator" is actually the widest string since it doesn't collapse. + // Note the use of translating into a column index to get the `col` element. This + // is because of Responsive which might remove `col` elements, knocking the alignment + // of the indexes out. + if (settings.display.length) { + // Get the column sizes from the first row in the table. This should really be a + // [].find, but it wasn't supported in Chrome until Sept 2015, and DT has 10 year + // browser support + let firstTr = null; + let start = dataSource(settings) !== 'ssp' ? settings.displayStart : 0; + for (let i = start; i < start + settings.display.length; i++) { + let idx = settings.display[i]; + let row = settings.data[idx]; + if (row) { + let tr = row.tr; + if (tr) { + firstTr = tr; + break; } - }); - } - if (!store.className[typeName]) { - store.className[typeName] = 'dt-right'; + } } - return function (d, type) { - // Allow for a default value - if (d === null || d === undefined) { - if (def === '--now') { - // We treat everything as UTC further down, so no changes - // are made, as such need to get the local date / time as if - // it were UTC - var local = new Date(); - d = new Date(Date.UTC(local.getFullYear(), local.getMonth(), local.getDate(), local.getHours(), local.getMinutes(), local.getSeconds())); - } - else { - d = ''; + if (firstTr) { + let colSizes = Dom.s(firstTr) + .children('th, td') + .mapTo(function (cell, idx) { + return { + idx: visibleToColumnIndex(settings, idx), + width: Dom.s(cell).width('outer') + }; + }); + // Check against what the colgroup > col is set to and correct if needed + for (let i = 0; i < colSizes.length; i++) { + let colEl = settings.columns[colSizes[i].idx].colEl; + colEl.css('width', colSizes[i].width + 'px'); + if (scroll.x) { + colEl.css('minWidth', colSizes[i].width + 'px'); } } - if (type === 'type') { - // Typing uses the type name for fast matching - return typeName; - } - if (d === '') { - return type !== 'sort' - ? '' - : __mldObj('0000-01-01 00:00:00', null, locale); - } - // Shortcut. If `from` and `to` are the same, we are using the - // renderer to format for ordering, not display - its already in the - // display format. - if (to !== null && - from === to && - type !== 'sort' && - type !== 'type' && - !(d instanceof Date)) { - return d; - } - // Determine if there is a timezone. If there is, we want to reuse - // it for the output, so the timezone doesn't change between the - // input and output. - let options = {}; - let tzMatch = typeof d === 'string' ? d.match(util.regex.isoTimezone) : null; - if (tzMatch) { - options.timeZone = tzMatch[1] === 'Z' ? 'UTC' : tzMatch[1]; - } - // Get a Date object (Luxon, moment or Date) - var dt = __mldObj(d, from, locale); - if (dt === null) { - return d; - } - if (type === 'sort') { - return dt; - } - var formatted = to === null - ? __mld(dt, 'toDate', 'toJSDate', '')[localeString](navigator.language, options) - : __mld(dt, 'format', 'toFormat', 'toISOString', to); - // XSS protection - return type === 'display' ? util.escapeHtml(formatted) : formatted; - }; - }; -} -// Based on locale, determine standard number formatting -// Fallback for legacy browsers is US English -var __thousands = ','; -var __decimal = '.'; -if (window.Intl !== undefined) { - try { - var num = new Intl.NumberFormat().formatToParts(100000.1); - for (var i = 0; i < num.length; i++) { - if (num[i].type === 'group') { - __thousands = num[i].value; - } - else if (num[i].type === 'decimal') { - __decimal = num[i].value; - } } } - catch (e) { - // noop - } -} -/** - * Register a date / time format for DataTables to use. - * - * @param format The date / time format to detect data in. Please refer to the - * Moment.js or Luxon document for the full list of tokens, depending on which - * of the two libraries you are using. - * @param locale The locale to pass to Moment.js / Luxon. - */ -function datetime(format, locale) { - var typeName = 'datetime-' + format; - if (!locale) { - locale = 'en'; + // 3. Copy the colgroup over to the header and footer + divHeaderTable.find('colgroup').remove(); + divHeaderTable.append(settings.colgroup.clone(true)); + if (footer) { + divFooterTable.find('colgroup').remove(); + divFooterTable.append(settings.colgroup.clone(true)); } - if (!store.order[typeName]) { - register$1(typeName, { - detect: function (d) { - var dt = __mldObj(d, format, locale); - return d === '' || dt ? typeName : false; - }, - order: { - pre: function (d) { - return __mldObj(d, format, locale) || 0; - } - } + // "Hide" the header and footer that we used for the sizing. We need to keep + // the content of the cell so that the width applied to the header and body + // both match, but we want to hide it completely. + headerCopy.find('th, td').each(function (el) { + Dom.c('div') + .classAdd('dt-scroll-sizing') + .append(Array.from(el.childNodes)) + .appendTo(el); + }); + if (footerCopy) { + footerCopy.find('th, td').each(function (el) { + Dom.c('div') + .classAdd('dt-scroll-sizing') + .append(Array.from(el.childNodes)) + .appendTo(el); }); } - if (!store.className[typeName]) { - store.className[typeName] = 'dt-right'; + // 4. Clean up + // Figure out if there are scrollbar present - if so then we need the header and footer to + // provide a bit more space to allow "overflow" scrolling (i.e. past the scrollbar) + let isScrolling = Math.floor(table.height()) > divBodyEl.get(0).clientHeight || + divBody.css('overflow-y') == 'scroll'; + let paddingSide = 'padding' + (browser.scrollbarLeft ? 'Left' : 'Right'); + // Set the width's of the header and footer tables + let outerWidth = table.width('withPadding'); + divHeaderTable.css('width', stringToCss(outerWidth)); + divHeaderInner + .css('width', stringToCss(outerWidth)) + .css(paddingSide, isScrolling ? barWidth + 'px' : '0px'); + if (footer.count()) { + divFooterTable.css('width', stringToCss(outerWidth)); + divFooterInner + .css('width', stringToCss(outerWidth)) + .css(paddingSide, isScrolling ? barWidth + 'px' : '0px'); + } + // Correct DOM ordering for colgroup - comes before the thead + table.children('colgroup').prependTo(table); + // Remove tabindex from the hidden row elements + table.find('thead, tfoot').find('[tabindex]').attrRemove('tabindex'); + // Dynamic ARIA roles - see setup for details on why this is needed + table + .find('thead, tfoot') + .attr('role', 'none') + .find('[role]') + .attrRemove('role'); + table.find('tbody tr:not([role])').attr('role', 'row'); + table.find('tbody td:not([role]), tbody th:not([role])').attr('role', 'cell'); + scrollAria(headerCopy); + scrollAria(footerCopy); + // Adjust the position of the header in case we loose the y-scrollbar + divBody.trigger('scroll'); + // If sorting or filtering has occurred, jump the scrolling back to the top + // only if we aren't holding the position + if ((settings.wasOrdered || settings.wasFiltered) && !settings.drawHold) { + divBodyEl.scrollTop(0); } } /** - * Helpers for `columns.render`. - */ -var helpers = { - date: __mlHelper('toLocaleDateString'), - datetime: __mlHelper('toLocaleString'), - time: __mlHelper('toLocaleTimeString'), - number: function (thousands, decimal, precision, prefix, postfix) { - // Auto locale detection - if (thousands === null || thousands === undefined) { - thousands = __thousands; - } - if (decimal === null || decimal === undefined) { - decimal = __decimal; - } - return { - display: function (d) { - if (typeof d !== 'number' && typeof d !== 'string') { - return d; - } - if (d === '' || d === null) { - return d; - } - var flo = typeof d === 'number' ? d : parseFloat(d); - var negative = flo < 0 ? '-' : ''; - var abs = Math.abs(flo); - // Scientific notation for large and small numbers - if (abs >= 100000000000 || (abs < 0.0001 && abs !== 0)) { - var exp = flo.toExponential(precision).split(/e\+?/); - return exp[0] + ' x 10' + exp[1] + ''; - } - // If NaN then there isn't much formatting that we can do - just - // return immediately, escaping any HTML (this was supposed to - // be a number after all) - if (isNaN(flo)) { - return util.escapeHtml(d); - } - flo = flo.toFixed(precision); - var absPart = Math.abs(flo); - var intPart = Math.abs(parseInt(flo, 10)); - var floatPart = precision - ? decimal + - (absPart - intPart).toFixed(precision).substring(2) - : ''; - // If zero, then can't have a negative prefix - if (intPart === 0 && parseFloat(floatPart) === 0) { - negative = ''; - } - return (negative + - (prefix || '') + - intPart - .toString() - .replace(/\B(?=(\d{3})+(?!\d))/g, thousands) + - floatPart + - (postfix || '')); - } - }; - }, - text: function () { - return { - display: util.escapeHtml, - filter: util.escapeHtml - }; - } -}; - -/** - * Column options that can be given to DataTables at initialisation time. - */ -const defaults$4 = { - ariaTitle: '', - cellType: 'td', - className: '', - contentPadding: '', - createdCell: null, - data: null, - defaultContent: null, - footer: null, - name: '', - orderable: true, - orderData: null, - orderDataType: 'std', - orderSequence: ['asc', 'desc', ''], - render: null, - search: null, - searchable: true, - title: null, - type: null, - visible: true, - width: null -}; - -/** - * Internal settings object used for individual columns. Instances are held in - * the setting object's `columns` array and contains all the information that - * DataTables needs about each individual column. - * - * Note that this object is related to the column defaults but this one is the - * internal data store for DataTables's cache of columns. It should NOT be - * manipulated outside of DataTables. Any configuration should be done through - * the initialisation options. + * Apply ARIA roles for the header / footer of a scrolling table + * @param element */ -class Settings { - constructor() { - /** - * Flag to indicate if HTML5 data attributes should be used as the data - * source for filtering or sorting. True is either are. - */ - this.attrSrc = false; - this.ariaTitle = ''; - /** - * The class to apply to all cells in the table's `tbody`` for the column - */ - this.className = null; - /** - * When DataTables calculates the column widths to assign to each column, it - * finds the longest string in each column and then constructs a temporary - * table and reads the widths from that. The problem with this is that "mmm" - * is much wider then "iiii", but the latter is a longer string - thus the - * calculation can go wrong (doing it properly and putting it into an DOM - * object and measuring that is horribly(!) slow). Thus as a "work around" - * we provide this option. It will append its value to the text that is - * found to be the longest string for the column - i.e. padding. - */ - this.contentPadding = null; - /** - * Property to read the value for the cells in the column from the data - * source array / object. If null, then the default content is used, if a - * function is given then the return from the function is used. - */ - this.data = null; - /** - * Allows a default value to be given for a column's data, and will be used - * whenever a null data source is encountered (this can be because mData is - * set to null, or because the data source itself is null). - */ - this.defaultContent = null; - /** - * Name for the column, allowing reference to the column by name as well as - * by index (needs a lookup to work by name). - */ - this.name = null; - /** - * A list of the columns that sorting should occur on when this column is - * sorted. That this property is an array allows multi-column sorting to be - * defined for a column (for example first name / last name columns would - * benefit from this). The values are integers pointing to the columns to be - * sorted on (typically it will be a single integer pointing at itself, but - * that doesn't need to be the case). - */ - this.orderData = []; - /** - * Custom sorting data type - defines which of the available plug-ins in - * afnSortData the custom sorting will use - if any is defined. - */ - this.orderDataType = 'std'; - /** - * Class to be applied to the header element when sorting on this column - */ - this.orderingClass = null; - /** - * Define the sorting directions that are applied to the column, in sequence - * as the column is repeatedly sorted upon - i.e. the first value is used as - * the sorting direction when the column if first sorted (clicked on). Sort - * it again (click again) and it will move on to the next index. Repeat - * until loop. - */ - this.orderSequence = []; - /** - * Partner property to mData which is used (only when defined) to get the - * data - i.e. it is basically the same as mData, but without the 'set' - * option, and also the data fed to it is the result from mData. This is the - * rendering method to match the data method of mData. - */ - this.render = null; - /** - * Title of the column - what is seen in the TH element (nTh). - */ - this.title = null; - /** - * Store for manual type assignment using the `column.type` option. This - * is held in store so we can manipulate the column's `type` property. - */ - this.typeManual = null; - /** Cached longest strings from a column */ - this.wideStrings = null; - /** - * Width of the column - */ - this.width = null; - /** - * Width of the column when it was first "encountered" - */ - this.widthOrig = null; +function scrollAria(element) { + if (element) { + element.find('tfoot:not([role])').attr('role', 'rowgroup'); + element.find('tr:not([role])').attr('role', 'row'); + element.find('th:not([role])').attr('role', 'columnheader'); + element.find('td:not([role])').attr('role', 'cell'); } } -const defaults$3 = { - boundary: false, - caseInsensitive: true, - columns: null, - exact: false, - regex: false, - return: false, - search: '', - smart: true -}; /** - * Create a new search options object + * Add the options to the page HTML for the table * - * @param parts Values to assign, otherwise the defaults will be used - * @returns New object + * @param ctx DataTables context */ -function create$2(parts = {}) { - return util.object.assignDeep({}, defaults$3, parts); +function createLayout(ctx) { + var classes = ctx.classes; + // Wrapper div around everything DataTables controls + var insert = Dom + .c('div') + .attr('id', ctx.tableId + '_wrapper') + .classAdd(classes.container) + .insertBefore(ctx.table); + ctx.tableWrapper = insert.get(0); + if (ctx.dom) { + // Legacy + legacyDom(ctx, ctx.dom, insert); + } + else { + var top = convert(ctx, ctx.layout, 'top'); + var bottom = convert(ctx, ctx.layout, 'bottom'); + var render = renderer(ctx, 'layout'); + // Everything above - the renderer will actually insert the contents into the document + top.forEach(function (item) { + render(ctx, insert, item); + }); + // The table - always the center of attention + render(ctx, insert, { + full: { + contents: [featureTable(ctx)], + items: [], + table: true + } + }); + // Everything below + bottom.forEach(function (item) { + render(ctx, insert, item); + }); + } + // Processing floats on top, so it isn't an inserted feature + processingHtml(ctx); } - -const browser = { - barWidth: -1, - scrollbarLeft: false -}; -const hungarianToCamelRe = /^(a|aa|ai|ao|as|b|fn|i|m|o|s)([A-Z])([a-z].*$)/; /** - * Take an object which has hungarian notation parameters and convert them to - * camelCase style. This is to allow compatibility with DataTables 1.9 and - * earlier which only used hungarian notation, and also with DataTables 1.10 - 2 - * which allowed it to be used. + * Expand the layout items into an object for the rendering function */ -function hungarianToCamel(user) { - if (!user) { - return user; +function layoutItems(row, align, items) { + if (Array.isArray(items)) { + for (var i = 0; i < items.length; i++) { + layoutItems(row, align, items[i]); + } + return; } - let userKeys = Object.keys(user); - let userAny = user; - for (let i = 0; i < userKeys.length; i++) { - let userKey = userKeys[i]; - let match = userKey.match(hungarianToCamelRe); - // Is the key in hungarian notation? - if (match) { - // If so map it down - user[match[2].toLowerCase() + match[3]] = userAny[userKey]; + var rowCell = row[align]; // can't be undefined - will have been created by getRow + // If it is an object, then there can be multiple features contained in it + if (util.is.plainObject(items)) { + // Is it an cell object already, with rowId, etc. A feature plugin cannot + // be named "features" due to this check + if (items.features) { + if (items.rowId) { + row.id = items.rowId; + } + if (items.rowClass) { + row.className = items.rowClass; + } + rowCell.id = items.id; + rowCell.className = items.className; + layoutItems(row, align, items.features); } - // Recurse down through the object - if (util.is.plainObject(userAny[userKey])) { - hungarianToCamel(userAny[userKey]); + else { + // An object of features and configuration options - e.g. `{paging: {startEnd: false}}` + util.object.each(items, (key, val) => { + rowCell.items.push({ + feature: key, + opts: val + }); + }); } } - return user; + else { + // Otherwise, it is a function, node or Dom / jQuery instance and can just get added + rowCell.items.push(items); + } } /** - * Map one parameter onto another + * Find, or create a layout row and setup a target cell in it * - * @param o Object to map - * @param newKey The new parameter name - * @param oldKey The old parameter name + * @param rows Rows array to search for the target row. Is mutated when a row is + * added if not found. + * @param rowNum Row index to get + * @param align Where the cell position is + * @returns The row */ -function compatMap(o, newKey, oldKey) { - if (o[oldKey] !== undefined) { - o[newKey] = o[oldKey]; +function getRow(rows, rowNum, align) { + var row; + // Find existing rows + for (var i = 0; i < rows.length; i++) { + row = rows[i]; + if (row.rowNum === rowNum) { + // full is on its own, but start and end share a row + if ((align === 'full' && row.full) || + ((align === 'start' || align === 'end') && + (row.start || row.end))) { + if (!row[align]) { + row[align] = { + contents: [], + items: [] + }; + } + return row; + } + } } + // If we get this far, then there was no match, create a new row + row = { + rowNum: rowNum + }; + row[align] = { + contents: [], + items: [] + }; + rows.push(row); + return row; } /** - * Provide backwards compatibility for the main DT options. Note that the new - * options are mapped onto the old parameters, so this is an external interface - * change only. + * Convert a `layout` object given by a user to the object structure needed + * for the renderer. This is done twice, once for above and once for below + * the table. Ordering must also be considered. * - * @param init Object to map + * @param settings DataTables settings object + * @param layout Layout object to convert + * @param side `top` or `bottom` + * @returns Converted array structure - one item for each row. */ -function compatOpts(init) { - // Convert any old style parameters to camelCase - hungarianToCamel(init); - // Map old parameter names to new - compatMap(init, 'ordering', 'sort'); - compatMap(init, 'orderMulti', 'sortMulti'); - compatMap(init, 'orderClasses', 'sortClasses'); - compatMap(init, 'orderCellsTop', 'sortCellsTop'); - compatMap(init, 'order', 'sorting'); - compatMap(init, 'orderFixed', 'sortingFixed'); - compatMap(init, 'paging', 'paginate'); - compatMap(init, 'pagingType', 'paginationType'); - compatMap(init, 'pageLength', 'displayLength'); - compatMap(init, 'searching', 'filter'); - compatMap(init, 'stateDuration', 'cookieDuration'); - // Boolean initialisation of x-scrolling - if (typeof init.scrollX === 'boolean') { - init.scrollX = init.scrollX ? '100%' : ''; - } - // Objects for ordering - if (typeof init.ordering === 'object') { - init.orderIndicators = - init.ordering.indicators !== undefined - ? init.ordering.indicators - : true; - init.orderHandler = - init.ordering.handler !== undefined ? init.ordering.handler : true; - init.ordering = true; - } - else if (init.ordering === false) { - init.orderIndicators = false; - init.orderHandler = false; - } - else if (init.ordering === true) { - init.orderIndicators = true; - init.orderHandler = true; - } - // Which cells are the title cells? - if (typeof init.orderCellsTop === 'boolean') { - init.titleRow = init.orderCellsTop; - } - // Column search objects are in an array, so it needs to be converted - // element by element - var searchCols = init.searchCols; - if (searchCols) { - for (var i = 0, iLen = searchCols.length; i < iLen; i++) { - if (searchCols[i]) { - hungarianToCamel(searchCols[i]); - } +function convert(settings, layout, side) { + var rows = []; + // Split out into an array + util.object.each(layout, function (pos, items) { + var parts = pos.match(/^([a-z]+)([0-9]*)([A-Za-z]*)$/); + if (items === null || !parts) { + return; } + var rowNum = parts[2] ? parseInt(parts[2]) : 0; + var align = parts[3] ? parts[3].toLowerCase() : 'full'; + // Filter out the side we aren't interested in + if (parts[1] !== side) { + return; + } + // Only really a type check + if (align !== 'full' && align !== 'start' && align !== 'end') { + return; + } + // Get or create the row we should attach to + var row = getRow(rows, rowNum, align); + layoutItems(row, align, items); + }); + // Order by item identifier + rows.sort(function (a, b) { + var order1 = a.rowNum || 0; + var order2 = b.rowNum || 0; + // If both in the same row, then the row with `full` comes first + if (order1 === order2) { + var ret = a.full && !b.full ? -1 : 1; + return side === 'bottom' ? ret * -1 : ret; + } + return order2 - order1; + }); + // Invert for below the table + if (side === 'bottom') { + rows.reverse(); } - // Enable search delay if server-side processing is enabled - if (init.serverSide && !init.searchDelay) { - init.searchDelay = 400; - } - // Language - if (init.language && init.language.url && !init.language.ajax) { - init.language.ajax = init.language.url; + for (var row = 0; row < rows.length; row++) { + delete rows[row].rowNum; + resolve(settings, rows[row]); } + return rows; +} +/** + * Convert the contents of a row's layout object to nodes that can be inserted + * into the document by a renderer. Execute functions, look up plug-ins, etc. + * + * @param settings DataTables settings object + * @param row Layout object for this row + */ +function resolve(settings, row) { + var getFeature = function (feature, opts) { + if (!ext.features[feature]) { + log(settings, 0, 'Unknown feature: ' + feature); + } + return ext.features[feature].apply(this, [settings, opts]); + }; + // Resolve items in the `contents` array from being an identifier, such as + // the name of a feature, into the node to display. + var resolve = function (item) { + if (!row[item]) { + return; + } + row[item].contents = row[item].items + .filter(item => !!item) + .map(item => { + if (typeof item === 'string') { + return getFeature(item, null); + } + else if (util.is.plainObject(item)) { + // If it's an object, it just has feature and opts properties from + // the transform in _layoutArray + return getFeature(item.feature, item.opts); + } + else if (typeof item.node === 'function') { + return item.node(settings); + } + else if (typeof item === 'function') { + var inst = item(settings); + return typeof inst.node === 'function' ? inst.node() : inst; + } + else if (item.nodeName) { + // An HTML element + return item; + } + else if (item instanceof Dom) { + return item.get(0); + } + else if (item.length) { + // Possibly jQuery + return item[0]; + } + }); + }; + resolve('start'); + resolve('end'); + resolve('full'); } /** - * Provide backwards compatibility for column options. Note that the new options - * are mapped onto the old parameters, so this is an external interface change - * only. + * Draw the table with the legacy DOM property * - * @param init Object to map + * @param settings DT settings instance + * @param layout DOM string + * @param insert Insert point */ -function compatCols(init) { - // Convert any old style parameters to camelCase - hungarianToCamel(init); - // typeof columnDefaults - compatMap(init, 'orderable', 'sortable'); - compatMap(init, 'orderData', 'dataSort'); - compatMap(init, 'orderSequence', 'sorting'); - compatMap(init, 'orderDataType', 'sortDataType'); - compatMap(init, 'className', 'class'); - // orderData can be given as an integer - var dataSort = init.aDataSort; - var orderData = init.orderData; - if (typeof dataSort === 'number') { - init.orderData = [dataSort]; - } - if (typeof orderData === 'number') { - init.orderData = [orderData]; - } - // Backwards compatibility for mDataProp from 1.9- - if (init.dataProp !== undefined && !init.data) { - init.data = init.dataProp; +function legacyDom(settings, layout, insert) { + let parts = layout.match(/(".*?")|('.*?')|./g); + let featureNode, option, newNode, next, attr; + if (!parts) { + return; } -} -/** - * Browser feature detection for capabilities, quirks - * - * @param ctx DataTables settings object - */ -function browserDetect(ctx) { - // We don't need to do this every time DataTables is constructed, the values - // calculated are specific to the browser and OS configuration which we - // don't expect to change between initialisations - if (browser.barWidth === -1) { - // Scrolling feature / quirks detection - var n = Dom - .c('div') - .css({ - position: 'fixed', - top: '0', - left: -1 * window.pageXOffset + 'px', // allow for scrolling - height: '1px', - width: '1px', - overflow: 'hidden' - }) - .append(Dom - .c('div') - .css({ - position: 'absolute', - top: '1px', - left: '1px', - width: '100px', - overflow: 'scroll' - }) - .append(Dom.c('div').css({ - width: '100%', - height: '10px' - }))) - .appendTo('body'); - var outer = n.children(); - var inner = outer.children(); - browser.barWidth = outer.get(0).offsetWidth - outer.get(0).clientWidth; - browser.scrollbarLeft = Math.round(inner.offset().left) !== 1; - n.remove(); + for (let i = 0; i < parts.length; i++) { + featureNode = null; + option = parts[i]; + if (option == '<') { + // New container div + newNode = Dom.c('div'); + // Check to see if we should append an id and/or a class name to the container + next = parts[i + 1]; + if (next[0] == "'" || next[0] == '"') { + attr = next.replace(/['"]/g, ''); + let id = '', className; + /* The attribute can be in the format of "#id.class", "#id" or "class" This logic + * breaks the string into parts and applies them as needed + */ + if (attr.indexOf('.') != -1) { + let split = attr.split('.'); + id = split[0]; + className = split[1]; + } + else if (attr[0] == '#') { + id = attr; + } + else { + className = attr; + } + newNode.attr('id', id.substring(1)).classAdd(className); + i++; // Move along the position array + } + insert.append(newNode.get()); // TODO + insert = newNode; + } + else if (option == '>') { + // End container div + insert = insert.parent(); + } + else if (option == 't') { + // Table + featureNode = featureTable(settings); + } + else { + ext.feature.forEach(function (feature) { + if (option == feature.cFeature) { + featureNode = feature.fnInit(settings); + } + }); + } + // Add to the display + if (featureNode) { + // TODO when doing the full dom update, won't need this check + insert.append(featureNode instanceof Dom ? featureNode.get() : featureNode); + } } - Object.assign(ctx.browser, browser); - ctx.scroll.barWidth = browser.barWidth; } -const defaults$2 = { - addedClasses: [], - cells: [], - data: [], - details: undefined, - detailsShow: undefined, - displayData: null, - idx: -1, - orderCache: null, - searchCellCache: null, - searchRowCache: null, - src: 'dom', - tr: null -}; -/** - * Create a new object that is a row model - * - * @param parts Values to assign, otherwise the defaults will be used - * @returns New object - */ -function create$1(parts = {}) { - return util.object.assignDeep({}, defaults$2, parts); +function sortInit(settings) { + var notSelector = ':not([data-dt-order="disable"]):not([data-dt-order="icon-only"])'; + if (settings.orderHandler) { + columnOrderingCells(settings, notSelector) + .each(el => { + sortAttachListener(settings, el, ''); + }); + } + // Need to resolve the user input array into our internal structure + var order = []; + sortResolve(settings, order, settings.order); + settings.order = order; } - /** - * Add a data array to the table, creating DOM node etc. This is the parallel to - * gatherData, but for adding rows from a JavaScript source, rather than a - * DOM source. + * Attach event listeners to a node that will trigger ordering on a column * - * @param settings DataTables settings object - * @param dataIn data array to be added - * @param tr TR element to add to the table - optional. If not given, DataTables - * will create a row automatically - * @param tds Array of TD|TH elements for the row - must be given if tr is. - * @returns >=0 if successful (index of new data entry), -1 if failed + * @param settings DataTables context + * @param node Node to attach to + * @param selector Delegate selector + * @param column Column index to target + * @param callback Callback for when done */ -function addData(settings, dataIn, tr, tds) { - /* Create the object for storing information about this new row */ - var rowIdx = settings.data.length; - var row = create$1({ - src: tr ? 'dom' : 'data', - idx: rowIdx +function sortAttachListener(settings, node, selector, column, callback) { + bindAction(node, selector, function (e) { + var run = false; + var columns = column === undefined + ? columnsFromHeader(e.target) + : typeof column === 'function' + ? column() + : Array.isArray(column) + ? column + : [column]; + if (columns.length) { + for (var i = 0, iLen = columns.length; i < iLen; i++) { + var ret = sortAdd(settings, columns[i], i, e.shiftKey); + if (ret !== false) { + run = true; + } + // If the first entry is no sort, then subsequent + // sort columns are ignored + if (settings.order.length === 1 && + settings.order[0][1] === '') { + break; + } + } + if (run) { + processingRun(settings, true, function () { + sort(settings); + sortDisplay(settings, settings.display); + reDraw(settings, false, false); + if (callback) { + callback(); + } + }); + } + } }); - row.data = dataIn; - settings.data.push(row); - var columns = settings.columns; - for (var i = 0, iLen = columns.length; i < iLen; i++) { - // Invalidate the column types as the new data needs to be revalidated - columns[i].type = null; - } - /* Add to the display array */ - settings.displayMaster.push(rowIdx); - var id = settings.rowIdFn(dataIn); - if (id !== undefined) { - settings.ids[id] = row; - } - /* Create the DOM information, or register it if already present */ - if (tr || !settings.features.deferRender) { - createTr(settings, rowIdx, tr, tds); - } - return rowIdx; } /** - * Add one or more TR elements to the table. Generally we'd expect to - * use this for reading data from a DOM sourced table, but it could be - * used for an TR element. Note that if a TR is given, it is used (i.e. - * it is not cloned). + * Sort the display array to match the master's order * - * @param settings DataTables settings object - * @param rows The TR element(s) to add to the table - * @returns Array of indexes for the added rows + * @param settings DataTables context + * @param display The display array */ -function addTr(settings, rows) { - return rows.mapTo(el => { - let row = getRowElementsFromNode(settings, el); - return addData(settings, row.data, el, row.cells); +function sortDisplay(settings, display) { + if (display.length < 2) { + return; + } + var master = settings.displayMaster; + var masterMap = {}; + var map = {}; + var i; + // Rather than needing an `indexOf` on master array, we can create a map + for (i = 0; i < master.length; i++) { + masterMap[master[i]] = i; + } + // And then cache what would be the indexOf from the display + for (i = 0; i < display.length; i++) { + map[display[i]] = masterMap[display[i]]; + } + display.sort(function (a, b) { + // Short version of this function is simply `master.indexOf(a) - master.indexOf(b);` + return map[a] - map[b]; }); } /** - * Get the data for a given cell from the internal cache, taking into account - * data mapping + * Convert the API variants that can be used for defining the order into our + * internal OrderColumn array. * - * @param settings DataTables settings object - * @param rowIdx data row id - * @param colIdx Column index - * @param type data get type ('display', 'type' 'filter|search' 'sort|order') - * @returns Cell data + * @param settings DataTable context object + * @param nestedSort Array to write the resolve values to + * @param sortItem Source object / array from user (It is really an `Order` + * but due to `aaSorting` being used for input and the internal structure + * it is currently any). + * @todo Split aaSorting into unresolved and resolved parameters (in state.ts as + * well) */ -function getCellData(settings, rowIdx, colIdx, type) { - if (type === 'search') { - type = 'filter'; - } - else if (type === 'order') { - type = 'sort'; - } - var row = settings.data[rowIdx]; - if (!row) { - return undefined; +function sortResolve(settings, nestedSort, sortItem // TODO typing +) { + var push = function (a) { + if (plainObject(a)) { + let orderIdx = a; + let orderName = a; + if (orderIdx.idx !== undefined) { + // Index based ordering + nestedSort.push([orderIdx.idx, orderIdx.dir]); + } + else if (orderName.name) { + // Name based ordering + var cols = pluck(settings.columns, 'name'); + var idx = cols.indexOf(orderName.name); + if (idx !== -1) { + nestedSort.push([idx, orderName.dir]); + } + } + } + else { + // Plain column index and direction pair + nestedSort.push(a); + } + }; + if (plainObject(sortItem)) { + // Object + push(sortItem); } - var draw = settings.drawCount; - var col = settings.columns[colIdx]; - var rowData = row.data; - var defaultContent = col.defaultContent; - var cellData = col.dataGet(rowData, type, { - settings: settings, - row: rowIdx, - col: colIdx - }); - // Allow for a node being returned for non-display types - if (type !== 'display' && - cellData && - typeof cellData === 'object' && - cellData.nodeName) { - cellData = cellData.innerHTML; + else if (Array.isArray(sortItem) && typeof sortItem[0] === 'number') { + // 1D array + push(sortItem); } - if (cellData === undefined) { - if (settings.drawError != draw && defaultContent === null) { - log(settings, 0, 'Requested unknown parameter ' + - (typeof col.data == 'function' - ? '{function}' - : "'" + col.data + "'") + - ' for row ' + - rowIdx + - ', column ' + - colIdx, 4); - settings.drawError = draw; + else if (Array.isArray(sortItem)) { + // 2D array + for (var z = 0; z < sortItem.length; z++) { + push(sortItem[z]); // Object or array } - return defaultContent; } - // When the data source is null and a specific data type is requested (i.e. - // not the original data), we can use default column data - if ((cellData === rowData || cellData === null) && - defaultContent !== null && - type !== undefined) { - cellData = defaultContent; +} +function sortFlatten(settings) { + var i, k, kLen, aSort = [], extSort = ext.type.order, aoColumns = settings.columns, dataSort, colIdx, type, srcCol, fixed = settings.orderFixed, fixedObj = plainObject(fixed), nestedSort = []; + if (!settings.features.ordering) { + return aSort; } - else if (typeof cellData === 'function') { - // If the data source is a function, then we run it and use the return, - // executing in the scope of the data object (for instances) - return cellData.call(rowData); + // Build the sort array, with pre-fix and post-fix options if they have been + // specified + if (Array.isArray(fixed)) { + sortResolve(settings, nestedSort, fixed); } - if (cellData === null && type === 'display') { - return ''; + if (fixedObj && fixed.pre) { + sortResolve(settings, nestedSort, fixed.pre); } - if (type === 'filter') { - var formatters = ext.type.search; - if (col.type && formatters[col.type]) { - cellData = formatters[col.type](cellData); + sortResolve(settings, nestedSort, settings.order); + if (fixedObj && fixed.post) { + sortResolve(settings, nestedSort, fixed.post); + } + for (i = 0; i < nestedSort.length; i++) { + srcCol = nestedSort[i][0]; + if (aoColumns[srcCol]) { + dataSort = aoColumns[srcCol].orderData; + for (k = 0, kLen = dataSort.length; k < kLen; k++) { + colIdx = dataSort[k]; + type = aoColumns[colIdx].type || 'string'; + if (nestedSort[i]._idx === undefined) { + nestedSort[i]._idx = aoColumns[colIdx].orderSequence.indexOf(nestedSort[i][1]); + } + if (nestedSort[i][1]) { + aSort.push({ + src: srcCol, + col: colIdx, + dir: nestedSort[i][1], + index: nestedSort[i]._idx, + type: type, + formatter: extSort[type + '-pre'], + sorter: extSort[type + '-' + nestedSort[i][1]] + }); + } + } } } - return cellData; + return aSort; } /** - * Set the value for a specific cell, into the internal data cache + * Change the order of the table * - * @param settings DataTables settings object - * @param rowIdx data row id - * @param colIdx Column index - * @param val Value to set + * @param ctx DataTables settings object + * @param col Column to perform sort on + * @param dir Direction to sort on */ -function setCellData(settings, rowIdx, colIdx, val) { - let row = settings.data[rowIdx]; - if (row) { - let col = settings.columns[colIdx]; - let rowData = row.data; - col.dataSet(rowData, val, { - settings: settings, - row: rowIdx, - col: colIdx +function sort(ctx, col, dir) { + var i, iLen, aiOrig = [], extSort = ext.type.order, data = ctx.data, sortCol, displayMaster = ctx.displayMaster, aSort; + // Make sure the columns all have types defined + columnTypes(ctx); + // Allow a specific column to be sorted, which will _not_ alter the display + // master + if (col !== undefined) { + var srcCol = ctx.columns[col]; + aSort = [ + { + src: col, + col: col, + dir: dir || '', + index: 0, + type: srcCol.type, + formatter: extSort[srcCol.type + '-pre'], + sorter: extSort[srcCol.type + '-' + dir] + } + ]; + displayMaster = displayMaster.slice(); + } + else { + aSort = sortFlatten(ctx); + } + for (i = 0, iLen = aSort.length; i < iLen; i++) { + sortCol = aSort[i]; + // Load the data needed for the sort, for each cell + sortData(ctx, sortCol.col); + } + /* No sorting required if server-side or no sorting array */ + if (dataSource(ctx) != 'ssp' && aSort.length !== 0) { + // Reset the initial positions on each pass so we get a stable sort + for (i = 0, iLen = displayMaster.length; i < iLen; i++) { + aiOrig[i] = i; + } + // If the first sort is desc, then reverse the array to preserve original + // order, just in reverse + if (aSort.length && aSort[0].dir === 'desc' && ctx.orderDescReverse) { + aiOrig.reverse(); + } + /* Do the sort - here we want multi-column sorting based on a given data source (column) + * and sorting function (from oSort) in a certain direction. It's reasonably complex to + * follow on its own, but this is what we want (example two column sorting): + * fnLocalSorting = function(a,b){ + * var test; + * test = oSort['string-asc']('data11', 'data12'); + * if (test !== 0) + * return test; + * test = oSort['numeric-desc']('data21', 'data22'); + * if (test !== 0) + * return test; + * return oSort['numeric-asc']( aiOrig[a], aiOrig[b] ); + * } + * Basically we have a test for each sorting column, if the data in that column is equal, + * test the next column. If all columns match, then we use a numeric sort on the row + * positions in the original data array to provide a stable sort. + */ + displayMaster.sort(function (a, b) { + var _a, _b; + var x, y, k, test, sortItem, len = aSort.length, dataA = (_a = data[a]) === null || _a === void 0 ? void 0 : _a.orderCache, dataB = (_b = data[b]) === null || _b === void 0 ? void 0 : _b.orderCache; + for (k = 0; k < len; k++) { + sortItem = aSort[k]; + // Data, which may have already been through a `-pre` function + x = dataA[sortItem.col]; + y = dataB[sortItem.col]; + if (sortItem.sorter) { + // If there is a custom sorter (`-asc` or `-desc`) for this + // data type, use it + test = sortItem.sorter(x, y); + if (test !== 0) { + return test; + } + } + else { + // Otherwise, use generic sorting + test = x < y ? -1 : x > y ? 1 : 0; + if (test !== 0) { + return sortItem.dir === 'asc' ? test : -test; + } + } + } + x = aiOrig[a]; + y = aiOrig[b]; + return x < y ? -1 : x > y ? 1 : 0; }); } -} -/** - * Write a value to a cell - * - * @param td Cell - * @param val Value - */ -function writeCell(td, val) { - let cell = Dom.s(td); - if (val && typeof val === 'object' && val.nodeName) { - cell.empty().append(val); + else if (aSort.length === 0) { + // Apply index order + displayMaster.sort(function (x, y) { + return x < y ? -1 : x > y ? 1 : 0; + }); } - else { - cell.html(val); + if (col === undefined) { + // Tell the draw function that we have sorted the data + ctx.wasOrdered = true; + ctx.sortDetails = aSort; + callbackFire(ctx, null, 'order', [ctx, aSort]); } + return displayMaster; } /** - * Return an array with the full table data - * - * @param settings DataTables settings object - * @returns array {array} aData Master data array - */ -function getDataMaster(settings) { - return util.array.pluck(settings.data, 'data'); -} -/** - * Nuke the table - * - * @param settings DataTables settings object - */ -function clearTable(settings) { - settings.data.length = 0; - settings.displayMaster.length = 0; - settings.display.length = 0; - settings.ids = {}; -} -/** - * Mark cached data as invalid such that a re-read of the data will occur when - * the cached data is next requested. Also update from the data source object. + * Function to run on user sort request * - * @param settings DataTables settings object - * @param rowIdx Row index to invalidate - * @param src Source to invalidate from: undefined, 'auto', 'dom' or 'data' - * @param colIdx Column index to invalidate. If undefined the whole row will be - * invalidated + * @param settings dataTables settings object + * @param colIdx column sorting index + * @param addIndex Counter + * @param shift Shift click add */ -function invalidateRow(settings, rowIdx, src, colIdx) { - var row = settings.data[rowIdx]; - var i, iLen; - if (!row) { - return; +function sortAdd(settings, colIdx, addIndex, shift) { + var col = settings.columns[colIdx]; + var sorting = settings.order; + var asSorting = col.orderSequence; + var nextSortIdx; + var next = function (a, overflow) { + var idx = a._idx; + if (idx === undefined) { + idx = asSorting.indexOf(a[1]); + } + return idx + 1 < asSorting.length ? idx + 1 : overflow ? null : 0; + }; + if (!col.orderable) { + return false; } - // Remove the cached data for the row - row.orderCache = null; - row.searchCellCache = null; - row.displayData = null; - // Are we reading last data from DOM or the data object? - if (src === 'dom' || ((!src || src === 'auto') && row.src === 'dom')) { - // Read the data from the DOM - row.data = getRowElementsFromModel(settings, row, colIdx).data; + // Convert to 2D array if needed + if (typeof sorting[0] === 'number') { + sorting = settings.order = [sorting]; } - else { - // Reading from data object, update the DOM - var cells = row.cells; - var display = getRowDisplay(settings, rowIdx); - if (cells.length) { - if (colIdx !== undefined) { - writeCell(cells[colIdx], display[colIdx]); + // If appending the sort then we are multi-column sorting + if ((shift || addIndex) && settings.features.orderMulti) { + // Are we already doing some kind of sort on this column? + var sortIdx = pluck(sorting, '0').indexOf(colIdx); + if (sortIdx !== -1) { + // Yes, modify the sort + nextSortIdx = next(sorting[sortIdx], true); + if (nextSortIdx === null && sorting.length === 1) { + nextSortIdx = 0; // can't remove sorting completely + } + if (nextSortIdx === null || asSorting[nextSortIdx] === '') { + sorting.splice(sortIdx, 1); } else { - for (i = 0, iLen = cells.length; i < iLen; i++) { - writeCell(cells[i], display[i]); - } + sorting[sortIdx][1] = asSorting[nextSortIdx]; + sorting[sortIdx]._idx = nextSortIdx; } } + else if (shift) { + // No sort on this column yet, being added by shift click + // add it as itself + sorting.push([colIdx, asSorting[0], 0]); + sorting[sorting.length - 1]._idx = 0; + } + else { + // No sort on this column yet, being added from a colspan + // so add with same direction as first column + sorting.push([colIdx, sorting[0][1], 0]); + sorting[sorting.length - 1]._idx = 0; + } } - invalidColumn(settings, colIdx); - // Update DataTables special `DT_*` attributes for the row - rowAttributes(settings, row); - callbackFire(settings, null, 'rowInvalidate', [settings, rowIdx, colIdx], false); -} -/** - * Column specific invalidation - * - * @param settings DataTables settings object - * @param colIdx Column index to invalidate, or all columns if not given - */ -function invalidColumn(settings, colIdx) { - // Column specific invalidation - var cols = settings.columns; - if (colIdx !== undefined) { - // Type - the data might have changed - cols[colIdx].type = null; - // Max length string. Its a fairly cheep recalculation, so not worth - // something more complicated - cols[colIdx].wideStrings = null; - } - else { - for (let i = 0, iLen = cols.length; i < iLen; i++) { - cols[i].type = null; - cols[i].wideStrings = null; + else if (sorting.length && sorting[0][0] == colIdx) { + // Single column - already sorting on this column, modify the sort + nextSortIdx = next(sorting[0]); + if (nextSortIdx) { + sorting.length = 1; + sorting[0][1] = asSorting[nextSortIdx]; + sorting[0]._idx = nextSortIdx; + } + else { + sorting.length = 1; + sorting[0][1] = asSorting[0]; + sorting[0]._idx = 0; } } - settings.containerWidth = -1; -} -/** - * Get the cells and data for a given row - from a element - * - * @param settings DataTables settings object - * @param row TR element from which to read data or existing row object from - * which to re-read the data from the cells - */ -function getRowElementsFromNode(settings, row) { - let data = settings.rowReadObject ? {} : []; - let cells = Dom.s(row).children('th, td'); - let id = row.getAttribute('id'); - cells.each((el, idx) => { - readCellData(settings, el, data, idx); - }); - if (id) { - util.set(settings.rowId)(data, id); + else { + // Single column - sort only on this column + sorting.length = 0; + sorting.push([colIdx, asSorting[0]]); + sorting[0]._idx = 0; } - return { - data: data, - cells: cells.get() - }; } /** - * Get the cells and data for a given row - from an existing row model + * Set the sorting classes on table's body, Note: it is safe to call this function + * when bSort and bSortClasses are false * * @param settings DataTables settings object - * @param row Existing row object from which to re-read the data from the cells - * @param colIdx Optional column index */ -function getRowElementsFromModel(settings, row, colIdx) { - let tds = row.cells; - for (let i = 0; i < tds.length; i++) { - if (colIdx === undefined || colIdx === i) { - readCellData(settings, tds[i], row.data, i); +function sortingClasses(settings) { + var oldSort = settings.lastOrder; + var sortClass = settings.classes.order.position; + var sortFlat = sortFlatten(settings); + var features = settings.features; + var i, iLen, colIdx; + if (features.ordering && features.orderClasses) { + // Remove old sorting classes + for (i = 0, iLen = oldSort.length; i < iLen; i++) { + colIdx = oldSort[i].src; + // Remove column sorting + Dom.s(pluck(settings.data, 'cells', colIdx)).classRemove(sortClass + (i < 2 ? i + 1 : 3)); } - } - // Read the ID from the DOM if present - if (row.tr) { - let id = row.tr.getAttribute('id'); - if (id) { - util.set(settings.rowId)(row.data, id); + // Add new column sorting + for (i = 0, iLen = sortFlat.length; i < iLen; i++) { + colIdx = sortFlat[i].src; + Dom.s(pluck(settings.data, 'cells', colIdx)).classAdd(sortClass + (i < 2 ? i + 1 : 3)); } } - return { - data: row.data, - cells: tds - }; + settings.lastOrder = sortFlat; } /** - * Read data from a cell into the data source object + * Get the data to sort a column, be it from cache, fresh (populating the + * cache), or from a sort formatter * * @param settings DataTables settings object - * @param cell The HTML cell element to read from - * @param data Data object / array to store data into - * @param colIdx The column index for the cell + * @param colIdx Column index */ -function readCellData(settings, cell, data, colIdx) { - let column = settings.columns[colIdx]; - let contents = cell.innerHTML.trim(); - if (column.attrSrc) { - // If we are working with attributes from the cell as values - let dataPoint = column.data; - let setter = util.set(dataPoint._); - let attr = function (str, cell) { - if (typeof str === 'string') { - let idx = str.indexOf('@'); - if (idx !== -1) { - let att = str.substring(idx + 1); - let setter = util.set(str); - setter(data, cell.getAttribute(att)); - } - } - }; - setter(data, contents); - attr(dataPoint.sort, cell); - attr(dataPoint.type, cell); - attr(dataPoint.filter, cell); +function sortData(settings, colIdx) { + // Custom sorting function - provided by the sort data type + var column = settings.columns[colIdx]; + var customSort = ext.order[column.orderDataType]; + var customData; + if (customSort) { + customData = customSort.call(settings.instance, settings, colIdx, columnIndexToVisible(settings, colIdx)); } - else { - if (!column.setter) { - // Cache the setter function - column.setter = util.set(column.data); + // Use / populate cache + var row, cellData; + var formatter = ext.type.order[column.type + '-pre']; + var data = settings.data; + for (var rowIdx = 0; rowIdx < data.length; rowIdx++) { + // Sparse array + if (!data[rowIdx]) { + continue; + } + row = data[rowIdx]; + if (row && !row.orderCache) { + row.orderCache = []; + } + if (row && (!row.orderCache[colIdx] || customSort)) { + cellData = customSort + ? customData[rowIdx] // If there was a custom sort function, use data from there + : getCellData(settings, rowIdx, colIdx, 'sort'); + row.orderCache[colIdx] = formatter + ? formatter(cellData, settings) + : cellData; } - column.setter(data, contents); } } +const defaults$3 = { + boundary: false, + caseInsensitive: true, + columns: null, + exact: false, + regex: false, + return: false, + search: '', + smart: true +}; /** - * Recalculate the column widths, if needed (by a column having been - * invalidated) + * Create a new search options object * - * @param settings DataTables settings object + * @param parts Values to assign, otherwise the defaults will be used + * @returns New object */ -function columnWidths(settings) { - if (settings.columns.map(c => c.wideStrings).includes(null)) { - calculateColumnWidths(settings); - } +function create$1(parts = {}) { + return util.object.assignDeep({}, defaults$3, parts); } + /** - * Calculate the width of columns for the table + * Alter the display settings to change the page * * @param settings DataTables settings object + * @param action Paging action to take: "first", "previous", "next" or "last" or + * page number to jump to (integer) + * @param redraw Automatically draw the update or not + * @returns true page has changed, false - no change */ -function calculateColumnWidths(settings) { - // Not interested in doing column width calculation if auto-width is disabled - if (!settings.features.autoWidth) { - return; - } - var table = settings.table, columns = settings.columns, scroll = settings.scroll, scrollY = scroll.y, scrollX = scroll.x, visibleColumns = getColumns(settings, 'visible'), tableWidthAttr = table.getAttribute('width'), // from DOM element - tableContainer = table.parentElement, i, j, column, columnIdx; - var styleWidth = table.style.width; - var containerWidth = wrapperWidth(settings); - // Don't re-run for the same width as the last time - if (containerWidth === settings.containerWidth) { - return false; - } - settings.containerWidth = containerWidth; - // If there is no width applied as a CSS style or as an attribute, we assume that - // the width is intended to be 100%, which is usually is in CSS, but it is very - // difficult to correctly parse the rules to get the final result. - if (!styleWidth && !tableWidthAttr) { - table.style.width = '100%'; - styleWidth = '100%'; - } - if (styleWidth && styleWidth.indexOf('%') !== -1) { - tableWidthAttr = styleWidth; - } - // Let plug-ins know that we are doing a recalc, in case they have changed any of the - // visible columns their own way (e.g. Responsive uses display:none). - callbackFire(settings, null, 'column-calc', [{ visible: visibleColumns }], false); - // Construct a worst case table with the widest, assign any user defined - // widths, then insert it into the DOM and allow the browser to do all - // the hard work of calculating table widths - var tmpTable = Dom - .s(table.cloneNode()) - .css('visibility', 'hidden') - .css('margin', '0') - .attrRemove('id'); - // Clean up the table body - tmpTable.append(Dom.c('tbody')); - // Clone the table header and footer - we can't use the header / footer - // from the cloned table, since if scrolling is active, the table's - // real header and footer are contained in different table tags - tmpTable - .append(settings.thead.cloneNode(true)) - .append(settings.tfoot.cloneNode(true)); - // Remove any assigned widths from the footer (from scrolling) - tmpTable.find('tfoot th, tfoot td').css('width', ''); - // Apply custom sizing to the cloned header - tmpTable.find('thead th, thead td').each(cell => { - // Get the `width` from the header layout - var width = columnsSumWidth(settings, cell, true); - if (width) { - cell.style.width = width; - // For scrollX we need to force the column width otherwise the - // browser will collapse it. If this width is smaller than the - // width the column requires, then it will have no effect - if (scrollX) { - cell.style.minWidth = width; - Dom.s(cell).append(Dom.c('div').css({ - width: width, - margin: '0', - padding: '0', - border: '0', - height: '1px' - })); - } - } - else { - cell.style.width = ''; - } - }); - // Get the widest strings for each of the visible columns and add them to - // our table to create a "worst case" - var longestData = []; - for (i = 0; i < visibleColumns.length; i++) { - longestData.push(getWideStrings(settings, visibleColumns[i])); - } - if (longestData.length) { - for (i = 0; i < longestData[0].length; i++) { - var tr = Dom.c('tr').appendTo(tmpTable.find('tbody')); - for (j = 0; j < visibleColumns.length; j++) { - columnIdx = visibleColumns[j]; - column = columns[columnIdx]; - var longest = longestData[j][i] || ''; - var autoClass = ext.type.className[column.type]; - var padding = column.contentPadding || (scrollX ? '-' : ''); - var text = longest + padding; - var cell = Dom - .c('td') - .classAdd(autoClass) - .classAdd(column.className) - .appendTo(tr); - if (longest.indexOf('<') === -1 && - longest.indexOf('&') === -1) { - cell.text(text); - } - else { - cell.html(text); - } - } - } - } - // Tidy the temporary table - remove name attributes so there aren't - // duplicated in the dom (radio elements for example) - tmpTable.find('[name]').attrRemove('name'); - // Table has been built, attach to the document so we can work with it. - // A holding element is used, positioned at the top of the container - // with minimal height, so it has no effect on if the container scrolls - // or not. Otherwise it might trigger scrolling when it actually isn't - // needed - var holder = Dom - .c('div') - .css(scrollX || scrollY - ? { - position: 'absolute', - top: '0', - left: '0', - height: '1px', - right: '0', - overflow: 'hidden' - } - : {}) - .append(tmpTable) - .appendTo(tableContainer); - // When scrolling (X or Y) we want to set the width of the table as - // appropriate. However, when not scrolling leave the table width as it - // is. This results in slightly different, but I think correct behaviour - if (scrollX) { - tmpTable.css('width', 'auto').attrRemove('width'); - // If there is no width attribute or style, then allow the table to - // collapse - if (tmpTable.width() < tableContainer.clientWidth && tableWidthAttr) { - tmpTable.width(tableContainer.clientWidth); - } - } - else if (scrollY) { - tmpTable.width(tableContainer.clientWidth); - } - else if (tableWidthAttr) { - tmpTable.width(tableWidthAttr); - } - // Get the width of each column in the constructed table - var total = 0; - var bodyCells = tmpTable.find('tbody tr').eq(0).children(); - for (i = 0; i < visibleColumns.length; i++) { - // Use getBounding for sub-pixel accuracy, which we then want to round - // up! - var bounding = bodyCells.get(i).getBoundingClientRect().width; - // Total is tracked to remove any sub-pixel errors as the outerWidth - // of the table might not equal the total given here - total += bounding; - // Width for each column to use - columns[visibleColumns[i]].width = stringToCss(bounding); - } - table.style.width = stringToCss(total); - // Finished with the table - ditch it - holder.remove(); - // If there is a width attr, we want to attach an event listener which - // allows the table sizing to automatically adjust when the window is - // resized. Use the width attr rather than CSS, since we can't know if the - // CSS is a relative value or absolute - DOM read is always px. - if (tableWidthAttr) { - table.style.width = stringToCss(tableWidthAttr); - } - if ((tableWidthAttr || scrollX) && !settings.reszEvt) { - var resize = util.throttle(function () { - var newWidth = wrapperWidth(settings); - // Don't do it if destroying or the container width is 0 - if (!settings.destroying && newWidth !== 0) { - adjustColumnSizing(settings); - } - }); - // For browsers that support it (~2020 onwards for wide support) we can watch for the - // container changing width. - if (window.ResizeObserver) { - // This is a tricky beast - if the element is visible when `.observe()` is called, - // then the callback is immediately run. Which we don't want. If the element isn't - // visible, then it isn't run, but we want it to run when it is then made visible. - // This flag allows the above to be satisfied. - var first = Dom.s(settings.tableWrapper).isVisible(); - // Use an empty div to attach the observer so it isn't impacted by height changes - var resizer = Dom - .c('div') - .css({ - width: '100%', - height: '0' - }) - .classAdd('dt-autosize') - .appendTo(settings.tableWrapper); - settings.resizeObserver = new ResizeObserver(function (e) { - if (first) { - first = false; - } - else { - resize(); - } - }); - settings.resizeObserver.observe(resizer.get(0)); +function pageChange(settings, action, redraw) { + var start = settings.displayStart, len = settings.pageLength, records = recordsDisplay(settings); + if (records === 0 || len === -1) { + start = 0; + } + else if (typeof action === 'number') { + start = action * len; + if (start > records) { + start = 0; } - else { - // For old browsers, the best we can do is listen for a window - // resize - window.addEventListener('resize', resize); - settings.windowResizeCb = resize; // For removal in `destroy` + } + else if (action == 'first') { + start = 0; + } + else if (action == 'previous') { + start = len >= 0 ? start - len : 0; + if (start < 0) { + start = 0; } - settings.reszEvt = true; } + else if (action == 'next') { + if (start + len < records) { + start += len; + } + } + else if (action == 'last') { + start = Math.floor((records - 1) / len) * len; + } + else if (action === 'ellipsis') { + return; + } + else { + log(settings, 0, 'Unknown paging action: ' + action, 5); + } + var changed = settings.displayStart !== start; + settings.displayStart = start; + callbackFire(settings, null, changed ? 'page' : 'page-nc', [settings]); + if (changed && redraw) { + draw(settings); + } + return changed; } + /** - * Get the width of the DataTables wrapper element + * State information for a table * * @param settings DataTables settings object - * @returns Width */ -function wrapperWidth(settings) { - let wrapper = Dom.s(settings.tableWrapper); - return wrapper.isVisible() ? wrapper.width() : 0; +function saveState(settings) { + if (settings.loadingState) { + return; + } + // Sort state saving uses [[idx, order]] structure. + var sorting = []; + sortResolve(settings, sorting, settings.order); + /* Store the interesting variables */ + var columns = settings.columns; + var state = { + columns: settings.columns.map(function (col, i) { + return { + name: col.name, + visible: col.visible, + search: Object.assign({}, settings.searches[i]) + }; + }), + length: settings.pageLength, + order: sorting.map(function (sort) { + // If a column name is available, use it + return columns[sort[0]] && columns[sort[0]].name + ? [columns[sort[0]].name, sort[1]] + : sort.slice(); + }), + search: Object.assign({}, settings.searches['*']), + searchGroups: Object.keys(settings.searches) + .filter(c => c.includes(',')) // Limit to only multi-column subsets + .map(c => Object.assign({}, settings.searches[c])), + start: settings.displayStart, + time: +new Date() + }; + settings.stateSaved = state; + callbackFire(settings, 'stateSaveParams', 'stateSaveParams', [ + settings, + state + ]); + if (settings.features.stateSave && !settings.destroying) { + settings.stateSaveCallback.call(settings.instance, settings, state); + } } /** - * Get the widest strings for each column. - * - * It is very difficult to determine what the widest string actually is due to variable character - * width and kerning. Doing an exact calculation with the DOM or even Canvas would kill performance - * and this is a critical point, so we use two techniques to determine a collection of the longest - * strings from the column, which will likely contain the widest strings: - * - * 1) Get the top three longest strings from the column - * 2) Get the top three widest words (i.e. an unbreakable phrase) + * Attempt to load a saved table state * - * @param settings DataTables settings object - * @param colIdx column of interest - * @returns Array of the longest strings + * @param settings dataTables settings object + * @param callback Callback to execute when the state has been loaded */ -function getWideStrings(settings, colIdx) { - var column = settings.columns[colIdx]; - // Do we need to recalculate (i.e. was invalidated), or just use the cached data? - if (!column.wideStrings) { - var allStrings = []; - var collection = []; - // Create an array with the string information for the column - for (var i = 0, iLen = settings.displayMaster.length; i < iLen; i++) { - var rowIdx = settings.displayMaster[i]; - var data = getRowDisplay(settings, rowIdx)[colIdx]; - var cellString = data && typeof data === 'object' && data.nodeType - ? data.innerHTML - : data + ''; - // Remove id / name attributes from elements so they - // don't interfere with existing elements - cellString = cellString - .replace(/id=".*?"/g, '') - .replace(/name=".*?"/g, ''); - // Don't want script, dialog or template tags in the width - // calculations as they are hidden content - cellString = cellString - .replace(/]*)?>/gi, ' ') - .replace(/]*)?>/gi, ' ') - .replace(/]*)?>/gi, ' '); - var noHtml = util.string - .stripHtml(cellString, ' ') - .replace(/ /g, ' '); - collection.push({ - str: cellString, - len: noHtml.length - }); - allStrings.push(noHtml); +function loadState(settings, callback) { + if (!settings.features.stateSave) { + callback(); + return; + } + var loaded = function (state, ignoreTime = false) { + implementState(settings, state, ignoreTime, callback); + }; + var state = settings.stateLoadCallback.call(settings.instance, settings, loaded); + if (state !== undefined) { + implementState(settings, state, false, callback); + } + // otherwise, wait for the loaded callback to be executed + return true; +} +function implementState(settings, s, ignoreTime, callback) { + var i, iLen; + var columns = settings.columns; + var currentNames = pluck(settings.columns, 'name'); + settings.loadingState = true; + // When StateRestore was introduced the state could now be implemented at + // any time Not just initialisation. To do this an api instance is required + // in some places + var api = settings.initDone ? new Api(settings) : null; + if (!ignoreTime) { + if (!s || !s.time) { + settings.loadingState = false; + callback(); + return; } - // Order and then cut down to the size we need - collection - .sort(function (a, b) { - return b.len - a.len; - }) - .splice(3); - column.wideStrings = collection.map(function (item) { - return item.str; - }); - // Longest unbroken string - const parts = allStrings.join(' ').split(' '); - parts.sort(function (a, b) { - return b.length - a.length; - }); - if (parts.length) { - column.wideStrings.push(parts[0]); + // Reject old data + var duration = settings.stateDuration; + if (duration > 0 && s.time < +new Date() - duration * 1000) { + settings.loadingState = false; + callback(); + return; } - if (parts.length > 1) { - column.wideStrings.push(parts[1]); + } + // Allow custom and plug-in manipulation functions to alter the saved data + // set and cancelling of loading by returning false + var abStateLoad = callbackFire(settings, 'stateLoadParams', 'stateLoadParams', [settings, s]); + if (abStateLoad.indexOf(false) !== -1) { + settings.loadingState = false; + callback(); + return; + } + // Store the saved state so it might be accessed at any time + settings.stateLoaded = assignDeep({}, s); + // This is needed for ColReorder, which has to happen first to allow all + // the stored indexes to be usable. It is not publicly documented. + callbackFire(settings, null, 'stateLoadInit', [settings, s], true); + // Page Length + if (s.length !== undefined) { + // If already initialised just set the value directly so that the select + // element is also updated + if (api) { + api.page.len(s.length); } - if (parts.length > 2) { - column.wideStrings.push(parts[3]); + else { + settings.pageLength = s.length; } } - return column.wideStrings; -} -/** - * Append a CSS unit (only if required) to a string - * - * @param s Value to css-ify - * @returns Value with css unit - */ -function stringToCss(s) { - if (s === null) { - return '0px'; + // Restore key features + if (s.start !== undefined) { + if (api === null) { + settings.displayStart = s.start; + settings.displayStartInit = s.start; + } + else { + pageChange(settings, s.start / settings.pageLength); + } + } + // Order + if (s.order !== undefined) { + settings.order = []; + for (let i = 0; i < s.order.length; i++) { + let col = s.order[i]; + let set = [col[0], col[1]]; + // A column name was stored and should be used for restore + if (typeof col[0] === 'string') { + // Find the name from the current list of column names + let idx = currentNames.indexOf(col[0]); + if (idx < 0) { + // If the column was not found ignore it and continue + continue; + } + set[0] = idx; + } + else if (set[0] >= columns.length) { + // If the column index is out of bounds ignore it and continue + continue; + } + settings.order.push(set); + } } - if (typeof s == 'number') { - return s < 0 ? '0px' : s + 'px'; + // Search + if (s.search !== undefined) { + Object.assign(settings.searches['*'], s.search); } - // Check it has a unit character already - return s.match(/\d$/) ? s + 'px' : s; -} -/** - * Re-insert the `col` elements for current visibility - * - * @param settings DT settings - */ -function colGroup(settings) { - var cols = settings.columns; - settings.colgroup.empty(); - for (var i = 0; i < cols.length; i++) { - if (cols[i].visible) { - settings.colgroup.append(cols[i].colEl); + if (s.searchGroups) { + s.searchGroups.forEach(group => { + if (group.columns) { + let index = group.columns.join(','); + settings.searches[index] = create$1(group); + } + }); + } + // Columns + if (s.columns) { + var set = s.columns; + var incoming = pluck(s.columns, 'name'); + // Check if it is a 2.2 style state object with a `name` property for + // the columns, and if the name was defined. If so, then create a new + // array that will map the state object given, to the current columns + // (don't bother if they are already matching tho). + if (incoming.join('').length && + incoming.join('') !== currentNames.join('')) { + set = []; + // For each column, try to find the name in the incoming array + for (i = 0; i < currentNames.length; i++) { + if (currentNames[i] != '') { + var idx = incoming.indexOf(currentNames[i]); + if (idx >= 0) { + set.push(s.columns[idx]); + } + else { + // No matching column name in the state's columns, so + // this might be a new column and thus can't have a + // state already. + set.push({}); + } + } + else { + // If no name, but other columns did have a name, then there + // is no knowing where this one came from originally so it + // can't be restored. + set.push({}); + } + } + } + // If the number of columns to restore is different from current, then + // all bets are off. + if (set.length === columns.length) { + for (i = 0, iLen = set.length; i < iLen; i++) { + var col = set[i]; + // Visibility + if (col.visible !== undefined) { + // If the api is defined, the table has been initialised so + // we need to use it rather than internal settings + if (api) { + // Don't redraw the columns on every iteration of this + // loop, we will do this at the end instead + api.column(i).visible(col.visible, false); + } + else { + columns[i].visible = col.visible; + } + } + // Search + if (col.search !== undefined) { + Object.assign(settings.searches[i], col.search); + // If out of order due to a change in order from named + // columns we need to make sure the index is correct + settings.searches[i].columns = [i]; + } + } + // If the api is defined then we need to adjust the columns once the + // visibility has been changed + if (api) { + api.one('draw', function () { + api.columns.adjust(); + }); + } } } + settings.loadingState = false; + callbackFire(settings, 'stateLoaded', 'stateLoaded', [settings, s]); + callback(); } /** - * Scrolling setup + * Draw the table for the first time, adding all required features * * @param settings DataTables settings object - * @returns Node to add to the DOM */ -function featureTable(settings) { - let table = Dom.s(settings.table); - let scroll = settings.scroll; - let scrollX = scroll.x; - let scrollY = scroll.y; - // No scrolling or x-scrolling only - if (scrollY === '' && scrollX === '') { - return table.get(0); - } - let classes = settings.classes.scrolling; - let caption = settings.captionNode; - let captionSide = caption - ? caption._captionSide - : null; - let tableCloneHeader = table.clone(false); - let tableCloneFooter = table.clone(false); - let footer = table.children('tfoot'); - let size = function (s) { - return !s ? '100%' : stringToCss(s); - }; - /* - * The HTML structure that we want to generate in this function is: - * div - scroller - * div - scroll head - * div - scroll head inner - * table - scroll head table - * thead - thead - * div - scroll body - * table - table (master table) - * thead - thead clone for sizing - * tbody - tbody - * div - scroll foot - * div - scroll foot inner - * table - scroll foot table - * tfoot - tfoot - */ - let scroller = Dom.c('div') - .classAdd(classes.container) - .attr('role', 'table') - .append(Dom.c('div') - .classAdd(classes.header.self) - .css({ - overflow: 'hidden', - position: 'relative', - border: '0', - width: scrollX ? size(scrollX) : '100%' - }) - .attr('role', 'none') - .append(Dom.c('div') - .classAdd(classes.header.inner) - .css({ - 'box-sizing': 'content-box', - width: scroll.xInner || '100%' - }) - .attr('role', 'none') - .append(tableCloneHeader - .attrRemove('id') - .css('margin-left', '0') - .append(captionSide === 'top' ? caption : null) - .append(table.children('thead'))))) - .append(Dom.c('div') - .classAdd(classes.body) - .css({ - position: 'relative', - overflow: 'auto', - width: size(scrollX) - }) - .attr('role', 'none') - .append(table)); - if (footer.count()) { - scroller.append(Dom.c('div') - .classAdd(classes.footer.self) - .css({ - overflow: 'hidden', - border: '0', - width: scrollX ? size(scrollX) : '100%' - }) - .attr('role', 'none') - .append(Dom.c('div') - .classAdd(classes.footer.inner) - .attr('role', 'none') - .append(tableCloneFooter - .attrRemove('id') - .css('margin-left', '0') - .append(captionSide === 'bottom' ? caption : null) - .append(table.children('tfoot'))))); +function initialise(settings) { + var i; + var init = settings.init; + var deferLoading = settings.deferLoading; + var dataSrc = dataSource(settings); + // Ensure that the table data is fully initialised + if (!settings.initialised) { + setTimeout(function () { + initialise(settings); + }, 200); + return; } - let children = scroller.children(); - let scrollHead = children.eq(0); - let scrollBody = children.eq(1); - let scrollFoot = children.eq(2); - // When the body is scrolled, then we also want to scroll the header and - // footer. Note that each element has its own scroll listener, and that in - // turn sets the scroll for the other elements. However this doesn't lead to - // an infinite loop as `scroll` is only triggered if the value changes. - scrollBody.on('scroll.DT', () => { - let scrollLeft = scrollBody.scrollLeft(); - scrollHead.scrollLeft(scrollLeft); - scrollFoot.scrollLeft(scrollLeft); - }); - scrollHead.on('scroll.DT', () => { - let scrollLeft = scrollHead.scrollLeft(); - scrollBody.scrollLeft(scrollLeft); - scrollFoot.scrollLeft(scrollLeft); - }); - scrollFoot.on('scroll.DT', () => { - let scrollLeft = scrollFoot.scrollLeft(); - scrollHead.scrollLeft(scrollLeft); - scrollBody.scrollLeft(scrollLeft); + // Build the header / footer for the table + buildHead(settings, 'header'); + buildHead(settings, 'footer'); + // Load the table's state (if needed) and then render around it and draw + loadState(settings, function () { + // Then draw the header / footer + drawHead(settings, settings.header); + drawHead(settings, settings.footer); + // Cache the paging start point, as the first redraw will reset it + var iAjaxStart = settings.displayStartInit; + // Local data load + // Check if there is data passing into the constructor + if (init && init.data) { + for (i = 0; i < init.data.length; i++) { + addData(settings, init.data[i]); + } + } + else if (deferLoading || dataSrc == 'dom') { + // Grab the data from the page + addTr(settings, Dom.s(settings.tbody).children('tr')); + } + // Filter not yet applied - copy the display master + settings.display = settings.displayMaster.slice(); + // Enable features + createLayout(settings); + sortInit(settings); + colGroup(settings); + /* Okay to show that something is going on now */ + processingDisplay(settings, true); + callbackFire(settings, null, 'preInit', [settings], true); + // If there is default sorting required - let's do it. The sort function + // will do the drawing for us. Otherwise we draw the table regardless of + // the Ajax source - this allows the table to look initialised for Ajax + // sourcing data (show 'loading' message possibly) + reDraw(settings); + // Server-side processing init complete is done by _fnAjaxUpdateDraw + if (dataSrc != 'ssp' || deferLoading) { + // if there is an ajax source load the data + if (dataSrc == 'ajax') { + buildAjax(settings, {}, function (json) { + var aData = ajaxDataSrc(settings, json, false); + // Got the data - add it to the table + for (i = 0; i < aData.length; i++) { + addData(settings, aData[i]); + } + // Reset the init display for cookie saving. We've already + // done a filter, and therefore cleared it before. So we + // need to make it appear 'fresh' + settings.displayStartInit = iAjaxStart; + reDraw(settings); + processingDisplay(settings, false); + initComplete(settings); + }); + } + else { + initComplete(settings); + processingDisplay(settings, false); + } + } }); - scrollBody.css('max-height', size(scrollY)); - if (!scroll.collapse) { - scrollBody.css('height', size(scrollY)); - } - settings.scrollHead = scrollHead; - settings.scrollBody = scrollBody; - settings.scrollFoot = scrollFoot; - // On redraw - align columns - settings.callbacks.draw.push(scrollDraw); - // Aria roles - because we break the table up into parts we need to be very - // explicit with the roles to create the accessability tree for the table, - // otherwise browser's attempt to "fix" the tree by filling in what it - // thinks are gaps. The static elements that we can assign roles to are done - // here. Dynamic ones are done in the draw function below. - table.attr('role', 'none'); - table.find('tbody').attr('role', 'rowgroup'); - tableCloneHeader.attr('role', 'none'); - tableCloneFooter.attr('role', 'none'); - settings.colgroup.find('colgroup').attr('role', 'none'); - // Move the info feature's aria desc by to the new "table" - let describedBy = table.attr('aria-describedby'); - if (describedBy) { - scroller.attr('aria-describedby', describedBy); - table.attrRemove('aria-describedby'); - } - return scroller.get(0); } /** - * Update the header, footer and body tables for resizing - i.e. column - * alignment. - * - * Welcome to the most horrible function DataTables. The process that this - * function follows is basically: - * 1. Re-create the table inside the scrolling div - * 2. Correct colgroup > col values if needed - * 3. Copy colgroup > col over to header and footer - * 4. Clean up + * Draw the table for the first time, adding all required features * * @param settings DataTables settings object */ -function scrollDraw(settings) { - // Given that this is such a monster function, a lot of variables are use - // to try and keep the minimised size as small as possible - let scroll = settings.scroll, barWidth = scroll.barWidth, divHeader = settings.scrollHead, divHeaderInner = divHeader.children('div'), divHeaderTable = divHeaderInner.children('table'), divBodyEl = settings.scrollBody, divBody = divBodyEl, divFooter = settings.scrollFoot, divFooterInner = divFooter.children('div'), divFooterTable = divFooterInner.children('table'), header = Dom.s(settings.thead), table = Dom.s(settings.table), footer = Dom.s(settings.tfoot), browser = settings.browser, headerCopy, footerCopy; - // If the scrollbar visibility has changed from the last draw, we need to - // adjust the column sizes as the table width will have changed to account - // for the scrollbar - let scrollBarVis = divBodyEl.get(0).scrollHeight > divBodyEl.get(0).clientHeight; - if (settings.scrollBarVis !== scrollBarVis && - settings.scrollBarVis !== undefined) { - settings.scrollBarVis = scrollBarVis; - adjustColumnSizing(settings); - return; // adjust column sizing will call this function again - } - else { - settings.scrollBarVis = scrollBarVis; +function initComplete(settings) { + if (settings.initDone) { + return; } - header.find('thead').attr('role', 'rowgroup'); - footer.find('tfoot').attr('role', 'rowgroup'); - // 1. Re-create the table inside the scrolling div - // Remove the old minimised thead and tfoot elements in the inner table - table.children('thead, tfoot').remove(); - // Clone the current header and footer elements and then place it into the - // inner table - headerCopy = header.clone(true).prependTo(table); - headerCopy.find('th, td').attrRemove('tabindex'); - headerCopy.find('[id]').attrRemove('id'); - if (footer.count()) { - footerCopy = footer.clone(true).prependTo(table); - footerCopy.find('[id]').attrRemove('id'); + var args = [settings, settings.json]; + settings.initDone = true; + // If the footer element is empty after initialisation, then remove it + let tfoot = Dom.s(settings.tfoot); + if (tfoot.children().count() === 0) { + tfoot.remove(); } - // 2. Correct colgroup > col values if needed - // It is possible that the cell sizes are smaller than the content, so we need to - // correct colgroup>col for such cases. This can happen if the auto width detection - // uses a cell which has a longer string, but isn't the widest! For example - // "Chief Executive Officer (CEO)" is the longest string in the demo, but - // "Systems Administrator" is actually the widest string since it doesn't collapse. - // Note the use of translating into a column index to get the `col` element. This - // is because of Responsive which might remove `col` elements, knocking the alignment - // of the indexes out. - if (settings.display.length) { - // Get the column sizes from the first row in the table. This should really be a - // [].find, but it wasn't supported in Chrome until Sept 2015, and DT has 10 year - // browser support - let firstTr = null; - let start = dataSource(settings) !== 'ssp' ? settings.displayStart : 0; - for (let i = start; i < start + settings.display.length; i++) { - let idx = settings.display[i]; - let row = settings.data[idx]; - if (row) { - let tr = row.tr; - if (tr) { - firstTr = tr; - break; - } + // Table is fully set up and we have data, so calculate the + // column widths + adjustColumnSizing(settings); + callbackFire(settings, null, 'plugin-init', args, true); + callbackFire(settings, 'init', 'init', args, true); +} + +/** + * Create an Ajax call based on the table's settings, taking into account that + * parameters can have multiple forms, and backwards compatibility. + * + * @param settings DataTables settings object + * @param data Data to send to the server, required by DataTables - may be + * augmented by developer callbacks + * @param fn Callback function to run when data is obtained + */ +function buildAjax(settings, data, fn) { + var ajaxData; + var ajaxConfig = settings.ajax; + var instance = settings.instance; + var callback = function (json) { + var status = settings.jqXHR ? settings.jqXHR.status : null; + if (json === null || (typeof status === 'number' && status == 204)) { + json = {}; + ajaxDataSrc(settings, json, []); + } + var error = json.error || json.sError; + if (error) { + log(settings, 0, error); + } + // Microsoft often wrap JSON as a string in another JSON object Let's + // handle that automatically + if (json.d && typeof json.d === 'string') { + try { + json = JSON.parse(json.d); + } + catch (e) { + // noop } } - if (firstTr) { - let colSizes = Dom.s(firstTr) - .children('th, td') - .mapTo(function (cell, idx) { - return { - idx: visibleToColumnIndex(settings, idx), - width: Dom.s(cell).width('outer') - }; - }); - // Check against what the colgroup > col is set to and correct if needed - for (let i = 0; i < colSizes.length; i++) { - let colEl = settings.columns[colSizes[i].idx].colEl; - colEl.css('width', colSizes[i].width + 'px'); - if (scroll.x) { - colEl.css('minWidth', colSizes[i].width + 'px'); + settings.json = json; + invalidColumn(settings); + callbackFire(settings, null, 'xhr', [settings, json, settings.jqXHR], true); + fn(json); + }; + if (util.is.plainObject(ajaxConfig) && ajaxConfig.data) { + ajaxData = ajaxConfig.data; + var newData = typeof ajaxData === 'function' + ? ajaxData(data, settings) // fn can manipulate data or return + : ajaxData; // an object or array to merge + // If the function returned something, use that alone + data = + typeof ajaxData === 'function' && newData + ? newData + : util.object.assignDeep(data, newData); + // Remove the data property as we've resolved it already and don't want + // jQuery to do it again (it is restored at the end of the function) + delete ajaxConfig.data; + } + var baseAjax = { + url: typeof ajaxConfig === 'string' ? ajaxConfig : '', + data: data, + success: callback, + dataType: 'json', + cache: false, + type: settings.serverMethod, + error: function (xhr, error) { + var ret = callbackFire(settings, null, 'xhr', [settings, null, settings.jqXHR], true); + if (ret.indexOf(false) === -1) { + if (error == 'parsererror') { + log(settings, 0, 'Invalid JSON response', 1); + } + else if (xhr.readyState === 4) { + log(settings, 0, 'Ajax error', 7); } } + processingDisplay(settings, false); } + }; + // If `ajax` option is an object, extend and override our default base + if (util.is.plainObject(ajaxConfig)) { + util.object.assign(baseAjax, ajaxConfig); } - // 3. Copy the colgroup over to the header and footer - divHeaderTable.find('colgroup').remove(); - divHeaderTable.append(settings.colgroup.clone(true)); - if (footer) { - divFooterTable.find('colgroup').remove(); - divFooterTable.append(settings.colgroup.clone(true)); - } - // "Hide" the header and footer that we used for the sizing. We need to keep - // the content of the cell so that the width applied to the header and body - // both match, but we want to hide it completely. - headerCopy.find('th, td').each(function (el) { - Dom.c('div') - .classAdd('dt-scroll-sizing') - .append(Array.from(el.childNodes)) - .appendTo(el); - }); - if (footerCopy) { - footerCopy.find('th, td').each(function (el) { - Dom.c('div') - .classAdd('dt-scroll-sizing') - .append(Array.from(el.childNodes)) - .appendTo(el); - }); - } - // 4. Clean up - // Figure out if there are scrollbar present - if so then we need the header and footer to - // provide a bit more space to allow "overflow" scrolling (i.e. past the scrollbar) - let isScrolling = Math.floor(table.height()) > divBodyEl.get(0).clientHeight || - divBody.css('overflow-y') == 'scroll'; - let paddingSide = 'padding' + (browser.scrollbarLeft ? 'Left' : 'Right'); - // Set the width's of the header and footer tables - let outerWidth = table.width('withPadding'); - divHeaderTable.css('width', stringToCss(outerWidth)); - divHeaderInner - .css('width', stringToCss(outerWidth)) - .css(paddingSide, isScrolling ? barWidth + 'px' : '0px'); - if (footer.count()) { - divFooterTable.css('width', stringToCss(outerWidth)); - divFooterInner - .css('width', stringToCss(outerWidth)) - .css(paddingSide, isScrolling ? barWidth + 'px' : '0px'); + // Store the data submitted for the API + settings.ajaxData = data; + // Allow plug-ins and external processes to modify the data + callbackFire(settings, null, 'preXhr', [settings, data, baseAjax], true); + if (typeof ajaxConfig === 'function') { + // Is a function - let the caller define what needs to be done + settings.jqXHR = ajaxConfig.call(instance, data, callback, settings); } - // Correct DOM ordering for colgroup - comes before the thead - table.children('colgroup').prependTo(table); - // Remove tabindex from the hidden row elements - table.find('thead, tfoot').find('[tabindex]').attrRemove('tabindex'); - // Dynamic ARIA roles - see setup for details on why this is needed - table - .find('thead, tfoot') - .attr('role', 'none') - .find('[role]') - .attrRemove('role'); - table.find('tbody tr:not([role])').attr('role', 'row'); - table.find('tbody td:not([role]), tbody th:not([role])').attr('role', 'cell'); - scrollAria(headerCopy); - scrollAria(footerCopy); - // Adjust the position of the header in case we loose the y-scrollbar - divBody.trigger('scroll'); - // If sorting or filtering has occurred, jump the scrolling back to the top - // only if we aren't holding the position - if ((settings.wasOrdered || settings.wasFiltered) && !settings.drawHold) { - divBodyEl.scrollTop(0); + else if (ajaxConfig && + typeof ajaxConfig !== 'string' && + ajaxConfig.url === '') { + // No url, so don't load any data. Just apply an empty data array + // to the object for the callback. + var empty = {}; + ajaxDataSrc(settings, empty, []); + callback(empty); } -} -/** - * Apply ARIA roles for the header / footer of a scrolling table - * @param element - */ -function scrollAria(element) { - if (element) { - element.find('tfoot:not([role])').attr('role', 'rowgroup'); - element.find('tr:not([role])').attr('role', 'row'); - element.find('th:not([role])').attr('role', 'columnheader'); - element.find('td:not([role])').attr('role', 'cell'); + else { + // Object to extend the base settings + settings.jqXHR = util.ajax(baseAjax); + } + // Restore for next time around + if (ajaxData) { + ajaxConfig.data = ajaxData; } } - /** - * Add a column to the list used for the table with default values + * Update the table using an Ajax call * * @param settings DataTables settings object + * @returns Block the table drawing or not */ -function addColumn(settings) { - // Add column to aoColumns array - let columnIdx = settings.columns.length; - let column = util.object.assign({}, new Settings(), defaults$4, { - orderData: defaults$4.orderData - ? defaults$4.orderData - : [columnIdx], - data: defaults$4.data ? defaults$4.data : columnIdx, - idx: columnIdx, - searchFixed: {}, - colEl: Dom - .c('col') - .attr('data-dt-column', columnIdx) +function ajaxUpdate(settings) { + settings.drawCount++; + processingDisplay(settings, true); + buildAjax(settings, ajaxParameters(settings), function (json) { + ajaxUpdateDraw(settings, json); }); - settings.columns.push(column); - // Legacy support for `searchCols` property. If set, and there is a value - // for this column, then it should be applied to the search. The new, column - // specific `search` option is applied in `columnOptions`, but we always - // want the search object for the column to exist. - let searchCols = settings.searchCols; - settings.searches[columnIdx] = create$2(searchCols[columnIdx] - ? hungarianToCamel(searchCols[columnIdx]) - : {}); - settings.searches[columnIdx].columns = [columnIdx]; +} +function functionOrValue(val) { + return typeof val === 'function' ? 'function' : val.toString(); } /** - * Apply options for a column + * Build up the parameters in an object needed for a server-side processing + * request. * * @param settings DataTables settings object - * @param colIdx column index to consider - * @param options Column configuration options + * @returns Block the table drawing or not */ -function columnOptions(settings, colIdx, options) { - var column = settings.columns[colIdx]; - /* User specified column options */ - if (options !== undefined && options !== null) { - // Backwards compatibility - compatCols(options); - if (options.type) { - column.typeManual = options.type; - } - // `class` is a reserved word in JavaScript, so we need to provide - // the ability to use a valid name for the camel case input - if (options.className && !options.className) { - options.className = options.className; - } - var origClass = column.className; - util.object.assign(column, options); - map(column, options, 'width', 'widthOrig'); - // Merge class from previously defined classes with this one, rather - // than just overwriting it in the extend above - if (origClass !== column.className) { - column.className = origClass + ' ' + column.className; +function ajaxParameters(settings) { + var columns = settings.columns, features = settings.features, searches = settings.searches, searchesFixed = settings.searchesFixed, colData = function (idx, prop) { + return typeof columns[idx][prop] === 'function' + ? 'function' + : columns[idx][prop]; + }; + return { + draw: settings.drawCount, + columns: columns.map(function (column, i) { + return { + data: colData(i, 'data'), + name: column.name, + searchable: column.searchable, + orderable: column.orderable, + search: { + value: searches[i] + ? functionOrValue(searches[i].search) + : '', + regex: searches[i] ? searches[i].regex : false, + fixed: searchesFixed[i] + ? Object.keys(searchesFixed[i]).map(name => ({ + name: name, + term: functionOrValue(searchesFixed[i][name].search) + })) + : [] + } + }; + }), + order: sortFlatten(settings).map(function (val) { + return { + column: val.col, + dir: val.dir, + name: colData(val.col, 'name') + }; + }), + start: settings.displayStart, + length: features.paging ? settings.pageLength : -1, + search: { + value: functionOrValue(searches['*'].search), + regex: searches['*'].regex, + fixed: Object.keys(settings.searchesFixed['*']).map(name => ({ + name: name, + term: functionOrValue(settings.searchesFixed['*'][name].search) + })), + groups: Object.keys(settings.searches) + .filter(c => c.includes(',')) // Limit to only multi-column subsets + .map(c => ({ + columns: settings.searches[c].columns || [], + term: functionOrValue(settings.searches[c].search) + })), + groupsFixed: Object.keys(settings.searchesFixed) + .filter(c => c.includes(',')) // Limit to only multi-column subsets + .map(c => { + let searches = settings.searchesFixed[c]; + return Object.keys(searches).map(n => ({ + columns: searches[n].columns || [], + name: n, + term: functionOrValue(searches[n].search) + })); + }) + .flat() } - map(column, options, 'orderData'); - // Search term specifically for this column - if (options.search) { - util.object.assign(settings.searches[colIdx], options.search); + }; +} +/** + * Data the data from the server (nuking the old) and redraw the table + * + * @param settings DataTables settings object + * @param json json data return from the server. + */ +function ajaxUpdateDraw(settings, json) { + var data = ajaxDataSrc(settings, json, false); + var drawUnique = ajaxDataSrcParam(settings, 'draw', json); + var recordsTotal = ajaxDataSrcParam(settings, 'recordsTotal', json); + var recordsFiltered = ajaxDataSrcParam(settings, 'recordsFiltered', json); + var existingTypes = settings.columns.map(c => c.type).join(','); + if (drawUnique !== undefined) { + // Protect against out of sequence returns + if (drawUnique * 1 < settings.drawCount) { + return; } + settings.drawCount = drawUnique * 1; } - /* Cache the data get and set functions for speed */ - var dataSrc = column.data; - var dataFn = util.get(dataSrc); - // The `render` option can be given as an array to access the helper - // rendering methods. The first element is the rendering method to use, the - // rest are the parameters to pass - if (column.render && Array.isArray(column.render)) { - var copy = column.render.slice(); - var name = copy.shift(); - column.render = helpers[name].apply(window, copy); + // No data in returned object, so rather than an array, we show an empty + // table + if (!data) { + data = []; } - column.renderer = column.render ? util.get(column.render) : null; - var attrTest = function (src) { - return typeof src === 'string' && src.indexOf('@') !== -1; - }; - column.attrSrc = - !!dataSrc && - util.is.plainObject(dataSrc) && - (attrTest(dataSrc.sort) || - attrTest(dataSrc.type) || - attrTest(dataSrc.filter)); - column.setter = null; - column.dataGet = function (rowData, type, meta) { - var innerData = dataFn(rowData, type, undefined, meta); - return column.renderer && type - ? column.renderer(innerData, type, rowData, meta) - : innerData; - }; - column.dataSet = function (rowData, val, meta) { - return util.set(dataSrc)(rowData, val, meta); - }; - // Indicate if DataTables should read DOM data as an object or array - // Used in _fnGetRowElements - if (typeof dataSrc !== 'number' && !column._isArrayHost) { - settings.rowReadObject = true; + clearTable(settings); + settings.recordsTotal = parseInt(recordsTotal, 10); + settings.recordsDisplay = parseInt(recordsFiltered, 10); + for (var i = 0, iLen = data.length; i < iLen; i++) { + addData(settings, data[i]); + } + settings.display = settings.displayMaster.slice(); + columnTypes(settings, existingTypes); + draw(settings, true); + initComplete(settings); + processingDisplay(settings, false); +} +/** + * Get the data from the JSON data source to use for drawing a table. + * + * @param settings DataTables settings object + * @param json Data source object / array from the server + * @param write Array or object to write the data to + * @return Array of data to use + */ +function ajaxDataSrc(settings, json, write) { + var dataProp = 'data'; + if (util.is.plainObject(settings.ajax) && + settings.ajax.dataSrc !== undefined) { + // Could in inside a `dataSrc` object, or not! + var dataSrc = settings.ajax.dataSrc; + // string, function and object are valid types + if (typeof dataSrc === 'string' || typeof dataSrc === 'function') { + dataProp = dataSrc; + } + else if (dataSrc.data !== undefined) { + dataProp = dataSrc.data; + } } - // Feature sorting overrides column specific when off - if (!settings.features.ordering) { - column.orderable = false; + if (!write) { + if (dataProp === 'data') { + // If the default, then we still want to support the old style, and + // safely ignore it if possible + return json.aaData || json[dataProp]; + } + return dataProp !== '' ? util.get(dataProp)(json) : json; } + // set + util.set(dataProp)(json, write); } /** - * Adjust the table column widths for new data. Note: you would probably want to - * do a redraw after calling this function! + * Very similar to ajaxDataSrc, but for the other SSP properties * * @param settings DataTables settings object + * @param param Target parameter + * @param json JSON data + * @returns Resolved value */ -function adjustColumnSizing(settings) { - calculateColumnWidths(settings); - columnSizes(settings); - let scroll = settings.scroll; - if (scroll.y !== '' || scroll.x !== '') { - scrollDraw(settings); +function ajaxDataSrcParam(settings, param, json) { + var dataSrc = util.is.plainObject(settings.ajax) + ? settings.ajax.dataSrc // TODO + : null; + if (dataSrc && dataSrc[param]) { + // Get from custom location + return util.data.get(dataSrc[param])(json); } - callbackFire(settings, null, 'column-sizing', [settings]); + // else - Default behaviour + var old = ''; + // Legacy support + if (param === 'draw') { + old = 'sEcho'; + } + else if (param === 'recordsTotal') { + old = 'iTotalRecords'; + } + else if (param === 'recordsFiltered') { + old = 'iTotalDisplayRecords'; + } + return json[old] !== undefined ? json[old] : json[param]; } + +const __filter_div = Dom.c('div').get(0); +const __filter_div_textContent = __filter_div.textContent !== undefined; /** - * Apply column sizes + * Filter the table using both the global filter and column based filtering * * @param settings DataTables settings object */ -function columnSizes(settings) { - let cols = settings.columns; - for (let i = 0; i < cols.length; i++) { - let width = columnsSumWidth(settings, [i], false); - if (width) { - cols[i].colEl.css('width', width); - if (settings.scroll.x) { - cols[i].colEl.css('min-width', width); - } - } +function filterComplete(settings) { + settings.columns; + // In server-side processing all filtering is done by the server, so no + // point hanging around here + if (dataSource(settings) != 'ssp') { + // Check if any of the rows were invalidated + filterData(settings); + // Start from the full data set + settings.display = settings.displayMaster.slice(); + // Column set filters first + util.object.each(settings.searches, (key, s) => { + filter(settings.display, settings, s.search, s); + }); + // Fixed (named) filters next + util.object.each(settings.searchesFixed, function (columns) { + util.object.each(settings.searchesFixed[columns], function (name, s) { + filter(settings.display, settings, s.search, s); + }); + }); + // And finally legacy global filtering + filterCustom(settings); } + // Tell the draw function we have been filtering + settings.wasFiltered = true; + callbackFire(settings, null, 'search', [settings]); } /** - * Convert the index of a visible column to the index in the data array (take - * account of hidden columns) + * Apply custom filtering functions + * + * This is legacy now that we have named functions, but it is widely used + * from 1.x, so it is not yet deprecated. * * @param settings DataTables settings object - * @param visIdx Visible column index to lookup - * @returns i the data index */ -function visibleToColumnIndex(settings, visIdx) { - let aiVis = getColumns(settings, 'visible'); - return typeof aiVis[visIdx] === 'number' ? aiVis[visIdx] : null; +function filterCustom(settings) { + let filters = ext.search; + let displayRows = settings.display; + let row, rowIdx; + for (let i = 0, iLen = filters.length; i < iLen; i++) { + let rows = []; + // Loop over each row and see if it should be included + for (let j = 0, jen = displayRows.length; j < jen; j++) { + rowIdx = displayRows[j]; + row = settings.data[rowIdx]; + if (row && + filters[i](settings, row.searchCellCache, rowIdx, row.data, j)) { + rows.push(rowIdx); + } + } + // So the array reference doesn't break set the results into the + // existing array + displayRows.length = 0; + arrayApply(displayRows, rows); + } } /** - * Convert the index of an index in the data array and convert it to the visible - * column index (take account of hidden columns) + * Filter the data table based on user input and draw the table * - * @param settings DataTables settings object - * @param match Column index to lookup - * @returns The data index + * @param searchRows + * @param settings + * @param input + * @param options + * @returns */ -function columnIndexToVisible(settings, match) { - let aiVis = getColumns(settings, 'visible'); - let iPos = aiVis.indexOf(match); - return iPos !== -1 ? iPos : null; +function filter(searchRows, settings, input, options) { + if (input === '') { + return; + } + let i = 0; + let matched = []; + // Search term can be a function, regex or string - if a string we apply our + // smart filtering regex (assuming the options require that) + let searchFunc = typeof input === 'function' ? input : null; + let rpSearch = input instanceof RegExp + ? input + : searchFunc + ? null + : filterCreateSearch(input, options); + let columns = options.columns + ? options.columns + : util.array.range(settings.columns.length); + // Then for each row, does the test pass. If not, lop the row from the array + for (i = 0; i < searchRows.length; i++) { + let row = settings.data[searchRows[i]]; + if (row) { + // Get the data array based on the columns to include in the search + let data = util.array.selectiveJoin(row.searchCellCache, columns); + // Run the search action + if ((searchFunc && + searchFunc(data, row.data, searchRows[i], columns.length === 1 ? columns[0] : columns // compat + )) || + (rpSearch && typeof data === 'string' && rpSearch.test(data))) { + matched.push(searchRows[i]); + } + } + } + // Mutate the searchRows array + searchRows.length = matched.length; + for (i = 0; i < matched.length; i++) { + searchRows[i] = matched[i]; + } } /** - * Get the number of visible columns - * - * @param settings DataTables settings object - * @returns i the number of visible columns + * Build a regular expression object suitable for searching a table */ -function visibleColumns(settings) { - let layout = settings.header; +function filterCreateSearch(searchIn, inOpts) { + let not = []; + let options = Object.assign({}, { + boundary: false, + caseInsensitive: true, + exact: false, + regex: false, + smart: true + }, inOpts); + let search = typeof searchIn !== 'string' ? searchIn.toString() : searchIn; + // Remove diacritics if normalize is set up to do so + search = util.diacritics(search); + if (options.exact) { + return new RegExp('^' + util.escapeRegex(search) + '$', options.caseInsensitive ? 'i' : ''); + } + search = options.regex ? search : util.escapeRegex(search); + if (options.smart) { + /* For smart filtering we want to allow the search to work regardless of + * word order. We also want double quoted text to be preserved, so word + * order is important - a la google. And a negative look around for + * finding rows which don't contain a given string. + * + * So this is the sort of thing we want to generate: + * + * ^(?=.*?\bone\b)(?=.*?\btwo three\b)(?=.*?\bfour\b).*$ + */ + let parts = search.match(/!?["\u201C][^"\u201D]+["\u201D]|[^ ]+/g) || [ + '' + ]; + let a = parts.map(function (word) { + let negative = false; + let m; + // Determine if it is a "does not include" + if (word.charAt(0) === '!') { + negative = true; + word = word.substring(1); + } + // Strip the quotes from around matched phrases + if (word.charAt(0) === '"') { + m = word.match(/^"(.*)"$/); + word = m ? m[1] : word; + } + else if (word.charAt(0) === '\u201C') { + // Smart quote match (iPhone users) + m = word.match(/^\u201C(.*)\u201D$/); + word = m ? m[1] : word; + } + // For our "not" case, we need to modify the string that is + // allowed to match at the end of the expression. + if (negative) { + if (word.length > 1) { + not.push('(?!' + word + ')'); + } + word = ''; + } + return word.replace(/"/g, ''); + }); + let match = not.length ? not.join('') : ''; + let boundary = options.boundary ? '\\b' : ''; + search = + '^(?=.*?' + + boundary + + a.join(')(?=.*?' + boundary) + + ')(' + + match + + '.)*$'; + } + return new RegExp(search, options.caseInsensitive ? 'i' : ''); +} +// Update the filtering data for each row if needed (by invalidation or first +// run) +function filterData(settings) { let columns = settings.columns; - let vis = 0; - if (layout.length) { - for (let i = 0, iLen = layout[0].length; i < iLen; i++) { - if (columns[i].visible && - Dom.s(layout[0][i].cell).css('display') !== 'none') { - vis++; + let data = settings.data; + let column; + let j, jen, cellData, row; + let wasInvalidated = false; + for (let rowIdx = 0; rowIdx < data.length; rowIdx++) { + if (!data[rowIdx]) { + continue; + } + row = data[rowIdx]; + if (row && !row.searchCellCache) { + const rowFilterData = []; + for (j = 0, jen = columns.length; j < jen; j++) { + column = columns[j]; + if (column.searchable) { + cellData = getCellData(settings, rowIdx, j, 'filter'); + // Search in DataTables is string based + if (cellData === null) { + cellData = ''; + } + if (typeof cellData !== 'string' && cellData.toString) { + cellData = cellData.toString(); + } + } + else { + cellData = ''; + } + // If it looks like there is an HTML entity in the string, + // attempt to decode it so sorting works as expected. Note that + // we could use a single line of jQuery to do this, but the DOM + // method used here is much faster + // https://jsperf.com/html-decode + if (cellData.indexOf && cellData.indexOf('&') !== -1) { + __filter_div.innerHTML = cellData; + cellData = __filter_div_textContent + ? __filter_div.textContent + : __filter_div.innerText; + } + if (cellData.replace) { + cellData = cellData.replace(/[\r\n\u2028]/g, ''); + } + rowFilterData.push(cellData); } + row.searchCellCache = rowFilterData; + row.searchRowCache = rowFilterData.join(' '); + wasInvalidated = true; } } - return vis; + return wasInvalidated; } + /** - * Get an array of column indexes that match a given property + * Render and cache a row's display data for the columns, if required * * @param settings DataTables settings object - * @param param Parameter in the columns array to look for - * @returns Array of indexes with matched properties + * @param rowIdx Row index + * @returns Array with display information */ -function getColumns(settings, param) { - let a = []; - settings.columns.map(function (val, i) { - if (val[param]) { - a.push(i); +function getRowDisplay(settings, rowIdx) { + var rowModal = settings.data[rowIdx]; + var columns = settings.columns; + if (!rowModal) { + return []; + } + if (!rowModal.displayData) { + // Need to render and cache + rowModal.displayData = []; + for (var colIdx = 0, len = columns.length; colIdx < len; colIdx++) { + rowModal.displayData.push(getCellData(settings, rowIdx, colIdx, 'display')); } - }); - return a; + } + return rowModal.displayData; } /** - * Allow the result from a type detection function to be `true` while - * translating that into a string. Old type detection functions will return the - * type name if it passes. An object store would be better, but not backwards - * compatible. + * Create a new TR element (and it's TD children) for a row * - * @param typeDetect Object or function for type detection - * @param res Result from the type detection function - * @returns Type name or false - */ -function _typeResult(typeDetect, res) { - return res === true ? typeDetect._name : res; -} -/** - * Calculate the 'type' of a column * @param settings DataTables settings object + * @param rowIdx Row to consider + * @param trIn TR element to add to the table - optional. If not given, + * DataTables will create a row automatically + * @param tds Array of TD|TH elements for the row - must be given if trIn is. */ -function columnTypes(settings, originalTypes = '') { - var columns = settings.columns; - var data = settings.data; - var types = ext.type.detect; - var i, iLen, j, jen, k, ken; - var col, detectedType, cache; - if (!originalTypes) { - originalTypes = columns.map(c => c.type).join(','); - } - // For each column, spin over the data type detection functions, seeing if - // one matches - for (i = 0, iLen = columns.length; i < iLen; i++) { - col = columns[i]; - cache = []; - if (!col.type && col.typeManual) { - col.type = col.typeManual; - } - else if (!col.type) { - // With SSP type detection can be unreliable and error prone, so we - // provide a way to turn it off. - if (!settings.typeDetect) { - return; +function createTr(settings, rowIdx, trIn, tds) { + var row = settings.data[rowIdx], cells = [], tr, td, column, i, iLen, create, trClass = settings.classes.tbody.row; + if (row && row.tr === null) { + let rowData = row.data; + tr = trIn || document.createElement('tr'); + row.tr = tr; + row.cells = cells; + Dom.s(tr).classAdd(trClass); + // Use a private property on the node to allow reserve mapping from the node + // to the aoData array for fast look up + tr._DT_RowIndex = rowIdx; + // Special parameters can be given by the data source to be used on the + // row + rowAttributes(settings, row); + /* Process each column */ + for (i = 0, iLen = settings.columns.length; i < iLen; i++) { + column = settings.columns[i]; + create = trIn && tds && tds[i] ? false : true; + td = create + ? document.createElement(column.cellType) + : tds[i]; + if (!td) { + log(settings, 0, 'Incorrect column count', 18); } - for (j = 0, jen = types.length; j < jen; j++) { - let typeDetect = types[j]; - let oneOf; - let allOf; - let init; - let one = false; - // There can be either one, or three type detection functions - if (typeof typeDetect === 'function') { - allOf = typeDetect; - } - else { - oneOf = typeDetect.oneOf; - allOf = typeDetect.allOf; - init = typeDetect.init; - } - detectedType = null; - // Fast detect based on column assignment - if (init) { - detectedType = _typeResult(typeDetect, init(settings, col, i)); - if (detectedType) { - col.type = detectedType; - break; - } - } - for (k = 0, ken = data.length; k < ken; k++) { - if (!data[k]) { - continue; - } - // Use a cache array so we only need to get the type data - // from the formatter once (when using multiple detectors) - if (cache[k] === undefined) { - cache[k] = getCellData(settings, k, i, 'type'); - } - // Only one data point in the column needs to match this - // function - if (oneOf && !one) { - one = _typeResult(typeDetect, oneOf(cache[k], settings)); - } - // All data points need to match this function - detectedType = _typeResult(typeDetect, allOf(cache[k], settings)); - // If null, then this type can't apply to this column, so - // rather than testing all cells, break out. There is an - // exception for the last type which is `html`. We need to - // scan all rows since it is possible to mix string and HTML - // types - if (!detectedType && j !== types.length - 3) { - break; - } - // Only a single match is needed for html type since it is - // bottom of the pile and very similar to string - but it - // must not be empty - if (detectedType === 'html' && !util.is.empty(cache[k])) { - break; - } - } - // Type is valid for all data points in the column - use this - // type - if ((oneOf && one && detectedType) || - (!oneOf && detectedType)) { - col.type = detectedType; - break; - } + td._DT_CellIndex = { + row: rowIdx, + column: i + }; + cells.push(td); + var display = getRowDisplay(settings, rowIdx); + // Need to create the HTML if new, or if a rendering function is + // defined + if (create || + ((column.render || column.data !== i) && + (!util.is.plainObject(column.data) || + (column.data && + column.data._ !== i + '.display')))) { + writeCell(td, display[i]); } - // Fall back - if no type was detected, always use string - if (!col.type) { - col.type = 'string'; + // column class + Dom.s(td).classAdd(column.className); + // Visibility - add or remove as required + if (column.visible && create) { + tr.appendChild(td); + } + else if (!column.visible && !create) { + td.parentNode.removeChild(td); + } + if (column.createdCell) { + column.createdCell.call(settings.instance, td, getCellData(settings, rowIdx, i), rowData, rowIdx, i); } } - // Set class names for header / footer for auto type classes - var autoClass = ext.type.className[col.type]; - if (autoClass) { - _columnAutoClass(settings.header, i, autoClass); - _columnAutoClass(settings.footer, i, autoClass); - } - var renderer = ext.type.render[col.type]; - // This can only happen once! There is no way to remove - // a renderer. After the first time the renderer has - // already been set so createTr will run the renderer itself. - if (renderer && !col.renderer) { - col.renderer = util.get(renderer); - _columnAutoRender(settings, i); - } - } - var newTypes = columns.map(c => c.type).join(','); - if (newTypes !== originalTypes) { - callbackFire(settings, null, 'columnTypes', [settings], false); - } -} -/** - * Apply an auto detected renderer to data which doesn't yet have a renderer - */ -function _columnAutoRender(settings, colIdx) { - let data = settings.data; - for (let i = 0; i < data.length; i++) { - let d = data[i]; - if (d && d.tr) { - // We have to update the display here since there is no invalidation - // check for the data - let display = getCellData(settings, i, colIdx, 'display'); - d.displayData[colIdx] = display; - writeCell(d.cells[colIdx], display); - // No need to update sort / filter data since it has been - // invalidated and will be re-read with the renderer now applied - } + callbackFire(settings, 'rowCreated', 'row-created', [ + tr, + rowData, + rowIdx, + cells + ]); + } + else if (row) { + Dom.s(row.tr).classAdd(trClass); } } /** - * Apply a class name to a column's header cells + * Add attributes to a row based on the special `DT_*` parameters in a data + * source object. * - * @param container The header / footer structure array - * @param colIdx Column index - * @param className Class name to apply + * @param settings DataTables settings object + * @param row Row object for the row to be modified */ -function _columnAutoClass(container, colIdx, className) { - container.forEach(function (row) { - if (row[colIdx] && row[colIdx].unique) { - Dom.s(row[colIdx].cell).classAdd(className); +function rowAttributes(settings, row) { + var tr = row.tr; + var data = row.data; + if (tr) { + var id = settings.rowIdFn(data); + if (id) { + tr.id = id; } - }); + if (data.DT_RowClass) { + // Remove any classes added by DT_RowClass before + var a = data.DT_RowClass.split(' '); + row.addedClasses = row.addedClasses + ? util.unique(row.addedClasses.concat(a)) + : a; + Dom.s(tr) + .classRemove(row.addedClasses.join(' ')) + .classAdd(data.DT_RowClass); + } + if (data.DT_RowAttr) { + Dom.s(tr).attr(data.DT_RowAttr); + } + if (data.DT_RowData) { + Dom.s(tr).data(data.DT_RowData); + } + } } /** - * Take the column definitions and static columns arrays and calculate how they - * relate to column indexes. The callback function will then apply the - * definition found for a column to a suitable configuration object. + * Create the HTML header for the table * - * @param settings DataTables settings object - * @param aoColDefs The aoColumnDefs array that is to be applied - * @param aoCols The aoColumns array that defines columns individually - * @param headerLayout Layout for header as it was loaded - * @param fn Callback function - takes two parameters, the calculated column - * index and the definition for that column. + * @param settings DataTable instance + * @param side If the header or footer should be used + * @returns */ -function applyColumnDefs(settings, aoColDefs, aoCols, headerLayout, fn) { - var i, iLen, j, jLen, k, kLen; - var columns = settings.columns; - if (aoCols) { - for (i = 0, iLen = aoCols.length; i < iLen; i++) { - // Compat - if (aoCols[i] && aoCols[i].name) { - columns[i].name = aoCols[i].name; - } - } +function buildHead(settings, side) { + let classes = settings.classes; + let columns = settings.columns; + let i, iLen, row; + let target = Dom.s(side === 'header' ? settings.thead : settings.tfoot); + let titleProp = side === 'header' ? 'title' : side; + // Footer might be defined + if (!target) { + return; } - // Column definitions with aTargets - if (aoColDefs) { - // Loop over the definitions array - loop in reverse so first instance - // has priority - for (i = aoColDefs.length - 1; i >= 0; i--) { - let def = aoColDefs[i]; - /* Each definition can target multiple columns, as it is an array */ - let aTargets = def.target !== undefined - ? def.target - : def.targets !== undefined - ? def.targets - : def.aTargets; // legacy - if (!Array.isArray(aTargets)) { - aTargets = [aTargets]; - } - for (j = 0, jLen = aTargets.length; j < jLen; j++) { - var target = aTargets[j]; - if (typeof target === 'number' && target >= 0) { - /* Add columns that we don't yet know about */ - while (columns.length <= target) { - addColumn(settings); - } - /* Integer, basic index */ - fn(target, def); - } - else if (typeof target === 'number' && target < 0) { - /* Negative integer, right to left column counting */ - fn(columns.length + target, def); - } - else if (typeof target === 'string') { - for (k = 0, kLen = columns.length; k < kLen; k++) { - if (target === '_all') { - // Apply to all columns - fn(k, def); - } - else if (target.indexOf(':name') !== -1) { - // Column selector - if (columns[k].name === target.replace(':name', '')) { - fn(k, def); - } - } - else { - // Cell selector - headerLayout.forEach(function (row) { - if (row[k]) { - var cell = row[k].cell; - // Legacy support. Note that it means that - // we don't support an element name selector - // only, since they are treated as class - // names for 1.x compat. - if (target.match(/^[a-z][\w-]*$/i)) { - target = '.' + target; - } - if (cell.matches(target)) { - fn(k, def); - } - } - }); - } - } - } + // If no cells yet and we have content for them, then create + if (side === 'header' || + util.array.pluck(settings.columns, titleProp).join('')) { + row = target.find('tr'); + // Add a row if needed + if (!row.count()) { + row = Dom.c('tr').appendTo(target); + } + // Add the number of cells needed to make up to the number of columns + if (row.count() === 1) { + let cellCount = 0; + row.find('td, th').each(el => { + cellCount += el.colSpan; + }); + for (i = cellCount, iLen = columns.length; i < iLen; i++) { + Dom.c('th') + .html(columns[i][titleProp] || '') + .appendTo(row); } } } - // Statically defined columns array - if (aoCols) { - for (i = 0, iLen = aoCols.length; i < iLen; i++) { - fn(i, aoCols[i]); - } + let detected = detectHeader(settings, target.get(0), true); + if (side === 'header') { + settings.header = detected; + target.find('tr').classAdd(classes.thead.row); + } + else { + settings.footer = detected; + target.find('tr').classAdd(classes.tfoot.row); } + // Every cell needs to be passed through the renderer + target + .children('tr') + .children('th, td') + .each(el => { + // Should just be able to do `renderer(settings, side)` here but + // Typescript doesn't like it, despite it already being constrained! + let runner = side === 'header' + ? renderer(settings, 'header') + : renderer(settings, 'footer'); + runner(settings, Dom.s(el), classes); + }); } /** - * Get the width for a given set of columns + * Build a layout structure for a header or footer * - * @param settings DataTables settings object - * @param targets Columns - comma separated string or array of numbers - * @param original Use the original width (true) or calculated (false) - * @param incVisible Include visible columns (true) or not (false) - * @returns Combined CSS value + * @param settings DataTables settings + * @param source Source layout array + * @param incColumns What columns should be included + * @returns Layout array in column index order */ -function columnsSumWidth(settings, targets, original, incVisible) { - if (!Array.isArray(targets)) { - targets = columnsFromHeader(targets); +function headerLayout(settings, source, incColumns) { + var row, column, cell; + var local = []; + var structure = []; + var columns = settings.columns; + var columnCount = columns.length; + var rowspan, colspan; + if (!source) { + return; } - let sum = 0; - let unit = 'px'; - let columns = settings.columns; - for (let i = 0, iLen = targets.length; i < iLen; i++) { - let column = columns[targets[i]]; - let definedWidth = original ? column.widthOrig : column.width; - if (column.visible === false) { - continue; - } - if (definedWidth === null || definedWidth === undefined) { - return null; // can't determine a defined width - browser defined - } - else if (typeof definedWidth === 'number') { - sum += definedWidth; - } - else { - let matched = definedWidth.match(/([\d\.]+)([^\d]*)/); - if (matched) { - sum += parseFloat(matched[1]); - unit = matched.length === 3 ? matched[2] : 'px'; + // Default is to work on only visible columns + if (!incColumns) { + incColumns = util.array.range(columnCount).filter(function (idx) { + return columns[idx].visible; + }); + } + // Make a copy of the master layout array, but with only the columns we want + for (row = 0; row < source.length; row++) { + // Remove any columns we haven't selected + local[row] = source[row].slice().filter(function (c, i) { + return incColumns.includes(i); + }); + // Prep the structure array - it needs an element for each row + structure.push([]); + } + for (row = 0; row < local.length; row++) { + for (column = 0; column < local[row].length; column++) { + rowspan = 1; + colspan = 1; + // Check to see if there is already a cell (row/colspan) covering + // our target insert point. If there is, then there is nothing to + // do. + if (structure[row][column] === undefined) { + cell = local[row][column].cell; + // Expand for rowspan + while (local[row + rowspan] !== undefined && + local[row][column].cell == local[row + rowspan][column].cell) { + structure[row + rowspan][column] = null; + rowspan++; + } + // And for colspan + while (local[row][column + colspan] !== undefined && + local[row][column].cell == local[row][column + colspan].cell) { + // Which also needs to go over rows + for (var k = 0; k < rowspan; k++) { + structure[row + k][column + colspan] = null; + } + colspan++; + } + var titleSpan = Dom.s(cell).find('.dt-column-title'); + structure[row][column] = { + cell: cell, + colspan: colspan, + rowspan: rowspan, + title: titleSpan.count() + ? titleSpan.html() + : Dom.s(cell).html() + }; } } } - return sum + unit; + return structure; } /** - * Determine what columns a header cell covers (can be multiple for colspan - * cases). + * Draw the header (or footer) element based on the column visibility states. * - * @param cell The header cell in question - * @returns An array of column indexes + * @param settings DataTables settings object + * @param source Layout array from detectHeader */ -function columnsFromHeader(cell) { - let attr = Dom.s(cell).closest('[data-dt-column]').attr('data-dt-column'); - if (!attr) { - return []; +function drawHead(settings, source) { + let layout = headerLayout(settings, source); + let tr; + if (!layout) { + return; } - return attr.split(',').map(function (val) { - return parseInt(val); - }); -} - -/** - * Generate the node required for the processing node - * - * @param ctx DataTables settings object - */ -function processingHtml(ctx) { - var table = ctx.table; - var scrolling = ctx.scroll.x !== '' || ctx.scroll.y !== ''; - if (ctx.features.processing) { - var n = Dom - .c('div') - .attr('id', ctx.tableId + '_processing') - .attr('role', 'status') - .classAdd(ctx.classes.processing.container) - .html(ctx.language.processing) - .append(Dom - .c('div') - .append(Dom.c('div')) - .append(Dom.c('div')) - .append(Dom.c('div')) - .append(Dom.c('div'))); - // Different positioning depending on if scrolling is enabled or not - if (scrolling) { - n.prependTo(Dom.s(ctx.tableWrapper).find('div.dt-scroll').get(0)); + for (let row = 0; row < source.length; row++) { + tr = source[row].row; + // All cells are going to be replaced, so empty out the row + if (tr) { + Dom.s(tr).detachChildren(); } - else { - n.insertBefore(table); + for (let column = 0; column < layout[row].length; column++) { + let point = layout[row][column]; + if (point) { + Dom.s(point.cell) + .appendTo(tr) + .attr('rowspan', point.rowspan) + .attr('colspan', point.colspan); + } } - Dom.s(table).on('processing.dt.DT', (e, s, show) => { - n.css('display', show ? 'block' : 'none'); - }); } } /** - * Display or hide the processing indicator + * Insert the required TR nodes into the table for display * - * @param ctx DataTables settings object - * @param show Show the processing indicator (true) or not (false) + * @param settings DataTables settings object + * @param ajaxComplete true after ajax call to complete rendering */ -function processingDisplay(ctx, show) { - // Ignore cases when we are still redrawing - if (ctx.doingDraw && show === false) { +function draw(settings, ajaxComplete) { + // Allow for state saving and a custom start position + setStartPosition(settings); + // Provide a pre-callback function which can be used to cancel the draw is + // false is returned + var aPreDraw = callbackFire(settings, 'preDraw', 'preDraw', [settings]); + if (aPreDraw.indexOf(false) !== -1) { + processingDisplay(settings, false); return; } - callbackFire(ctx, null, 'processing', [ctx, show]); -} -/** - * Show the processing element if an action takes longer than a given time - * - * @param ctx DataTables settings object - * @param enable Do (true) or not (false) async processing (local feature enablement) - * @param run Function to run - */ -function processingRun(ctx, enable, run) { - if (!enable) { - // Immediate execution, synchronous - run(); - } - else { - processingDisplay(ctx, true); - // Allow the processing display to show if needed - setTimeout(function () { - run(); - processingDisplay(ctx, false); - }, 0); + var rowEls = []; + var rowCount = 0; + var isServerSide = dataSource(settings) == 'ssp'; + var display = settings.display; + var start = settings.displayStart; + var end = displayEnd(settings); + var columns = settings.columns; + var body = Dom.s(settings.tbody); + settings.doingDraw = true; + /* Server-side processing draw intercept */ + if (settings.deferLoading) { + settings.deferLoading = false; + settings.drawCount++; + processingDisplay(settings, false); } -} - -function renderer(ctx, type) { - var render = ctx.renderer; - var host = ext.renderer[type]; - if (plainObject(render) && render[type]) { - // Specific renderer for this type. If available use it, otherwise use - // the default. - return host[render[type]] || host._; + else if (!isServerSide) { + settings.drawCount++; } - else if (typeof render === 'string') { - // Common renderer - if there is one available for this type use it, - // otherwise use the default - return host[render] || host._; + else if (!settings.destroying && !ajaxComplete) { + // Show loading message for server-side processing + if (settings.drawCount === 0) { + body.empty().append(_emptyRow(settings)); + } + ajaxUpdate(settings); + return; } - // Use the default - return host._; -} - -/** - * Add the options to the page HTML for the table - * - * @param ctx DataTables context - */ -function createLayout(ctx) { - var classes = ctx.classes; - // Wrapper div around everything DataTables controls - var insert = Dom - .c('div') - .attr('id', ctx.tableId + '_wrapper') - .classAdd(classes.container) - .insertBefore(ctx.table); - ctx.tableWrapper = insert.get(0); - if (ctx.dom) { - // Legacy - legacyDom(ctx, ctx.dom, insert); + if (display.length !== 0) { + var iStart = isServerSide ? 0 : start; + var iEnd = isServerSide ? settings.data.length : end; + for (var j = iStart; j < iEnd; j++) { + var dataIdx = display[j]; + var data = settings.data[dataIdx]; + // Row has been deleted - can't be displayed + if (data === null) { + continue; + } + // Row node hasn't been created yet + if (data.tr === null) { + createTr(settings, dataIdx); + } + var nRow = data.tr; + // Add various classes as needed + for (var i = 0; i < columns.length; i++) { + var col = columns[i]; + var td = data.cells[i]; + Dom.s(td) + .classAdd(col.type ? ext.type.className[col.type] : null) // auto class + .classAdd(settings.classes.tbody.cell); // all cells + } + // Row callback functions - might want to manipulate the row + // rowCount and j are not currently documented. Are they at all + // useful? + callbackFire(settings, 'row', null, [ + nRow, + data.data, + rowCount, + j, + dataIdx + ]); + rowEls.push(nRow); + rowCount++; + } } else { - var top = convert(ctx, ctx.layout, 'top'); - var bottom = convert(ctx, ctx.layout, 'bottom'); - var render = renderer(ctx, 'layout'); - // Everything above - the renderer will actually insert the contents into the document - top.forEach(function (item) { - render(ctx, insert, item); - }); - // The table - always the center of attention - render(ctx, insert, { - full: { - contents: [featureTable(ctx)], - items: [], - table: true - } - }); - // Everything below - bottom.forEach(function (item) { - render(ctx, insert, item); - }); + rowEls[0] = _emptyRow(settings); } - // Processing floats on top, so it isn't an inserted feature - processingHtml(ctx); + /* Header and footer callbacks */ + callbackFire(settings, 'header', 'header', [ + Dom.s(settings.thead).children('tr').get(0), + getDataMaster(settings), + start, + end, + display + ]); + callbackFire(settings, 'footer', 'footer', [ + Dom.s(settings.tfoot).children('tr').get(0), + getDataMaster(settings), + start, + end, + display + ]); + body.detachChildren().append(rowEls); + // Empty table needs a specific class + Dom.s(settings.tableWrapper).classToggle('dt-empty-footer', Dom.s(settings.tfoot).find('tr').count() === 0); + // Call all required callback functions for the end of a draw + callbackFire(settings, 'draw', 'draw', [settings], true); + // Draw is complete, sorting and filtering must be as well + settings.wasOrdered = false; + settings.wasFiltered = false; + settings.doingDraw = false; } /** - * Expand the layout items into an object for the rendering function + * Redraw the table - taking account of the various features which are enabled + * + * @param settings DataTables settings object + * @param holdPosition Keep the current paging position. By default the paging + * is reset to the first page + * @param recompute Indicate if a rebuild of sort and filter should happen */ -function layoutItems(row, align, items) { - if (Array.isArray(items)) { - for (var i = 0; i < items.length; i++) { - layoutItems(row, align, items[i]); +function reDraw(settings, holdPosition, recompute) { + let features = settings.features, doSort = features.ordering, doFilter = features.searching; + if (recompute === undefined || recompute === true) { + // Resolve any column types that are unknown due to addition or + // invalidation + columnTypes(settings); + columnWidths(settings); + if (doSort) { + sort(settings); } - return; - } - var rowCell = row[align]; // can't be undefined - will have been created by getRow - // If it is an object, then there can be multiple features contained in it - if (util.is.plainObject(items)) { - // Is it an cell object already, with rowId, etc. A feature plugin cannot - // be named "features" due to this check - if (items.features) { - if (items.rowId) { - row.id = items.rowId; - } - if (items.rowClass) { - row.className = items.rowClass; - } - rowCell.id = items.id; - rowCell.className = items.className; - layoutItems(row, align, items.features); + if (doFilter) { + filterComplete(settings); } else { - // An object of features and configuration options - e.g. `{paging: {startEnd: false}}` - util.object.each(items, (key, val) => { - rowCell.items.push({ - feature: key, - opts: val - }); - }); + // No filtering, so we want to just use the display master + settings.display = settings.displayMaster.slice(); } } + if (holdPosition !== true) { + settings.displayStart = 0; + } else { - // Otherwise, it is a function, node or Dom / jQuery instance and can just get added - rowCell.items.push(items); + // Keep position, but make sure that there is actually data to display, + // otherwise we need to rewind a bit (e.g. if rows were deleted) + lengthOverflow(settings); } + // Let any modules know about the draw hold position state (used by + // scrolling internally) + settings.drawHold = holdPosition; + draw(settings); + settings.api.one('draw', function () { + settings.drawHold = false; + }); } /** - * Find, or create a layout row and setup a target cell in it + * Table is empty - create a row with an empty message in it * - * @param rows Rows array to search for the target row. Is mutated when a row is - * added if not found. - * @param rowNum Row index to get - * @param align Where the cell position is - * @returns The row + * @param settings DataTables context */ -function getRow(rows, rowNum, align) { - var row; - // Find existing rows - for (var i = 0; i < rows.length; i++) { - row = rows[i]; - if (row.rowNum === rowNum) { - // full is on its own, but start and end share a row - if ((align === 'full' && row.full) || - ((align === 'start' || align === 'end') && - (row.start || row.end))) { - if (!row[align]) { - row[align] = { - contents: [], - items: [] - }; +function _emptyRow(settings) { + let lang = settings.language; + let zero = lang.zeroRecords; + let dataSrc = dataSource(settings); + // Make use of the fact that settings.json is only set once the initial data + // has been loaded. Show loading when that isn't the case + if ((dataSrc === 'ssp' || dataSrc === 'ajax') && !settings.json) { + zero = lang.loadingRecords; + } + else if (lang.emptyTable && recordsTotal(settings) === 0) { + zero = lang.emptyTable; + } + return Dom + .c('tr') + .append(Dom + .c('td') + .attr('colSpan', visibleColumns(settings)) + .classAdd(settings.classes.empty.row) + .html(zero)) + .get(0); +} +/** + * Use the DOM source to create up an array of header cells. The idea here is to + * create a layout grid (array) of rows x columns, which contains a reference to + * the cell at that point in the grid (regardless of col/rowspan), such that any + * column / row could be removed and the new grid constructed. + * + * @param settings DataTables context + * @param thead thead / tbody element + * @param write If cells should be written (if required) + * @returns Calculated layout array + */ +function detectHeader(settings, thead, write) { + let columns = settings.columns; + let rows = Dom.s(thead).children('tr'); + let row, loopCell; + let i, k, l, len, shifted, column, colspan, rowspan; + let titleRow = settings.titleRow; + let isHeader = thead && thead.nodeName.toLowerCase() === 'thead'; + let layout = []; + let isUnique; + let shift = function (a, b, j) { + let d = a[b]; + while (d[j]) { + j++; + } + return j; + }; + // We know how many rows there are in the layout - so prep it + for (i = 0, len = rows.count(); i < len; i++) { + layout.push([]); + } + for (i = 0, len = rows.count(); i < len; i++) { + row = rows.get(i); + column = 0; + // For every cell in the row.. + loopCell = row.firstChild; + while (loopCell) { + if (loopCell.nodeName.toUpperCase() == 'TD' || + loopCell.nodeName.toUpperCase() == 'TH') { + let cell = Dom.s(loopCell); + let cols = []; + // Get the col and rowspan attributes from the DOM and sanitise + // them + colspan = parseInt(cell.attr('colspan') || '1') || 1; + rowspan = parseInt(cell.attr('rowspan') || '1') || 1; + colspan = + !colspan || colspan === 0 || colspan === 1 ? 1 : colspan; + rowspan = + !rowspan || rowspan === 0 || rowspan === 1 ? 1 : rowspan; + // There might be colspan cells already in this row, so shift + // our target accordingly + shifted = shift(layout, i, column); + // Cache calculation for unique columns + isUnique = colspan === 1 ? true : false; + // Perform header setup + if (write) { + if (isUnique) { + // Allow column options to be set from HTML attributes + columnOptions(settings, shifted, escapeObject(cell.data())); + // Get the width for the column. This can be defined + // from the width attribute, style attribute or + // `columns.width` option + let columnDef = columns[shifted]; + let width = cell.attr('width') || null; + let t = cell + .get(0) + .style.width.match(/width:\s*(\d+[pxem%]+)/); + if (t) { + width = t[1]; + } + columnDef.widthOrig = columnDef.width || width; + if (isHeader) { + // Column title handling - can be user set, or read + // from the DOM This happens before the render, so + // the original is still in place + if (columnDef.title !== null && + !columnDef.autoTitle) { + if ((titleRow === true && i === 0) || // top row + (titleRow === false && + i === rows.count() - 1) || // bottom row + titleRow === i || // specific row + titleRow === null) { + cell.html(columnDef.title); + } + } + if (!columnDef.title && isUnique) { + columnDef.title = util.string.stripHtml(cell.html()); + columnDef.autoTitle = true; + } + } + else { + // Footer specific operations + if (columnDef.footer) { + cell.html(columnDef.footer); + } + } + // Fall back to the aria-label attribute on the table + // header if no ariaTitle is provided. + if (!columnDef.ariaTitle) { + columnDef.ariaTitle = + cell.attr('aria-label') || columnDef.title; + } + // Column specific class names + if (columnDef.className) { + cell.classAdd(columnDef.className); + } + } + // Wrap the column title so we can write to it in future + if (cell.find('div.dt-column-title').count() === 0) { + Dom.c('div') + .classAdd('dt-column-title') + .append(Array.from(cell.get(0).childNodes)) + .appendTo(cell); + } + if (settings.orderIndicators && + isHeader && + cell.filter(':not([data-dt-order=disable])').count() !== + 0 && + cell.parent(':not([data-dt-order=disable])').count() !== + 0 && + cell.find('div.dt-column-order').count() === 0) { + Dom.c('div') + .classAdd('dt-column-order') + .appendTo(cell); + } + // We need to wrap the elements in the header in another + // element to use flexbox layout for those elements + var headerFooter = isHeader ? 'header' : 'footer'; + if (cell.find('div.dt-column-' + headerFooter).count() === + 0) { + Dom.c('div') + .classAdd('dt-column-' + headerFooter) + .append(Array.from(cell.get(0).childNodes)) + .appendTo(cell); + } + } + // If there is col / rowspan, copy the information into the + // layout grid + for (l = 0; l < colspan; l++) { + for (k = 0; k < rowspan; k++) { + layout[i + k][shifted + l] = { + cell: cell.get(0), + unique: isUnique + }; + layout[i + k].row = row; + } + cols.push(shifted + l); } - return row; + // Assign an attribute so spanning cells can still be identified + // as belonging to a column + cell.attr('data-dt-column', util.unique(cols).join(',')); } + loopCell = loopCell.nextSibling; } } - // If we get this far, then there was no match, create a new row - row = { - rowNum: rowNum - }; - row[align] = { - contents: [], - items: [] - }; - rows.push(row); - return row; + return layout; } /** - * Convert a `layout` object given by a user to the object structure needed - * for the renderer. This is done twice, once for above and once for below - * the table. Ordering must also be considered. + * Set the start position for draw * * @param settings DataTables settings object - * @param layout Layout object to convert - * @param side `top` or `bottom` - * @returns Converted array structure - one item for each row. */ -function convert(settings, layout, side) { - var rows = []; - // Split out into an array - util.object.each(layout, function (pos, items) { - var parts = pos.match(/^([a-z]+)([0-9]*)([A-Za-z]*)$/); - if (items === null || !parts) { - return; - } - var rowNum = parts[2] ? parseInt(parts[2]) : 0; - var align = parts[3] ? parts[3].toLowerCase() : 'full'; - // Filter out the side we aren't interested in - if (parts[1] !== side) { - return; - } - // Only really a type check - if (align !== 'full' && align !== 'start' && align !== 'end') { - return; - } - // Get or create the row we should attach to - var row = getRow(rows, rowNum, align); - layoutItems(row, align, items); - }); - // Order by item identifier - rows.sort(function (a, b) { - var order1 = a.rowNum || 0; - var order2 = b.rowNum || 0; - // If both in the same row, then the row with `full` comes first - if (order1 === order2) { - var ret = a.full && !b.full ? -1 : 1; - return side === 'bottom' ? ret * -1 : ret; - } - return order2 - order1; - }); - // Invert for below the table - if (side === 'bottom') { - rows.reverse(); - } - for (var row = 0; row < rows.length; row++) { - delete rows[row].rowNum; - resolve(settings, rows[row]); +function setStartPosition(settings) { + var bServerSide = dataSource(settings) == 'ssp'; + var iInitDisplayStart = settings.displayStartInit; + // Check and see if we have an initial draw position from state saving + if (iInitDisplayStart !== undefined && iInitDisplayStart !== -1) { + settings.displayStart = bServerSide + ? iInitDisplayStart + : iInitDisplayStart >= recordsDisplay(settings) + ? 0 + : iInitDisplayStart; + settings.displayStartInit = -1; } - return rows; } /** - * Convert the contents of a row's layout object to nodes that can be inserted - * into the document by a renderer. Execute functions, look up plug-ins, etc. + * Get the number of records in the current record set, before filtering * - * @param settings DataTables settings object - * @param row Layout object for this row + * @param ctx DataTables settings object */ -function resolve(settings, row) { - var getFeature = function (feature, opts) { - if (!ext.features[feature]) { - log(settings, 0, 'Unknown feature: ' + feature); - } - return ext.features[feature].apply(this, [settings, opts]); - }; - // Resolve items in the `contents` array from being an identifier, such as - // the name of a feature, into the node to display. - var resolve = function (item) { - if (!row[item]) { - return; - } - row[item].contents = row[item].items - .filter(item => !!item) - .map(item => { - if (typeof item === 'string') { - return getFeature(item, null); - } - else if (util.is.plainObject(item)) { - // If it's an object, it just has feature and opts properties from - // the transform in _layoutArray - return getFeature(item.feature, item.opts); - } - else if (typeof item.node === 'function') { - return item.node(settings); - } - else if (typeof item === 'function') { - var inst = item(settings); - return typeof inst.node === 'function' ? inst.node() : inst; - } - else if (item.nodeName) { - // An HTML element - return item; - } - else if (item instanceof Dom) { - return item.get(0); - } - else if (item.length) { - // Possibly jQuery - return item[0]; - } - }); - }; - resolve('start'); - resolve('end'); - resolve('full'); +function recordsTotal(ctx) { + return dataSource(ctx) == 'ssp' + ? ctx.recordsTotal * 1 + : ctx.displayMaster.length; } /** - * Draw the table with the legacy DOM property + * Get the number of records in the current record set, after filtering * - * @param settings DT settings instance - * @param layout DOM string - * @param insert Insert point + * @param ctx DataTables settings object */ -function legacyDom(settings, layout, insert) { - let parts = layout.match(/(".*?")|('.*?')|./g); - let featureNode, option, newNode, next, attr; - if (!parts) { - return; +function recordsDisplay(ctx) { + return dataSource(ctx) == 'ssp' + ? ctx.recordsDisplay * 1 + : ctx.display.length; +} +/** + * Get the display end point - display index + * + * @param ctx DataTables settings object + */ +function displayEnd(ctx) { + var len = ctx.pageLength, start = ctx.displayStart, calc = start + len, records = ctx.display.length, features = ctx.features, paginate = features.paging; + if (features.serverSide) { + return paginate === false || len === -1 + ? start + records + : Math.min(start + len, ctx.recordsDisplay); } - for (let i = 0; i < parts.length; i++) { - featureNode = null; - option = parts[i]; - if (option == '<') { - // New container div - newNode = Dom.c('div'); - // Check to see if we should append an id and/or a class name to the container - next = parts[i + 1]; - if (next[0] == "'" || next[0] == '"') { - attr = next.replace(/['"]/g, ''); - let id = '', className; - /* The attribute can be in the format of "#id.class", "#id" or "class" This logic - * breaks the string into parts and applies them as needed - */ - if (attr.indexOf('.') != -1) { - let split = attr.split('.'); - id = split[0]; - className = split[1]; - } - else if (attr[0] == '#') { - id = attr; - } - else { - className = attr; - } - newNode.attr('id', id.substring(1)).classAdd(className); - i++; // Move along the position array - } - insert.append(newNode.get()); // TODO - insert = newNode; + else { + return !paginate || calc > records || len === -1 ? records : calc; + } +} + +/** + * Log an error message + * + * @param ctx DataTables settings object + * @param level log error messages, or display them to the user + * @param msg error message + * @param tn Technical note id to get more information about the error. + */ +function log(ctx, level, msg, tn) { + msg = + 'DataTables warning: ' + + (ctx ? 'table id=' + ctx.tableId + ' - ' : '') + + msg; + if (tn) { + msg += + '. For more information about this error, please see ' + + 'https://datatables.net/tn/' + + tn; + } + { + // Backwards compatibility pre 1.10 + var type = ext.sErrMode || ext.errMode; + if (ctx) { + callbackFire(ctx, null, 'dt-error', [ctx, tn, msg], true); } - else if (option == '>') { - // End container div - insert = insert.parent(); + if (type == 'alert') { + alert(msg); } - else if (option == 't') { - // Table - featureNode = featureTable(settings); + else if (type == 'throw') { + throw new Error(msg); } - else { - ext.feature.forEach(function (feature) { - if (option == feature.cFeature) { - featureNode = feature.fnInit(settings); - } - }); + else if (typeof type == 'function') { + type(ctx, tn, msg); } - // Add to the display - if (featureNode) { - // TODO when doing the full dom update, won't need this check - insert.append(featureNode instanceof Dom ? featureNode.get() : featureNode); + } +} +/** + * See if a property is defined on one object, if so assign it to the other + * object + * + * @param ret target object + * @param src source object + * @param name property + * @param mappedName name to map too - optional, name used if not given + */ +function map(ret, src, name, mappedName) { + if (Array.isArray(name)) { + for (let i = 0; i < name.length; i++) { + let val = name[i]; + if (Array.isArray(val)) { + map(ret, src, val[0], val[1]); + } + else { + map(ret, src, val); + } } + return; } -} - -function sortInit(settings) { - var target = settings.thead; - var headerRows = target.querySelectorAll('tr'); - var titleRow = settings.titleRow; - var notSelector = ':not([data-dt-order="disable"]):not([data-dt-order="icon-only"])'; - // Legacy support for `orderCellsTop` - if (titleRow === true) { - target = headerRows[0]; - } - else if (titleRow === false) { - target = headerRows[headerRows.length - 1]; - } - else if (titleRow !== null) { - target = headerRows[titleRow]; + if (mappedName === undefined) { + mappedName = name; } - // else - all rows - if (settings.orderHandler) { - sortAttachListener(settings, target, target === settings.thead - ? 'tr' + - notSelector + - ' th' + - notSelector + - ', tr' + - notSelector + - ' td' + - notSelector - : 'th' + notSelector + ', td' + notSelector); + if (src[name] !== undefined) { + ret[mappedName] = src[name]; } - // Need to resolve the user input array into our internal structure - var order = []; - sortResolve(settings, order, settings.order); - settings.order = order; } /** - * Attach event listeners to a node that will trigger ordering on a column + * Bind an event handler to allow a click or return key to activate the callback. + * This is good for accessibility since a return on the keyboard will have the + * same effect as a click, if the element has focus. * - * @param settings DataTables context - * @param node Node to attach to - * @param selector Delegate selector - * @param column Column index to target - * @param callback Callback for when done + * @param n Element to bind the action to + * @param selector Selector (for delegated events) + * @param fn Callback function for when the event is triggered */ -function sortAttachListener(settings, node, selector, column, callback) { - bindAction(node, selector, function (e) { - var run = false; - var columns = column === undefined - ? columnsFromHeader(e.target) - : typeof column === 'function' - ? column() - : Array.isArray(column) - ? column - : [column]; - if (columns.length) { - for (var i = 0, iLen = columns.length; i < iLen; i++) { - var ret = sortAdd(settings, columns[i], i, e.shiftKey); - if (ret !== false) { - run = true; - } - // If the first entry is no sort, then subsequent - // sort columns are ignored - if (settings.order.length === 1 && - settings.order[0][1] === '') { - break; - } - } - if (run) { - processingRun(settings, true, function () { - sort(settings); - sortDisplay(settings, settings.display); - reDraw(settings, false, false); - if (callback) { - callback(); - } - }); - } +function bindAction(n, selector, fn) { + Dom.s(n) + .on('click.DT', selector, function (e) { + fn(e); + }) + .on('keypress.DT', selector, function (e) { + if (e.which === 13) { + e.preventDefault(); + fn(e); } + }) + .on('selectstart.DT', selector, function () { + // Don't want a double click resulting in text selection + return false; }); } /** - * Sort the display array to match the master's order + * Register a callback function. Easily allows a callback function to be added + * to an array store of callback functions that can then all be called together. * - * @param settings DataTables context - * @param display The display array + * @param settings dataTables settings object + * @param store Name of the array storage for the callbacks in settings + * @param fn Function to be called back */ -function sortDisplay(settings, display) { - if (display.length < 2) { - return; - } - var master = settings.displayMaster; - var masterMap = {}; - var map = {}; - var i; - // Rather than needing an `indexOf` on master array, we can create a map - for (i = 0; i < master.length; i++) { - masterMap[master[i]] = i; - } - // And then cache what would be the indexOf from the display - for (i = 0; i < display.length; i++) { - map[display[i]] = masterMap[display[i]]; +function callbackReg(ctx, store, fn) { + if (fn) { + ctx.callbacks[store].push(fn); } - display.sort(function (a, b) { - // Short version of this function is simply `master.indexOf(a) - master.indexOf(b);` - return map[a] - map[b]; - }); } /** - * Convert the API variants that can be used for defining the order into our - * internal OrderColumn array. + * Fire callback functions and trigger events. Note that the loop over the + * callback array store is done backwards! Further note that you do not want to + * fire off triggers in time sensitive applications (for example cell creation) + * as its slow. * - * @param settings DataTable context object - * @param nestedSort Array to write the resolve values to - * @param sortItem Source object / array from user (It is really an `Order` - * but due to `aaSorting` being used for input and the internal structure - * it is currently any). - * @todo Split aaSorting into unresolved and resolved parameters (in state.ts as - * well) + * @param ctx DataTables settings object + * @param callbackArr Name of the array storage for the callbacks in the context + * @param eventName Name of the custom event to trigger. If null no trigger is + * fired + * @param args Array of arguments to pass to the callback function / trigger + * @param bubbles True if the event should bubble */ -function sortResolve(settings, nestedSort, sortItem // TODO typing -) { - var push = function (a) { - if (plainObject(a)) { - let orderIdx = a; - let orderName = a; - if (orderIdx.idx !== undefined) { - // Index based ordering - nestedSort.push([orderIdx.idx, orderIdx.dir]); - } - else if (orderName.name) { - // Name based ordering - var cols = pluck(settings.columns, 'name'); - var idx = cols.indexOf(orderName.name); - if (idx !== -1) { - nestedSort.push([idx, orderName.dir]); - } - } - } - else { - // Plain column index and direction pair - nestedSort.push(a); - } - }; - if (plainObject(sortItem)) { - // Object - push(sortItem); - } - else if (Array.isArray(sortItem) && typeof sortItem[0] === 'number') { - // 1D array - push(sortItem); +function callbackFire(ctx, callbackArr, eventName, args, bubbles = false) { + var ret = []; + if (callbackArr) { + ret = ctx.callbacks[callbackArr] + .slice() + .reverse() + .map(function (val) { + return val.apply(ctx.instance, args); + }); } - else if (Array.isArray(sortItem)) { - // 2D array - for (var z = 0; z < sortItem.length; z++) { - push(sortItem[z]); // Object or array + if (eventName !== null) { + let table = Dom.s(ctx.table); + let result = table.trigger(eventName + '.dt', bubbles, args, { + dt: ctx.api + }); + // If not yet attached to the document, trigger the event + // on the body directly to sort of simulate the bubble + if (bubbles && table.closest('body').count() === 0) { + Dom.s('body').trigger(eventName + '.dt', bubbles, args, { + dt: ctx.api + }); } + ret.push(result[0]); } + return ret; } -function sortFlatten(settings) { - var i, k, kLen, aSort = [], extSort = ext.type.order, aoColumns = settings.columns, dataSort, colIdx, type, srcCol, fixed = settings.orderFixed, fixedObj = plainObject(fixed), nestedSort = []; - if (!settings.features.ordering) { - return aSort; - } - // Build the sort array, with pre-fix and post-fix options if they have been - // specified - if (Array.isArray(fixed)) { - sortResolve(settings, nestedSort, fixed); - } - if (fixedObj && fixed.pre) { - sortResolve(settings, nestedSort, fixed.pre); +function lengthOverflow(ctx) { + var start = ctx.displayStart, end = displayEnd(ctx), len = ctx.pageLength; + // If we have space to show extra rows (backing up from the end point - then + // do so + if (start >= end) { + start = end - len; } - sortResolve(settings, nestedSort, settings.order); - if (fixedObj && fixed.post) { - sortResolve(settings, nestedSort, fixed.post); + // Keep the start record on the current page + start -= start % len; + if (len === -1 || start < 0) { + start = 0; } - for (i = 0; i < nestedSort.length; i++) { - srcCol = nestedSort[i][0]; - if (aoColumns[srcCol]) { - dataSort = aoColumns[srcCol].orderData; - for (k = 0, kLen = dataSort.length; k < kLen; k++) { - colIdx = dataSort[k]; - type = aoColumns[colIdx].type || 'string'; - if (nestedSort[i]._idx === undefined) { - nestedSort[i]._idx = aoColumns[colIdx].orderSequence.indexOf(nestedSort[i][1]); - } - if (nestedSort[i][1]) { - aSort.push({ - src: srcCol, - col: colIdx, - dir: nestedSort[i][1], - index: nestedSort[i]._idx, - type: type, - formatter: extSort[type + '-pre'], - sorter: extSort[type + '-' + nestedSort[i][1]] - }); - } - } - } + ctx.displayStart = start; +} +/** + * Detect the data source being used for the table. Used to simplify the code a + * little (ajax) and to make it compress a little smaller. + * + * @param ctx DataTables settings object + * @returns Data source + */ +function dataSource(ctx) { + if (ctx.features.serverSide) { + return 'ssp'; } - return aSort; + else if (ctx.ajax) { + return 'ajax'; + } + return 'dom'; } /** - * Change the order of the table + * Common replacement for language strings * * @param ctx DataTables settings object - * @param col Column to perform sort on - * @param dir Direction to sort on + * @param str String with values to replace + * @param entries Plural number for _ENTRIES_ - can be undefined + * @returns String */ -function sort(ctx, col, dir) { - var i, iLen, aiOrig = [], extSort = ext.type.order, data = ctx.data, sortCol, displayMaster = ctx.displayMaster, aSort; - // Make sure the columns all have types defined - columnTypes(ctx); - // Allow a specific column to be sorted, which will _not_ alter the display - // master - if (col !== undefined) { - var srcCol = ctx.columns[col]; - aSort = [ - { - src: col, - col: col, - dir: dir || '', - index: 0, - type: srcCol.type, - formatter: extSort[srcCol.type + '-pre'], - sorter: extSort[srcCol.type + '-' + dir] - } - ]; - displayMaster = displayMaster.slice(); +function macros(ctx, str, entries) { + // When infinite scrolling, we are always starting at 1. _iDisplayStart is + // used only internally + var formatter = ctx.formatNumber, start = ctx.displayStart + 1, len = ctx.pageLength, vis = recordsDisplay(ctx), max = recordsTotal(ctx), all = len === -1; + return str + .replace(/_START_/g, formatter(start, ctx)) + .replace(/_END_/g, formatter(displayEnd(ctx), ctx)) + .replace(/_MAX_/g, formatter(max, ctx)) + .replace(/_TOTAL_/g, formatter(vis, ctx)) + .replace(/_PAGE_/g, formatter(all ? 1 : Math.ceil(start / len), ctx)) + .replace(/_PAGES_/g, formatter(all ? 1 : Math.ceil(vis / len), ctx)) + .replace(/_ENTRIES_/g, ctx.api.i18n('entries', '', entries)) + .replace(/_ENTRIES-MAX_/g, ctx.api.i18n('entries', '', max)) + .replace(/_ENTRIES-TOTAL_/g, ctx.api.i18n('entries', '', vis)); +} +/** + * Add elements to an array as quickly as possible, but stack safe. + * + * @param arr Array to add the data to + * @param data Data array that is to be added + */ +function arrayApply(arr, data) { + if (!data) { + return; + } + // Chrome can throw a max stack error if apply is called with + // too large an array, but apply is faster. + if (data.length < 10000) { + arr.push.apply(arr, data); } else { - aSort = sortFlatten(ctx); + for (var i = 0; i < data.length; i++) { + arr.push(data[i]); + } } - for (i = 0, iLen = aSort.length; i < iLen; i++) { - sortCol = aSort[i]; - // Load the data needed for the sort, for each cell - sortData(ctx, sortCol.col); +} +/** + * Add one or more listeners to the table + * + * @param that JQ for the table + * @param name Event name + * @param src Listener(s) + */ +function listener(that, name, src) { + let srcArr = Array.isArray(src) ? src : [src]; + for (var i = 0; i < srcArr.length; i++) { + that.on(name + '.dt.DT', srcArr[i]); } - /* No sorting required if server-side or no sorting array */ - if (dataSource(ctx) != 'ssp' && aSort.length !== 0) { - // Reset the initial positions on each pass so we get a stable sort - for (i = 0, iLen = displayMaster.length; i < iLen; i++) { - aiOrig[i] = i; +} +/** + * Escape HTML entities in strings, in an object + */ +function escapeObject(obj) { + if (ext.escape.attributes) { + each(obj, function (key, val) { + obj[key] = escapeHtml(val); + }); + } + return obj; +} + +const store = { + className: {}, + detect: [], + render: {}, + search: {}, + order: {} +}; +// Common function to remove new lines, strip HTML and diacritic control +function _filterString(stripHtml, normalize) { + return function (str) { + if (util.is.empty(str) || typeof str !== 'string') { + return str; } - // If the first sort is desc, then reverse the array to preserve original - // order, just in reverse - if (aSort.length && aSort[0].dir === 'desc' && ctx.orderDescReverse) { - aiOrig.reverse(); + str = str.replace(util.regex.reNewLines, ' '); + if (stripHtml) { + str = util.stripHtml(str); } - /* Do the sort - here we want multi-column sorting based on a given data source (column) - * and sorting function (from oSort) in a certain direction. It's reasonably complex to - * follow on its own, but this is what we want (example two column sorting): - * fnLocalSorting = function(a,b){ - * var test; - * test = oSort['string-asc']('data11', 'data12'); - * if (test !== 0) - * return test; - * test = oSort['numeric-desc']('data21', 'data22'); - * if (test !== 0) - * return test; - * return oSort['numeric-asc']( aiOrig[a], aiOrig[b] ); - * } - * Basically we have a test for each sorting column, if the data in that column is equal, - * test the next column. If all columns match, then we use a numeric sort on the row - * positions in the original data array to provide a stable sort. - */ - displayMaster.sort(function (a, b) { - var _a, _b; - var x, y, k, test, sortItem, len = aSort.length, dataA = (_a = data[a]) === null || _a === void 0 ? void 0 : _a.orderCache, dataB = (_b = data[b]) === null || _b === void 0 ? void 0 : _b.orderCache; - for (k = 0; k < len; k++) { - sortItem = aSort[k]; - // Data, which may have already been through a `-pre` function - x = dataA[sortItem.col]; - y = dataB[sortItem.col]; - if (sortItem.sorter) { - // If there is a custom sorter (`-asc` or `-desc`) for this - // data type, use it - test = sortItem.sorter(x, y); - if (test !== 0) { - return test; - } - } - else { - // Otherwise, use generic sorting - test = x < y ? -1 : x > y ? 1 : 0; - if (test !== 0) { - return sortItem.dir === 'asc' ? test : -test; - } - } - } - x = aiOrig[a]; - y = aiOrig[b]; - return x < y ? -1 : x > y ? 1 : 0; - }); + { + str = util.diacritics(str, false); + } + return str; + }; +} +function __numericReplace(d, decimalPlace, re1, re2) { + if (d !== 0 && (!d || d === '-')) { + return -Infinity; } - else if (aSort.length === 0) { - // Apply index order - displayMaster.sort(function (x, y) { - return x < y ? -1 : x > y ? 1 : 0; - }); + if (typeof d === 'number' || typeof d === 'bigint') { + return d; } - if (col === undefined) { - // Tell the draw function that we have sorted the data - ctx.wasOrdered = true; - ctx.sortDetails = aSort; - callbackFire(ctx, null, 'order', [ctx, aSort]); + // If a decimal place other than `.` is used, it needs to be given to the + // function so we can detect it and replace with a `.` which is the only + // decimal place JavaScript recognises - it is not locale aware. + if (decimalPlace) { + d = util.conv.numToDecimal(d, decimalPlace); } - return displayMaster; + if (typeof d === 'string') { + if (re1) { + d = d.replace(re1, ''); + } + if (re2) { + d = d.replace(re2, ''); + } + } + return d * 1; } -/** - * Function to run on user sort request - * - * @param settings dataTables settings object - * @param colIdx column sorting index - * @param addIndex Counter - * @param shift Shift click add - */ -function sortAdd(settings, colIdx, addIndex, shift) { - var col = settings.columns[colIdx]; - var sorting = settings.order; - var asSorting = col.orderSequence; - var nextSortIdx; - var next = function (a, overflow) { - var idx = a._idx; - if (idx === undefined) { - idx = asSorting.indexOf(a[1]); +function register$1(name, prop, val) { + if (!prop) { + return { + className: store.className[name], + detect: store.detect.find(function (fn) { + return fn._name === name; + }), + order: { + pre: store.order[name + '-pre'], + asc: store.order[name + '-asc'], + desc: store.order[name + '-desc'] + }, + render: store.render[name], + search: store.search[name] + }; + } + var setProp = function (prop2, propVal) { + store[prop2][name] = propVal; + }; + var setDetect = function (detect) { + // `detect` can be a function or an object - we set a name + // property for either - that is used for the detection + Object.defineProperty(detect, '_name', { value: name }); + var idx = store.detect.findIndex(function (item) { + return item._name === name; + }); + if (idx === -1) { + store.detect.unshift(detect); + } + else { + store.detect.splice(idx, 1, detect); } - return idx + 1 < asSorting.length ? idx + 1 : overflow ? null : 0; }; - if (!col.orderable) { - return false; + var setOrder = function (obj) { + store.order[name + '-pre'] = obj.pre; // can be undefined + store.order[name + '-asc'] = obj.asc; // can be undefined + store.order[name + '-desc'] = obj.desc; // can be undefined + }; + // prop is optional + if (val === undefined) { + val = prop; + prop = undefined; } - // Convert to 2D array if needed - if (typeof sorting[0] === 'number') { - sorting = settings.order = [sorting]; + if (prop === 'className') { + setProp('className', val); } - // If appending the sort then we are multi-column sorting - if ((shift || addIndex) && settings.features.orderMulti) { - // Are we already doing some kind of sort on this column? - var sortIdx = pluck(sorting, '0').indexOf(colIdx); - if (sortIdx !== -1) { - // Yes, modify the sort - nextSortIdx = next(sorting[sortIdx], true); - if (nextSortIdx === null && sorting.length === 1) { - nextSortIdx = 0; // can't remove sorting completely - } - if (nextSortIdx === null || asSorting[nextSortIdx] === '') { - sorting.splice(sortIdx, 1); - } - else { - sorting[sortIdx][1] = asSorting[nextSortIdx]; - sorting[sortIdx]._idx = nextSortIdx; - } + else if (prop === 'detect') { + setDetect(val); + } + else if (prop === 'order') { + setOrder(val); + } + else if (prop === 'render') { + setProp('render', val); + } + else if (prop === 'search') { + setProp('search', val); + } + else if (!prop) { + if (val.className) { + setProp('className', val.className); } - else if (shift) { - // No sort on this column yet, being added by shift click - // add it as itself - sorting.push([colIdx, asSorting[0], 0]); - sorting[sorting.length - 1]._idx = 0; + if (val.detect !== undefined) { + setDetect(val.detect); } - else { - // No sort on this column yet, being added from a colspan - // so add with same direction as first column - sorting.push([colIdx, sorting[0][1], 0]); - sorting[sorting.length - 1]._idx = 0; + if (val.order) { + setOrder(val.order); } - } - else if (sorting.length && sorting[0][0] == colIdx) { - // Single column - already sorting on this column, modify the sort - nextSortIdx = next(sorting[0]); - if (nextSortIdx) { - sorting.length = 1; - sorting[0][1] = asSorting[nextSortIdx]; - sorting[0]._idx = nextSortIdx; + if (val.render !== undefined) { + setProp('render', val.render); } - else { - sorting.length = 1; - sorting[0][1] = asSorting[0]; - sorting[0]._idx = 0; + if (val.search !== undefined) { + setProp('search', val.search); } } - else { - // Single column - sort only on this column - sorting.length = 0; - sorting.push([colIdx, asSorting[0]]); - sorting[0]._idx = 0; - } } -/** - * Set the sorting classes on table's body, Note: it is safe to call this function - * when bSort and bSortClasses are false - * - * @param settings DataTables settings object - */ -function sortingClasses(settings) { - var oldSort = settings.lastOrder; - var sortClass = settings.classes.order.position; - var sortFlat = sortFlatten(settings); - var features = settings.features; - var i, iLen, colIdx; - if (features.ordering && features.orderClasses) { - // Remove old sorting classes - for (i = 0, iLen = oldSort.length; i < iLen; i++) { - colIdx = oldSort[i].src; - // Remove column sorting - Dom.s(pluck(settings.data, 'cells', colIdx)).classRemove(sortClass + (i < 2 ? i + 1 : 3)); +// Get a list of types +function types() { + return store.detect.map(function (detect) { + return detect._name; + }); +} +var __diacriticSort = function (a, b) { + a = a !== null && a !== undefined ? a.toString().toLowerCase() : ''; + b = b !== null && b !== undefined ? b.toString().toLowerCase() : ''; + // Checked for `navigator.languages` support in `oneOf` so this code can't execute in old + // Safari and thus can disable this check + // eslint-disable-next-line compat/compat + return a.localeCompare(b, navigator.languages[0] || navigator.language, { + numeric: true, + ignorePunctuation: true + }); +}; +var __diacriticHtmlSort = function (a, b) { + a = util.stripHtml(a); + b = util.stripHtml(b); + return __diacriticSort(a, b); +}; +// +// Built in data types +// +register$1('string', { + detect: function () { + return 'string'; + }, + order: { + pre: function (a) { + // This is a little complex, but faster than always calling toString, + // http://jsperf.com/tostring-v-check + return util.is.empty(a) && typeof a !== 'boolean' + ? '' + : typeof a === 'string' + ? a.toLowerCase() + : !a.toString + ? '' + : a.toString(); } - // Add new column sorting - for (i = 0, iLen = sortFlat.length; i < iLen; i++) { - colIdx = sortFlat[i].src; - Dom.s(pluck(settings.data, 'cells', colIdx)).classAdd(sortClass + (i < 2 ? i + 1 : 3)); + }, + search: _filterString(false) +}); +register$1('string-utf8', { + detect: { + allOf: function () { + return true; + }, + oneOf: function (d) { + // At least one data point must contain a non-ASCII character + // This line will also check if navigator.languages is supported or not. If not (Safari 10.0-) + // this data type won't be supported. + // eslint-disable-next-line compat/compat + return (!util.is.empty(d) && + navigator.languages && + typeof d === 'string' && + !!d.match(/[^\x00-\x7F]/)); } - } - settings.lastOrder = sortFlat; -} -/** - * Get the data to sort a column, be it from cache, fresh (populating the - * cache), or from a sort formatter - * - * @param settings DataTables settings object - * @param colIdx Column index - */ -function sortData(settings, colIdx) { - // Custom sorting function - provided by the sort data type - var column = settings.columns[colIdx]; - var customSort = ext.order[column.orderDataType]; - var customData; - if (customSort) { - customData = customSort.call(settings.instance, settings, colIdx, columnIndexToVisible(settings, colIdx)); - } - // Use / populate cache - var row, cellData; - var formatter = ext.type.order[column.type + '-pre']; - var data = settings.data; - for (var rowIdx = 0; rowIdx < data.length; rowIdx++) { - // Sparse array - if (!data[rowIdx]) { - continue; + }, + order: { + asc: __diacriticSort, + desc: function (a, b) { + return __diacriticSort(a, b) * -1; } - row = data[rowIdx]; - if (row && !row.orderCache) { - row.orderCache = []; + }, + search: _filterString(false) +}); +register$1('html', { + detect: { + allOf: function (d) { + return (util.is.empty(d) || + (typeof d === 'string' && d.indexOf('<') !== -1)); + }, + oneOf: function (d) { + // At least one data point must contain a `<` + return (!util.is.empty(d) && + typeof d === 'string' && + d.indexOf('<') !== -1); + } + }, + order: { + pre: function (a) { + return util.is.empty(a) + ? '' + : a.replace + ? util.stripHtml(a).trim().toLowerCase() + : a + ''; + } + }, + search: _filterString(true) +}); +register$1('html-utf8', { + detect: { + allOf: function (d) { + return (util.is.empty(d) || + (typeof d === 'string' && d.indexOf('<') !== -1)); + }, + oneOf: function (d) { + // At least one data point must contain a `<` and a non-ASCII character + // eslint-disable-next-line compat/compat + return (navigator.languages && + !util.is.empty(d) && + typeof d === 'string' && + d.indexOf('<') !== -1 && + typeof d === 'string' && + !!d.match(/[^\x00-\x7F]/)); + } + }, + order: { + asc: __diacriticHtmlSort, + desc: function (a, b) { + return __diacriticHtmlSort(a, b) * -1; + } + }, + search: _filterString(true) +}); +register$1('date', { + className: 'dt-type-date', + detect: { + allOf: function (d) { + // V8 tries _very_ hard to make a string passed into `Date.parse()` + // valid, so we need to use a regex to restrict date formats. Use a + // plug-in for anything other than ISO8601 style strings + if (d && !(d instanceof Date) && !util.regex.reDate.test(d)) { + return null; + } + var parsed = Date.parse(d); + return (parsed !== null && !isNaN(parsed)) || util.is.empty(d); + }, + oneOf: function (d) { + // At least one entry must be a date or a string with a date + return (d instanceof Date || + (typeof d === 'string' && util.regex.reDate.test(d))); } - if (row && (!row.orderCache[colIdx] || customSort)) { - cellData = customSort - ? customData[rowIdx] // If there was a custom sort function, use data from there - : getCellData(settings, rowIdx, colIdx, 'sort'); - row.orderCache[colIdx] = formatter - ? formatter(cellData, settings) - : cellData; + }, + order: { + pre: function (d) { + var ts = Date.parse(d); + return isNaN(ts) ? -Infinity : ts; } } -} - -/** - * Alter the display settings to change the page - * - * @param settings DataTables settings object - * @param action Paging action to take: "first", "previous", "next" or "last" or - * page number to jump to (integer) - * @param redraw Automatically draw the update or not - * @returns true page has changed, false - no change - */ -function pageChange(settings, action, redraw) { - var start = settings.displayStart, len = settings.pageLength, records = recordsDisplay(settings); - if (records === 0 || len === -1) { - start = 0; - } - else if (typeof action === 'number') { - start = action * len; - if (start > records) { - start = 0; +}); +register$1('html-num-fmt', { + className: 'dt-type-numeric', + detect: { + allOf: function (d, settings) { + var decimal = settings.language.decimal; + return util.is.htmlNum(d, decimal, true, false); + }, + oneOf: function (d, settings) { + // At least one data point must contain a numeric value + var decimal = settings.language.decimal; + return util.is.htmlNum(d, decimal, true, false); } - } - else if (action == 'first') { - start = 0; - } - else if (action == 'previous') { - start = len >= 0 ? start - len : 0; - if (start < 0) { - start = 0; + }, + order: { + pre: function (d, s) { + var dp = s.language.decimal; + return __numericReplace(d, dp, util.regex.reHtml, util.regex.reFormattedNumeric); } - } - else if (action == 'next') { - if (start + len < records) { - start += len; + }, + search: _filterString(true) +}); +register$1('html-num', { + className: 'dt-type-numeric', + detect: { + allOf: function (d, settings) { + var decimal = settings.language.decimal; + return util.is.htmlNum(d, decimal, false, true); + }, + oneOf: function (d, settings) { + // At least one data point must contain a numeric value + var decimal = settings.language.decimal; + return util.is.htmlNum(d, decimal, false, false); + } + }, + order: { + pre: function (d, s) { + var dp = s.language.decimal; + return __numericReplace(d, dp, util.regex.reHtml); + } + }, + search: _filterString(true) +}); +register$1('num-fmt', { + className: 'dt-type-numeric', + detect: { + allOf: function (d, settings) { + var decimal = settings.language.decimal; + return util.is.num(d, decimal, true, true); + }, + oneOf: function (d, settings) { + // At least one data point must contain a numeric value + var decimal = settings.language.decimal; + return util.is.num(d, decimal, true, false); + } + }, + order: { + pre: function (d, s) { + var dp = s.language.decimal; + return __numericReplace(d, dp, util.regex.reFormattedNumeric); } } - else if (action == 'last') { - start = Math.floor((records - 1) / len) * len; - } - else if (action === 'ellipsis') { - return; - } - else { - log(settings, 0, 'Unknown paging action: ' + action, 5); - } - var changed = settings.displayStart !== start; - settings.displayStart = start; - callbackFire(settings, null, changed ? 'page' : 'page-nc', [settings]); - if (changed && redraw) { - draw(settings); +}); +register$1('num', { + className: 'dt-type-numeric', + detect: { + allOf: function (d, settings) { + var decimal = settings.language.decimal; + return util.is.num(d, decimal, false, true); + }, + oneOf: function (d, settings) { + // At least one data point must contain a numeric value + var decimal = settings.language.decimal; + return util.is.num(d, decimal, false, false); + } + }, + order: { + pre: function (d, s) { + var dp = s.language.decimal; + return __numericReplace(d, dp); + } } - return changed; -} +}); +/* + * Public helper functions. These aren't used internally by DataTables, or + * called by any of the options passed into DataTables, but they can be used + * externally by developers working with DataTables. They are helper functions + * to make working with DataTables a little bit easier. + */ /** - * State information for a table + * Common logic for moment, luxon or a date action. * - * @param settings DataTables settings object + * Happens after __mldObj, so don't need to call `resolveWindowsLibs` again */ -function saveState(settings) { - if (settings.loadingState) { - return; +function __mld(dtLib, momentFn, luxonFn, dateFn, arg1) { + if (__moment) { + return dtLib[momentFn](arg1); } - // Sort state saving uses [[idx, order]] structure. - var sorting = []; - sortResolve(settings, sorting, settings.order); - /* Store the interesting variables */ - var columns = settings.columns; - var state = { - columns: settings.columns.map(function (col, i) { - return { - name: col.name, - visible: col.visible, - search: Object.assign({}, settings.searches[i]) - }; - }), - length: settings.pageLength, - order: sorting.map(function (sort) { - // If a column name is available, use it - return columns[sort[0]] && columns[sort[0]].name - ? [columns[sort[0]].name, sort[1]] - : sort.slice(); - }), - search: Object.assign({}, settings.searches['*']), - searchGroups: Object.keys(settings.searches) - .filter(c => c.includes(',')) // Limit to only multi-column subsets - .map(c => Object.assign({}, settings.searches[c])), - start: settings.displayStart, - time: +new Date() - }; - settings.stateSaved = state; - callbackFire(settings, 'stateSaveParams', 'stateSaveParams', [ - settings, - state - ]); - if (settings.features.stateSave && !settings.destroying) { - settings.stateSaveCallback.call(settings.instance, settings, state); + else if (__luxon) { + return dtLib[luxonFn](arg1); } + return dateFn ? dtLib[dateFn](arg1) : dtLib; } +var __mlWarning = false; +var __luxon; +var __moment; /** - * Attempt to load a saved table state * - * @param settings dataTables settings object - * @param callback Callback to execute when the state has been loaded */ -function loadState(settings, callback) { - if (!settings.features.stateSave) { - callback(); - return; - } - var loaded = function (state, ignoreTime = false) { - implementState(settings, state, ignoreTime, callback); - }; - var state = settings.stateLoadCallback.call(settings.instance, settings, loaded); - if (state !== undefined) { - implementState(settings, state, false, callback); - } - // otherwise, wait for the loaded callback to be executed - return true; +function resolveWindowLibs() { + __luxon = util.external('luxon'); + __moment = util.external('moment'); } -function implementState(settings, s, ignoreTime, callback) { - var i, iLen; - var columns = settings.columns; - var currentNames = pluck(settings.columns, 'name'); - settings.loadingState = true; - // When StateRestore was introduced the state could now be implemented at - // any time Not just initialisation. To do this an api instance is required - // in some places - var api = settings.initDone ? new Api(settings) : null; - if (!ignoreTime) { - if (!s || !s.time) { - settings.loadingState = false; - callback(); - return; +function __mldObj(d, format, locale) { + var dt; + resolveWindowLibs(); + if (__moment) { + dt = __moment(d, format, locale, true); + if (!dt.isValid()) { + return null; } - // Reject old data - var duration = settings.stateDuration; - if (duration > 0 && s.time < +new Date() - duration * 1000) { - settings.loadingState = false; - callback(); - return; + } + else if (__luxon) { + dt = + format && typeof d === 'string' + ? __luxon.DateTime.fromFormat(d, format) + : __luxon.DateTime.fromISO(d); + if (!dt.isValid) { + return null; } + dt = dt.setLocale(locale); + } + else if (!format) { + // No format given, must be ISO + dt = new Date(d); } - // Allow custom and plug-in manipulation functions to alter the saved data - // set and cancelling of loading by returning false - var abStateLoad = callbackFire(settings, 'stateLoadParams', 'stateLoadParams', [settings, s]); - if (abStateLoad.indexOf(false) !== -1) { - settings.loadingState = false; - callback(); - return; + else { + if (!__mlWarning) { + alert('DataTables warning: Formatted date without Moment.js or Luxon - https://datatables.net/tn/17'); + } + __mlWarning = true; } - // Store the saved state so it might be accessed at any time - settings.stateLoaded = assignDeep({}, s); - // This is needed for ColReorder, which has to happen first to allow all - // the stored indexes to be usable. It is not publicly documented. - callbackFire(settings, null, 'stateLoadInit', [settings, s], true); - // Page Length - if (s.length !== undefined) { - // If already initialised just set the value directly so that the select - // element is also updated - if (api) { - api.page.len(s.length); + return dt; +} +// Wrapper for date, datetime and time which all operate the same way with the +// exception of the output string for auto locale support +function __mlHelper(localeString) { + return function (from, to, locale, def) { + // Luxon and Moment support + // Argument shifting + if (arguments.length === 0) { + locale = 'en'; + to = null; // means toLocaleString + from = null; // means iso8601 } - else { - settings.pageLength = s.length; + else if (arguments.length === 1) { + locale = 'en'; + to = from; + from = null; } - } - // Restore key features - if (s.start !== undefined) { - if (api === null) { - settings.displayStart = s.start; - settings.displayStartInit = s.start; + else if (arguments.length === 2) { + locale = to; + to = from; + from = null; } - else { - pageChange(settings, s.start / settings.pageLength); + var typeName = 'datetime' + (to ? '-' + to : ''); + // Add type detection and sorting specific to this date format - we need + // to be able to identify date type columns as such, rather than as + // numbers in extensions. Hence the need for this. + if (!store.order[typeName + '-pre']) { + register$1(typeName, { + detect: function (d) { + // The renderer will give the value to type detect as the + // type! + return d === typeName ? typeName : false; + }, + order: { + pre: function (d) { + // The renderer gives us Moment, Luxon or Date objects + // for the sorting, all of which have a `valueOf` which + // gives milliseconds epoch + return d.valueOf(); + } + } + }); } - } - // Order - if (s.order !== undefined) { - settings.order = []; - for (let i = 0; i < s.order.length; i++) { - let col = s.order[i]; - let set = [col[0], col[1]]; - // A column name was stored and should be used for restore - if (typeof col[0] === 'string') { - // Find the name from the current list of column names - let idx = currentNames.indexOf(col[0]); - if (idx < 0) { - // If the column was not found ignore it and continue - continue; + if (!store.className[typeName]) { + store.className[typeName] = 'dt-right'; + } + return function (d, type) { + // Allow for a default value + if (d === null || d === undefined) { + if (def === '--now') { + // We treat everything as UTC further down, so no changes + // are made, as such need to get the local date / time as if + // it were UTC + var local = new Date(); + d = new Date(Date.UTC(local.getFullYear(), local.getMonth(), local.getDate(), local.getHours(), local.getMinutes(), local.getSeconds())); + } + else { + d = ''; } - set[0] = idx; } - else if (set[0] >= columns.length) { - // If the column index is out of bounds ignore it and continue - continue; + if (type === 'type') { + // Typing uses the type name for fast matching + return typeName; + } + if (d === '') { + return type !== 'sort' + ? '' + : __mldObj('0000-01-01 00:00:00', null, locale); + } + // Shortcut. If `from` and `to` are the same, we are using the + // renderer to format for ordering, not display - its already in the + // display format. + if (to !== null && + from === to && + type !== 'sort' && + type !== 'type' && + !(d instanceof Date)) { + return d; + } + // Determine if there is a timezone. If there is, we want to reuse + // it for the output, so the timezone doesn't change between the + // input and output. + let options = {}; + let tzMatch = typeof d === 'string' ? d.match(util.regex.isoTimezone) : null; + if (tzMatch) { + options.timeZone = tzMatch[1] === 'Z' ? 'UTC' : tzMatch[1]; + } + // Get a Date object (Luxon, moment or Date) + var dt = __mldObj(d, from, locale); + if (dt === null) { + return d; + } + if (type === 'sort') { + return dt; + } + var formatted = to === null + ? __mld(dt, 'toDate', 'toJSDate', '')[localeString](navigator.language, options) + : __mld(dt, 'format', 'toFormat', 'toISOString', to); + // XSS protection + return type === 'display' ? util.escapeHtml(formatted) : formatted; + }; + }; +} +// Based on locale, determine standard number formatting +// Fallback for legacy browsers is US English +var __thousands = ','; +var __decimal = '.'; +if (window.Intl !== undefined) { + try { + var num = new Intl.NumberFormat().formatToParts(100000.1); + for (var i = 0; i < num.length; i++) { + if (num[i].type === 'group') { + __thousands = num[i].value; + } + else if (num[i].type === 'decimal') { + __decimal = num[i].value; } - settings.order.push(set); } } - // Search - if (s.search !== undefined) { - Object.assign(settings.searches['*'], s.search); + catch (e) { + // noop } - if (s.searchGroups) { - s.searchGroups.forEach(group => { - if (group.columns) { - let index = group.columns.join(','); - settings.searches[index] = create$2(group); - } - }); +} +/** + * Register a date / time format for DataTables to use. + * + * @param format The date / time format to detect data in. Please refer to the + * Moment.js or Luxon document for the full list of tokens, depending on which + * of the two libraries you are using. + * @param locale The locale to pass to Moment.js / Luxon. + */ +function datetime(format, locale) { + var typeName = 'datetime-' + format; + if (!locale) { + locale = 'en'; } - // Columns - if (s.columns) { - var set = s.columns; - var incoming = pluck(s.columns, 'name'); - // Check if it is a 2.2 style state object with a `name` property for - // the columns, and if the name was defined. If so, then create a new - // array that will map the state object given, to the current columns - // (don't bother if they are already matching tho). - if (incoming.join('').length && - incoming.join('') !== currentNames.join('')) { - set = []; - // For each column, try to find the name in the incoming array - for (i = 0; i < currentNames.length; i++) { - if (currentNames[i] != '') { - var idx = incoming.indexOf(currentNames[i]); - if (idx >= 0) { - set.push(s.columns[idx]); - } - else { - // No matching column name in the state's columns, so - // this might be a new column and thus can't have a - // state already. - set.push({}); - } - } - else { - // If no name, but other columns did have a name, then there - // is no knowing where this one came from originally so it - // can't be restored. - set.push({}); - } - } - } - // If the number of columns to restore is different from current, then - // all bets are off. - if (set.length === columns.length) { - for (i = 0, iLen = set.length; i < iLen; i++) { - var col = set[i]; - // Visibility - if (col.visible !== undefined) { - // If the api is defined, the table has been initialised so - // we need to use it rather than internal settings - if (api) { - // Don't redraw the columns on every iteration of this - // loop, we will do this at the end instead - api.column(i).visible(col.visible, false); - } - else { - columns[i].visible = col.visible; - } - } - // Search - if (col.search !== undefined) { - Object.assign(settings.searches[i], col.search); - // If out of order due to a change in order from named - // columns we need to make sure the index is correct - settings.searches[i].columns = [i]; - } - } - // If the api is defined then we need to adjust the columns once the - // visibility has been changed - if (api) { - api.one('draw', function () { - api.columns.adjust(); - }); + if (!store.order[typeName]) { + register$1(typeName, { + detect: function (d) { + var dt = __mldObj(d, format, locale); + return d === '' || dt ? typeName : false; + }, + order: { + pre: function (d) { + return __mldObj(d, format, locale) || 0; + } } - } + }); + } + if (!store.className[typeName]) { + store.className[typeName] = 'dt-right'; } - settings.loadingState = false; - callbackFire(settings, 'stateLoaded', 'stateLoaded', [settings, s]); - callback(); } - /** - * Draw the table for the first time, adding all required features - * - * @param settings DataTables settings object + * Helpers for `columns.render`. */ -function initialise(settings) { - var i; - var init = settings.init; - var deferLoading = settings.deferLoading; - var dataSrc = dataSource(settings); - // Ensure that the table data is fully initialised - if (!settings.initialised) { - setTimeout(function () { - initialise(settings); - }, 200); - return; - } - // Build the header / footer for the table - buildHead(settings, 'header'); - buildHead(settings, 'footer'); - // Load the table's state (if needed) and then render around it and draw - loadState(settings, function () { - // Then draw the header / footer - drawHead(settings, settings.header); - drawHead(settings, settings.footer); - // Cache the paging start point, as the first redraw will reset it - var iAjaxStart = settings.displayStartInit; - // Local data load - // Check if there is data passing into the constructor - if (init && init.data) { - for (i = 0; i < init.data.length; i++) { - addData(settings, init.data[i]); - } +var helpers = { + date: __mlHelper('toLocaleDateString'), + datetime: __mlHelper('toLocaleString'), + time: __mlHelper('toLocaleTimeString'), + number: function (thousands, decimal, precision, prefix, postfix) { + // Auto locale detection + if (thousands === null || thousands === undefined) { + thousands = __thousands; } - else if (deferLoading || dataSrc == 'dom') { - // Grab the data from the page - addTr(settings, Dom.s(settings.tbody).children('tr')); + if (decimal === null || decimal === undefined) { + decimal = __decimal; } - // Filter not yet applied - copy the display master - settings.display = settings.displayMaster.slice(); - // Enable features - createLayout(settings); - sortInit(settings); - colGroup(settings); - /* Okay to show that something is going on now */ - processingDisplay(settings, true); - callbackFire(settings, null, 'preInit', [settings], true); - // If there is default sorting required - let's do it. The sort function - // will do the drawing for us. Otherwise we draw the table regardless of - // the Ajax source - this allows the table to look initialised for Ajax - // sourcing data (show 'loading' message possibly) - reDraw(settings); - // Server-side processing init complete is done by _fnAjaxUpdateDraw - if (dataSrc != 'ssp' || deferLoading) { - // if there is an ajax source load the data - if (dataSrc == 'ajax') { - buildAjax(settings, {}, function (json) { - var aData = ajaxDataSrc(settings, json, false); - // Got the data - add it to the table - for (i = 0; i < aData.length; i++) { - addData(settings, aData[i]); - } - // Reset the init display for cookie saving. We've already - // done a filter, and therefore cleared it before. So we - // need to make it appear 'fresh' - settings.displayStartInit = iAjaxStart; - reDraw(settings); - processingDisplay(settings, false); - initComplete(settings); - }); - } - else { - initComplete(settings); - processingDisplay(settings, false); + return { + display: function (d) { + if (typeof d !== 'number' && typeof d !== 'string') { + return d; + } + if (d === '' || d === null) { + return d; + } + var flo = typeof d === 'number' ? d : parseFloat(d); + var negative = flo < 0 ? '-' : ''; + var abs = Math.abs(flo); + // Scientific notation for large and small numbers + if (abs >= 100000000000 || (abs < 0.0001 && abs !== 0)) { + var exp = flo.toExponential(precision).split(/e\+?/); + return exp[0] + ' x 10' + exp[1] + ''; + } + // If NaN then there isn't much formatting that we can do - just + // return immediately, escaping any HTML (this was supposed to + // be a number after all) + if (isNaN(flo)) { + return util.escapeHtml(d); + } + flo = flo.toFixed(precision); + var absPart = Math.abs(flo); + var intPart = Math.abs(parseInt(flo, 10)); + var floatPart = precision + ? decimal + + (absPart - intPart).toFixed(precision).substring(2) + : ''; + // If zero, then can't have a negative prefix + if (intPart === 0 && parseFloat(floatPart) === 0) { + negative = ''; + } + return (negative + + (prefix || '') + + intPart + .toString() + .replace(/\B(?=(\d{3})+(?!\d))/g, thousands) + + floatPart + + (postfix || '')); } - } - }); -} + }; + }, + text: function () { + return { + display: util.escapeHtml, + filter: util.escapeHtml + }; + } +}; + /** - * Draw the table for the first time, adding all required features + * Column options that can be given to DataTables at initialisation time. + */ +const defaults$2 = { + ariaTitle: '', + cellType: 'td', + className: '', + contentPadding: '', + createdCell: null, + data: null, + defaultContent: null, + footer: null, + name: '', + orderable: true, + orderData: null, + orderDataType: 'std', + orderSequence: ['asc', 'desc', ''], + render: null, + search: null, + searchable: true, + title: null, + type: null, + visible: true, + width: null +}; + +/** + * Internal settings object used for individual columns. Instances are held in + * the setting object's `columns` array and contains all the information that + * DataTables needs about each individual column. * - * @param settings DataTables settings object + * Note that this object is related to the column defaults but this one is the + * internal data store for DataTables's cache of columns. It should NOT be + * manipulated outside of DataTables. Any configuration should be done through + * the initialisation options. */ -function initComplete(settings) { - if (settings.initDone) { - return; - } - var args = [settings, settings.json]; - settings.initDone = true; - // If the footer element is empty after initialisation, then remove it - let tfoot = Dom.s(settings.tfoot); - if (tfoot.children().count() === 0) { - tfoot.remove(); +class Settings { + constructor() { + /** + * Flag to indicate if HTML5 data attributes should be used as the data + * source for filtering or sorting. True is either are. + */ + this.attrSrc = false; + this.ariaTitle = ''; + /** + * The class to apply to all cells in the table's `tbody`` for the column + */ + this.className = null; + /** + * When DataTables calculates the column widths to assign to each column, it + * finds the longest string in each column and then constructs a temporary + * table and reads the widths from that. The problem with this is that "mmm" + * is much wider then "iiii", but the latter is a longer string - thus the + * calculation can go wrong (doing it properly and putting it into an DOM + * object and measuring that is horribly(!) slow). Thus as a "work around" + * we provide this option. It will append its value to the text that is + * found to be the longest string for the column - i.e. padding. + */ + this.contentPadding = null; + /** + * Property to read the value for the cells in the column from the data + * source array / object. If null, then the default content is used, if a + * function is given then the return from the function is used. + */ + this.data = null; + /** + * Allows a default value to be given for a column's data, and will be used + * whenever a null data source is encountered (this can be because mData is + * set to null, or because the data source itself is null). + */ + this.defaultContent = null; + /** + * Name for the column, allowing reference to the column by name as well as + * by index (needs a lookup to work by name). + */ + this.name = null; + /** + * A list of the columns that sorting should occur on when this column is + * sorted. That this property is an array allows multi-column sorting to be + * defined for a column (for example first name / last name columns would + * benefit from this). The values are integers pointing to the columns to be + * sorted on (typically it will be a single integer pointing at itself, but + * that doesn't need to be the case). + */ + this.orderData = []; + /** + * Custom sorting data type - defines which of the available plug-ins in + * afnSortData the custom sorting will use - if any is defined. + */ + this.orderDataType = 'std'; + /** + * Class to be applied to the header element when sorting on this column + */ + this.orderingClass = null; + /** + * Define the sorting directions that are applied to the column, in sequence + * as the column is repeatedly sorted upon - i.e. the first value is used as + * the sorting direction when the column if first sorted (clicked on). Sort + * it again (click again) and it will move on to the next index. Repeat + * until loop. + */ + this.orderSequence = []; + /** + * Partner property to mData which is used (only when defined) to get the + * data - i.e. it is basically the same as mData, but without the 'set' + * option, and also the data fed to it is the result from mData. This is the + * rendering method to match the data method of mData. + */ + this.render = null; + /** + * Title of the column - what is seen in the TH element (nTh). + */ + this.title = null; + /** + * Store for manual type assignment using the `column.type` option. This + * is held in store so we can manipulate the column's `type` property. + */ + this.typeManual = null; + /** Cached longest strings from a column */ + this.wideStrings = null; + /** + * Width of the column + */ + this.width = null; + /** + * Width of the column when it was first "encountered" + */ + this.widthOrig = null; } - // Table is fully set up and we have data, so calculate the - // column widths - adjustColumnSizing(settings); - callbackFire(settings, null, 'plugin-init', args, true); - callbackFire(settings, 'init', 'init', args, true); } +const browser = { + barWidth: -1, + scrollbarLeft: false +}; +const hungarianToCamelRe = /^(a|aa|ai|ao|as|b|fn|i|m|o|s)([A-Z])([a-z].*$)/; /** - * Create an Ajax call based on the table's settings, taking into account that - * parameters can have multiple forms, and backwards compatibility. - * - * @param settings DataTables settings object - * @param data Data to send to the server, required by DataTables - may be - * augmented by developer callbacks - * @param fn Callback function to run when data is obtained + * Take an object which has hungarian notation parameters and convert them to + * camelCase style. This is to allow compatibility with DataTables 1.9 and + * earlier which only used hungarian notation, and also with DataTables 1.10 - 2 + * which allowed it to be used. */ -function buildAjax(settings, data, fn) { - var ajaxData; - var ajaxConfig = settings.ajax; - var instance = settings.instance; - var callback = function (json) { - var status = settings.jqXHR ? settings.jqXHR.status : null; - if (json === null || (typeof status === 'number' && status == 204)) { - json = {}; - ajaxDataSrc(settings, json, []); - } - var error = json.error || json.sError; - if (error) { - log(settings, 0, error); - } - // Microsoft often wrap JSON as a string in another JSON object Let's - // handle that automatically - if (json.d && typeof json.d === 'string') { - try { - json = JSON.parse(json.d); - } - catch (e) { - // noop - } - } - settings.json = json; - callbackFire(settings, null, 'xhr', [settings, json, settings.jqXHR], true); - fn(json); - }; - if (util.is.plainObject(ajaxConfig) && ajaxConfig.data) { - ajaxData = ajaxConfig.data; - var newData = typeof ajaxData === 'function' - ? ajaxData(data, settings) // fn can manipulate data or return - : ajaxData; // an object or array to merge - // If the function returned something, use that alone - data = - typeof ajaxData === 'function' && newData - ? newData - : util.object.assignDeep(data, newData); - // Remove the data property as we've resolved it already and don't want - // jQuery to do it again (it is restored at the end of the function) - delete ajaxConfig.data; +function hungarianToCamel(user) { + if (!user) { + return user; } - var baseAjax = { - url: typeof ajaxConfig === 'string' ? ajaxConfig : '', - data: data, - success: callback, - dataType: 'json', - cache: false, - type: settings.serverMethod, - error: function (xhr, error) { - var ret = callbackFire(settings, null, 'xhr', [settings, null, settings.jqXHR], true); - if (ret.indexOf(false) === -1) { - if (error == 'parsererror') { - log(settings, 0, 'Invalid JSON response', 1); - } - else if (xhr.readyState === 4) { - log(settings, 0, 'Ajax error', 7); - } - } - processingDisplay(settings, false); + let userKeys = Object.keys(user); + let userAny = user; + for (let i = 0; i < userKeys.length; i++) { + let userKey = userKeys[i]; + let match = userKey.match(hungarianToCamelRe); + // Is the key in hungarian notation? + if (match) { + // If so map it down + user[match[2].toLowerCase() + match[3]] = userAny[userKey]; + } + // Recurse down through the object + if (util.is.plainObject(userAny[userKey])) { + hungarianToCamel(userAny[userKey]); } - }; - // If `ajax` option is an object, extend and override our default base - if (util.is.plainObject(ajaxConfig)) { - util.object.assign(baseAjax, ajaxConfig); - } - // Store the data submitted for the API - settings.ajaxData = data; - // Allow plug-ins and external processes to modify the data - callbackFire(settings, null, 'preXhr', [settings, data, baseAjax], true); - if (typeof ajaxConfig === 'function') { - // Is a function - let the caller define what needs to be done - settings.jqXHR = ajaxConfig.call(instance, data, callback, settings); - } - else if (ajaxConfig && - typeof ajaxConfig !== 'string' && - ajaxConfig.url === '') { - // No url, so don't load any data. Just apply an empty data array - // to the object for the callback. - var empty = {}; - ajaxDataSrc(settings, empty, []); - callback(empty); - } - else { - // Object to extend the base settings - settings.jqXHR = util.ajax(baseAjax); - } - // Restore for next time around - if (ajaxData) { - ajaxConfig.data = ajaxData; } + return user; } /** - * Update the table using an Ajax call + * Map one parameter onto another * - * @param settings DataTables settings object - * @returns Block the table drawing or not + * @param o Object to map + * @param newKey The new parameter name + * @param oldKey The old parameter name */ -function ajaxUpdate(settings) { - settings.drawCount++; - processingDisplay(settings, true); - buildAjax(settings, ajaxParameters(settings), function (json) { - ajaxUpdateDraw(settings, json); - }); -} -function functionOrValue(val) { - return typeof val === 'function' ? 'function' : val.toString(); +function compatMap(o, newKey, oldKey) { + if (o[oldKey] !== undefined) { + o[newKey] = o[oldKey]; + } } /** - * Build up the parameters in an object needed for a server-side processing - * request. + * Provide backwards compatibility for the main DT options. Note that the new + * options are mapped onto the old parameters, so this is an external interface + * change only. * - * @param settings DataTables settings object - * @returns Block the table drawing or not + * @param init Object to map + * @param defaults Indicate if the object is the defaults object or not */ -function ajaxParameters(settings) { - var columns = settings.columns, features = settings.features, searches = settings.searches, searchesFixed = settings.searchesFixed, colData = function (idx, prop) { - return typeof columns[idx][prop] === 'function' - ? 'function' - : columns[idx][prop]; - }; - return { - draw: settings.drawCount, - columns: columns.map(function (column, i) { - return { - data: colData(i, 'data'), - name: column.name, - searchable: column.searchable, - orderable: column.orderable, - search: { - value: searches[i] - ? functionOrValue(searches[i].search) - : '', - regex: searches[i] ? searches[i].regex : false, - fixed: searchesFixed[i] - ? Object.keys(searchesFixed[i]).map(name => ({ - name: name, - term: functionOrValue(searchesFixed[i][name].search) - })) - : [] - } - }; - }), - order: sortFlatten(settings).map(function (val) { - return { - column: val.col, - dir: val.dir, - name: colData(val.col, 'name') - }; - }), - start: settings.displayStart, - length: features.paging ? settings.pageLength : -1, - search: { - value: functionOrValue(searches['*'].search), - regex: searches['*'].regex, - fixed: Object.keys(settings.searchesFixed['*']).map(name => ({ - name: name, - term: functionOrValue(settings.searchesFixed['*'][name].search) - })), - groups: Object.keys(settings.searches) - .filter(c => c.includes(',')) // Limit to only multi-column subsets - .map(c => ({ - columns: settings.searches[c].columns || [], - term: functionOrValue(settings.searches[c].search) - })), - groupsFixed: Object.keys(settings.searchesFixed) - .filter(c => c.includes(',')) // Limit to only multi-column subsets - .map(c => { - let searches = settings.searchesFixed[c]; - return Object.keys(searches).map(n => ({ - columns: searches[n].columns || [], - name: n, - term: functionOrValue(searches[n].search) - })); - }) - .flat() +function compatOpts(init, defaults = false) { + // Convert any old style parameters to camelCase + hungarianToCamel(init); + // Map old parameter names to new + compatMap(init, 'ordering', 'sort'); + compatMap(init, 'orderMulti', 'sortMulti'); + compatMap(init, 'orderClasses', 'sortClasses'); + compatMap(init, 'orderCellsTop', 'sortCellsTop'); + compatMap(init, 'order', 'sorting'); + compatMap(init, 'orderFixed', 'sortingFixed'); + compatMap(init, 'paging', 'paginate'); + compatMap(init, 'pagingType', 'paginationType'); + compatMap(init, 'pageLength', 'displayLength'); + compatMap(init, 'searching', 'filter'); + compatMap(init, 'stateDuration', 'cookieDuration'); + // Boolean initialisation of x-scrolling + if (typeof init.scrollX === 'boolean') { + init.scrollX = init.scrollX ? '100%' : ''; + } + // Objects for ordering + if (typeof init.ordering === 'object') { + init.orderIndicators = + init.ordering.indicators !== undefined + ? init.ordering.indicators + : true; + init.orderHandler = + init.ordering.handler !== undefined ? init.ordering.handler : true; + if (!defaults) { + init.ordering = true; } - }; + } + else if (init.ordering === false) { + init.orderIndicators = false; + init.orderHandler = false; + } + else if (init.ordering === true) { + init.orderIndicators = true; + init.orderHandler = true; + } + // Which cells are the title cells? + if (typeof init.orderCellsTop === 'boolean') { + init.titleRow = init.orderCellsTop; + } + // Column search objects are in an array, so it needs to be converted + // element by element + var searchCols = init.searchCols; + if (searchCols) { + for (var i = 0, iLen = searchCols.length; i < iLen; i++) { + if (searchCols[i]) { + hungarianToCamel(searchCols[i]); + } + } + } + // Enable search delay if server-side processing is enabled + if (init.serverSide && !init.searchDelay) { + init.searchDelay = 400; + } + // Language + if (init.language && init.language.url && !init.language.ajax) { + init.language.ajax = init.language.url; + } } /** - * Data the data from the server (nuking the old) and redraw the table + * Provide backwards compatibility for column options. Note that the new options + * are mapped onto the old parameters, so this is an external interface change + * only. * - * @param settings DataTables settings object - * @param json json data return from the server. + * @param init Object to map */ -function ajaxUpdateDraw(settings, json) { - var data = ajaxDataSrc(settings, json, false); - var drawUnique = ajaxDataSrcParam(settings, 'draw', json); - var recordsTotal = ajaxDataSrcParam(settings, 'recordsTotal', json); - var recordsFiltered = ajaxDataSrcParam(settings, 'recordsFiltered', json); - var existingTypes = settings.columns.map(c => c.type).join(','); - if (drawUnique !== undefined) { - // Protect against out of sequence returns - if (drawUnique * 1 < settings.drawCount) { - return; - } - settings.drawCount = drawUnique * 1; +function compatCols(init) { + // Convert any old style parameters to camelCase + hungarianToCamel(init); + // typeof columnDefaults + compatMap(init, 'orderable', 'sortable'); + compatMap(init, 'orderData', 'dataSort'); + compatMap(init, 'orderSequence', 'sorting'); + compatMap(init, 'orderDataType', 'sortDataType'); + compatMap(init, 'className', 'class'); + // orderData can be given as an integer + var dataSort = init.aDataSort; + var orderData = init.orderData; + if (typeof dataSort === 'number') { + init.orderData = [dataSort]; } - // No data in returned object, so rather than an array, we show an empty - // table - if (!data) { - data = []; + if (typeof orderData === 'number') { + init.orderData = [orderData]; } - clearTable(settings); - settings.recordsTotal = parseInt(recordsTotal, 10); - settings.recordsDisplay = parseInt(recordsFiltered, 10); - for (var i = 0, iLen = data.length; i < iLen; i++) { - addData(settings, data[i]); + // Backwards compatibility for mDataProp from 1.9- + if (init.dataProp !== undefined && !init.data) { + init.data = init.dataProp; } - settings.display = settings.displayMaster.slice(); - columnTypes(settings, existingTypes); - draw(settings, true); - initComplete(settings); - processingDisplay(settings, false); } /** - * Get the data from the JSON data source to use for drawing a table. + * Browser feature detection for capabilities, quirks * - * @param settings DataTables settings object - * @param json Data source object / array from the server - * @param write Array or object to write the data to - * @return Array of data to use + * @param ctx DataTables settings object */ -function ajaxDataSrc(settings, json, write) { - var dataProp = 'data'; - if (util.is.plainObject(settings.ajax) && - settings.ajax.dataSrc !== undefined) { - // Could in inside a `dataSrc` object, or not! - var dataSrc = settings.ajax.dataSrc; - // string, function and object are valid types - if (typeof dataSrc === 'string' || typeof dataSrc === 'function') { - dataProp = dataSrc; - } - else if (dataSrc.data !== undefined) { - dataProp = dataSrc.data; - } - } - if (!write) { - if (dataProp === 'data') { - // If the default, then we still want to support the old style, and - // safely ignore it if possible - return json.aaData || json[dataProp]; - } - return dataProp !== '' ? util.get(dataProp)(json) : json; +function browserDetect(ctx) { + // We don't need to do this every time DataTables is constructed, the values + // calculated are specific to the browser and OS configuration which we + // don't expect to change between initialisations + if (browser.barWidth === -1) { + // Scrolling feature / quirks detection + var n = Dom + .c('div') + .css({ + position: 'fixed', + top: '0', + left: -1 * window.pageXOffset + 'px', // allow for scrolling + height: '1px', + width: '1px', + overflow: 'hidden' + }) + .append(Dom + .c('div') + .css({ + position: 'absolute', + top: '1px', + left: '1px', + width: '100px', + overflow: 'scroll' + }) + .append(Dom.c('div').css({ + width: '100%', + height: '10px' + }))) + .appendTo('body'); + var outer = n.children(); + var inner = outer.children(); + browser.barWidth = outer.get(0).offsetWidth - outer.get(0).clientWidth; + browser.scrollbarLeft = Math.round(inner.offset().left) !== 1; + n.remove(); } - // set - util.set(dataProp)(json, write); + Object.assign(ctx.browser, browser); + ctx.scroll.barWidth = browser.barWidth; } + /** - * Very similar to ajaxDataSrc, but for the other SSP properties + * Add a column to the list used for the table with default values * * @param settings DataTables settings object - * @param param Target parameter - * @param json JSON data - * @returns Resolved value */ -function ajaxDataSrcParam(settings, param, json) { - var dataSrc = util.is.plainObject(settings.ajax) - ? settings.ajax.dataSrc // TODO - : null; - if (dataSrc && dataSrc[param]) { - // Get from custom location - return util.data.get(dataSrc[param])(json); +function addColumn(settings) { + // Add column to aoColumns array + let columnIdx = settings.columns.length; + let column = util.object.assign({}, new Settings(), defaults$2, { + orderData: defaults$2.orderData + ? defaults$2.orderData + : [columnIdx], + data: defaults$2.data ? defaults$2.data : columnIdx, + idx: columnIdx, + searchFixed: {}, + colEl: Dom + .c('col') + .attr('data-dt-column', columnIdx) + }); + settings.columns.push(column); + // Legacy support for `searchCols` property. If set, and there is a value + // for this column, then it should be applied to the search. The new, column + // specific `search` option is applied in `columnOptions`, but we always + // want the search object for the column to exist. + let searchCols = settings.searchCols; + settings.searches[columnIdx] = create$1(searchCols[columnIdx] + ? hungarianToCamel(searchCols[columnIdx]) + : {}); + settings.searches[columnIdx].columns = [columnIdx]; +} +/** + * Apply options for a column + * + * @param settings DataTables settings object + * @param colIdx column index to consider + * @param options Column configuration options + */ +function columnOptions(settings, colIdx, options) { + var column = settings.columns[colIdx]; + /* User specified column options */ + if (options !== undefined && options !== null) { + // Backwards compatibility + compatCols(options); + if (options.type) { + column.typeManual = options.type; + } + // `class` is a reserved word in JavaScript, so we need to provide + // the ability to use a valid name for the camel case input + if (options.className && !options.className) { + options.className = options.className; + } + var origClass = column.className; + util.object.assign(column, options); + map(column, options, 'width', 'widthOrig'); + // Merge class from previously defined classes with this one, rather + // than just overwriting it in the extend above + if (origClass !== column.className) { + column.className = origClass + ' ' + column.className; + } + map(column, options, 'orderData'); + // Search term specifically for this column + if (options.search) { + util.object.assign(settings.searches[colIdx], options.search); + } } - // else - Default behaviour - var old = ''; - // Legacy support - if (param === 'draw') { - old = 'sEcho'; + /* Cache the data get and set functions for speed */ + var dataSrc = column.data; + var dataFn = util.get(dataSrc); + // The `render` option can be given as an array to access the helper + // rendering methods. The first element is the rendering method to use, the + // rest are the parameters to pass + if (column.render && Array.isArray(column.render)) { + var copy = column.render.slice(); + var name = copy.shift(); + column.render = helpers[name].apply(window, copy); } - else if (param === 'recordsTotal') { - old = 'iTotalRecords'; + column.renderer = column.render ? util.get(column.render) : null; + var attrTest = function (src) { + return typeof src === 'string' && src.indexOf('@') !== -1; + }; + column.attrSrc = + !!dataSrc && + util.is.plainObject(dataSrc) && + (attrTest(dataSrc.sort) || + attrTest(dataSrc.type) || + attrTest(dataSrc.filter)); + column.setter = null; + column.dataGet = function (rowData, type, meta) { + var innerData = dataFn(rowData, type, undefined, meta); + return column.renderer && type + ? column.renderer(innerData, type, rowData, meta) + : innerData; + }; + column.dataSet = function (rowData, val, meta) { + return util.set(dataSrc)(rowData, val, meta); + }; + // Indicate if DataTables should read DOM data as an object or array + // Used in _fnGetRowElements + if (typeof dataSrc !== 'number' && !column._isArrayHost) { + settings.rowReadObject = true; } - else if (param === 'recordsFiltered') { - old = 'iTotalDisplayRecords'; + // Feature sorting overrides column specific when off + if (!settings.features.ordering) { + column.orderable = false; } - return json[old] !== undefined ? json[old] : json[param]; } - -const __filter_div = Dom.c('div').get(0); -const __filter_div_textContent = __filter_div.textContent !== undefined; /** - * Filter the table using both the global filter and column based filtering + * Adjust the table column widths for new data. Note: you would probably want to + * do a redraw after calling this function! * * @param settings DataTables settings object */ -function filterComplete(settings) { - settings.columns; - // In server-side processing all filtering is done by the server, so no - // point hanging around here - if (dataSource(settings) != 'ssp') { - // Check if any of the rows were invalidated - filterData(settings); - // Start from the full data set - settings.display = settings.displayMaster.slice(); - // Column set filters first - util.object.each(settings.searches, (key, s) => { - filter(settings.display, settings, s.search, s); - }); - // Fixed (named) filters next - util.object.each(settings.searchesFixed, function (columns) { - util.object.each(settings.searchesFixed[columns], function (name, s) { - filter(settings.display, settings, s.search, s); - }); - }); - // And finally legacy global filtering - filterCustom(settings); +function adjustColumnSizing(settings) { + calculateColumnWidths(settings); + columnSizes(settings); + let scroll = settings.scroll; + if (scroll.y !== '' || scroll.x !== '') { + scrollDraw(settings); } - // Tell the draw function we have been filtering - settings.wasFiltered = true; - callbackFire(settings, null, 'search', [settings]); + callbackFire(settings, null, 'column-sizing', [settings]); } /** - * Apply custom filtering functions - * - * This is legacy now that we have named functions, but it is widely used - * from 1.x, so it is not yet deprecated. + * Apply column sizes * * @param settings DataTables settings object */ -function filterCustom(settings) { - let filters = ext.search; - let displayRows = settings.display; - let row, rowIdx; - for (let i = 0, iLen = filters.length; i < iLen; i++) { - let rows = []; - // Loop over each row and see if it should be included - for (let j = 0, jen = displayRows.length; j < jen; j++) { - rowIdx = displayRows[j]; - row = settings.data[rowIdx]; - if (row && - filters[i](settings, row.searchCellCache, rowIdx, row.data, j)) { - rows.push(rowIdx); +function columnSizes(settings) { + let cols = settings.columns; + for (let i = 0; i < cols.length; i++) { + let width = columnsSumWidth(settings, [i], false); + if (width) { + cols[i].colEl.css('width', width); + if (settings.scroll.x) { + cols[i].colEl.css('min-width', width); } } - // So the array reference doesn't break set the results into the - // existing array - displayRows.length = 0; - arrayApply(displayRows, rows); } } /** - * Filter the data table based on user input and draw the table + * Convert the index of a visible column to the index in the data array (take + * account of hidden columns) * - * @param searchRows - * @param settings - * @param input - * @param options - * @returns - */ -function filter(searchRows, settings, input, options) { - if (input === '') { - return; - } - let i = 0; - let matched = []; - // Search term can be a function, regex or string - if a string we apply our - // smart filtering regex (assuming the options require that) - let searchFunc = typeof input === 'function' ? input : null; - let rpSearch = input instanceof RegExp - ? input - : searchFunc - ? null - : filterCreateSearch(input, options); - let columns = options.columns - ? options.columns - : util.array.range(settings.columns.length); - // Then for each row, does the test pass. If not, lop the row from the array - for (i = 0; i < searchRows.length; i++) { - let row = settings.data[searchRows[i]]; - if (row) { - // Get the data array based on the columns to include in the search - let data = util.array.selectiveJoin(row.searchCellCache, columns); - // Run the search action - if ((searchFunc && - searchFunc(data, row.data, searchRows[i], columns.length === 1 ? columns[0] : columns // compat - )) || - (rpSearch && typeof data === 'string' && rpSearch.test(data))) { - matched.push(searchRows[i]); - } - } - } - // Mutate the searchRows array - searchRows.length = matched.length; - for (i = 0; i < matched.length; i++) { - searchRows[i] = matched[i]; - } -} -/** - * Build a regular expression object suitable for searching a table - */ -function filterCreateSearch(searchIn, inOpts) { - let not = []; - let options = Object.assign({}, { - boundary: false, - caseInsensitive: true, - exact: false, - regex: false, - smart: true - }, inOpts); - let search = typeof searchIn !== 'string' ? searchIn.toString() : searchIn; - // Remove diacritics if normalize is set up to do so - search = util.diacritics(search); - if (options.exact) { - return new RegExp('^' + util.escapeRegex(search) + '$', options.caseInsensitive ? 'i' : ''); - } - search = options.regex ? search : util.escapeRegex(search); - if (options.smart) { - /* For smart filtering we want to allow the search to work regardless of - * word order. We also want double quoted text to be preserved, so word - * order is important - a la google. And a negative look around for - * finding rows which don't contain a given string. - * - * So this is the sort of thing we want to generate: - * - * ^(?=.*?\bone\b)(?=.*?\btwo three\b)(?=.*?\bfour\b).*$ - */ - let parts = search.match(/!?["\u201C][^"\u201D]+["\u201D]|[^ ]+/g) || [ - '' - ]; - let a = parts.map(function (word) { - let negative = false; - let m; - // Determine if it is a "does not include" - if (word.charAt(0) === '!') { - negative = true; - word = word.substring(1); - } - // Strip the quotes from around matched phrases - if (word.charAt(0) === '"') { - m = word.match(/^"(.*)"$/); - word = m ? m[1] : word; - } - else if (word.charAt(0) === '\u201C') { - // Smart quote match (iPhone users) - m = word.match(/^\u201C(.*)\u201D$/); - word = m ? m[1] : word; - } - // For our "not" case, we need to modify the string that is - // allowed to match at the end of the expression. - if (negative) { - if (word.length > 1) { - not.push('(?!' + word + ')'); - } - word = ''; - } - return word.replace(/"/g, ''); - }); - let match = not.length ? not.join('') : ''; - let boundary = options.boundary ? '\\b' : ''; - search = - '^(?=.*?' + - boundary + - a.join(')(?=.*?' + boundary) + - ')(' + - match + - '.)*$'; - } - return new RegExp(search, options.caseInsensitive ? 'i' : ''); -} -// Update the filtering data for each row if needed (by invalidation or first -// run) -function filterData(settings) { - let columns = settings.columns; - let data = settings.data; - let column; - let j, jen, cellData, row; - let wasInvalidated = false; - for (let rowIdx = 0; rowIdx < data.length; rowIdx++) { - if (!data[rowIdx]) { - continue; - } - row = data[rowIdx]; - if (row && !row.searchCellCache) { - const rowFilterData = []; - for (j = 0, jen = columns.length; j < jen; j++) { - column = columns[j]; - if (column.searchable) { - cellData = getCellData(settings, rowIdx, j, 'filter'); - // Search in DataTables is string based - if (cellData === null) { - cellData = ''; - } - if (typeof cellData !== 'string' && cellData.toString) { - cellData = cellData.toString(); - } - } - else { - cellData = ''; - } - // If it looks like there is an HTML entity in the string, - // attempt to decode it so sorting works as expected. Note that - // we could use a single line of jQuery to do this, but the DOM - // method used here is much faster - // https://jsperf.com/html-decode - if (cellData.indexOf && cellData.indexOf('&') !== -1) { - __filter_div.innerHTML = cellData; - cellData = __filter_div_textContent - ? __filter_div.textContent - : __filter_div.innerText; - } - if (cellData.replace) { - cellData = cellData.replace(/[\r\n\u2028]/g, ''); - } - rowFilterData.push(cellData); + * @param settings DataTables settings object + * @param visIdx Visible column index to lookup + * @returns i the data index + */ +function visibleToColumnIndex(settings, visIdx) { + let aiVis = getColumns(settings, 'visible'); + return typeof aiVis[visIdx] === 'number' ? aiVis[visIdx] : null; +} +/** + * Convert the index of an index in the data array and convert it to the visible + * column index (take account of hidden columns) + * + * @param settings DataTables settings object + * @param match Column index to lookup + * @returns The data index + */ +function columnIndexToVisible(settings, match) { + let aiVis = getColumns(settings, 'visible'); + let iPos = aiVis.indexOf(match); + return iPos !== -1 ? iPos : null; +} +/** + * Get the number of visible columns + * + * @param settings DataTables settings object + * @returns i the number of visible columns + */ +function visibleColumns(settings) { + let layout = settings.header; + let columns = settings.columns; + let vis = 0; + if (layout.length) { + for (let i = 0, iLen = layout[0].length; i < iLen; i++) { + if (columns[i].visible && + Dom.s(layout[0][i].cell).css('display') !== 'none') { + vis++; } - row.searchCellCache = rowFilterData; - row.searchRowCache = rowFilterData.join(' '); - wasInvalidated = true; } } - return wasInvalidated; + return vis; } - /** - * Render and cache a row's display data for the columns, if required + * Get an array of column indexes that match a given property * * @param settings DataTables settings object - * @param rowIdx Row index - * @returns Array with display information + * @param param Parameter in the columns array to look for + * @returns Array of indexes with matched properties */ -function getRowDisplay(settings, rowIdx) { - var rowModal = settings.data[rowIdx]; - var columns = settings.columns; - if (!rowModal) { - return []; - } - if (!rowModal.displayData) { - // Need to render and cache - rowModal.displayData = []; - for (var colIdx = 0, len = columns.length; colIdx < len; colIdx++) { - rowModal.displayData.push(getCellData(settings, rowIdx, colIdx, 'display')); +function getColumns(settings, param) { + let a = []; + settings.columns.map(function (val, i) { + if (val[param]) { + a.push(i); } - } - return rowModal.displayData; + }); + return a; } /** - * Create a new TR element (and it's TD children) for a row + * Allow the result from a type detection function to be `true` while + * translating that into a string. Old type detection functions will return the + * type name if it passes. An object store would be better, but not backwards + * compatible. * + * @param typeDetect Object or function for type detection + * @param res Result from the type detection function + * @returns Type name or false + */ +function _typeResult(typeDetect, res) { + return res === true ? typeDetect._name : res; +} +/** + * Calculate the 'type' of a column * @param settings DataTables settings object - * @param rowIdx Row to consider - * @param trIn TR element to add to the table - optional. If not given, - * DataTables will create a row automatically - * @param tds Array of TD|TH elements for the row - must be given if trIn is. */ -function createTr(settings, rowIdx, trIn, tds) { - var row = settings.data[rowIdx], cells = [], tr, td, column, i, iLen, create, trClass = settings.classes.tbody.row; - if (row && row.tr === null) { - let rowData = row.data; - tr = trIn || document.createElement('tr'); - row.tr = tr; - row.cells = cells; - Dom.s(tr).classAdd(trClass); - // Use a private property on the node to allow reserve mapping from the node - // to the aoData array for fast look up - tr._DT_RowIndex = rowIdx; - // Special parameters can be given by the data source to be used on the - // row - rowAttributes(settings, row); - /* Process each column */ - for (i = 0, iLen = settings.columns.length; i < iLen; i++) { - column = settings.columns[i]; - create = trIn && tds && tds[i] ? false : true; - td = create - ? document.createElement(column.cellType) - : tds[i]; - if (!td) { - log(settings, 0, 'Incorrect column count', 18); - } - td._DT_CellIndex = { - row: rowIdx, - column: i - }; - cells.push(td); - var display = getRowDisplay(settings, rowIdx); - // Need to create the HTML if new, or if a rendering function is - // defined - if (create || - ((column.render || column.data !== i) && - (!util.is.plainObject(column.data) || - (column.data && - column.data._ !== i + '.display')))) { - writeCell(td, display[i]); - } - // column class - Dom.s(td).classAdd(column.className); - // Visibility - add or remove as required - if (column.visible && create) { - tr.appendChild(td); +function columnTypes(settings, originalTypes = '') { + var columns = settings.columns; + var data = settings.data; + var types = ext.type.detect; + var i, iLen, j, jen, k, ken; + var col, detectedType, cache; + if (!originalTypes) { + originalTypes = columns.map(c => c.type).join(','); + } + // For each column, spin over the data type detection functions, seeing if + // one matches + for (i = 0, iLen = columns.length; i < iLen; i++) { + col = columns[i]; + cache = []; + if (!col.type && col.typeManual) { + col.type = col.typeManual; + } + else if (!col.type) { + // With SSP type detection can be unreliable and error prone, so we + // provide a way to turn it off. + if (!settings.typeDetect) { + return; } - else if (!column.visible && !create) { - td.parentNode.removeChild(td); + for (j = 0, jen = types.length; j < jen; j++) { + let typeDetect = types[j]; + let oneOf; + let allOf; + let init; + let one = false; + // There can be either one, or three type detection functions + if (typeof typeDetect === 'function') { + allOf = typeDetect; + } + else { + oneOf = typeDetect.oneOf; + allOf = typeDetect.allOf; + init = typeDetect.init; + } + detectedType = null; + // Fast detect based on column assignment + if (init) { + detectedType = _typeResult(typeDetect, init(settings, col, i)); + if (detectedType) { + col.type = detectedType; + break; + } + } + for (k = 0, ken = data.length; k < ken; k++) { + if (!data[k]) { + continue; + } + // Use a cache array so we only need to get the type data + // from the formatter once (when using multiple detectors) + if (cache[k] === undefined) { + cache[k] = getCellData(settings, k, i, 'type'); + } + // Only one data point in the column needs to match this + // function + if (oneOf && !one) { + one = _typeResult(typeDetect, oneOf(cache[k], settings)); + } + // All data points need to match this function + detectedType = _typeResult(typeDetect, allOf(cache[k], settings)); + // If null, then this type can't apply to this column, so + // rather than testing all cells, break out. There is an + // exception for the last type which is `html`. We need to + // scan all rows since it is possible to mix string and HTML + // types + if (!detectedType && j !== types.length - 3) { + break; + } + // Only a single match is needed for html type since it is + // bottom of the pile and very similar to string - but it + // must not be empty + if (detectedType === 'html' && !util.is.empty(cache[k])) { + break; + } + } + // Type is valid for all data points in the column - use this + // type + if ((oneOf && one && detectedType) || + (!oneOf && detectedType)) { + col.type = detectedType; + break; + } } - if (column.createdCell) { - column.createdCell.call(settings.instance, td, getCellData(settings, rowIdx, i), rowData, rowIdx, i); + // Fall back - if no type was detected, always use string + if (!col.type) { + col.type = 'string'; } } - callbackFire(settings, 'rowCreated', 'row-created', [ - tr, - rowData, - rowIdx, - cells - ]); - } - else if (row) { - Dom.s(row.tr).classAdd(trClass); + // Set class names for header / footer for auto type classes + var autoClass = ext.type.className[col.type]; + if (autoClass) { + _columnAutoClass(settings.header, i, autoClass); + _columnAutoClass(settings.footer, i, autoClass); + } + var renderer = ext.type.render[col.type]; + // This can only happen once! There is no way to remove + // a renderer. After the first time the renderer has + // already been set so createTr will run the renderer itself. + if (renderer && !col.renderer) { + col.renderer = util.get(renderer); + _columnAutoRender(settings, i); + } + } + var newTypes = columns.map(c => c.type).join(','); + if (newTypes !== originalTypes) { + callbackFire(settings, null, 'columnTypes', [settings], false); + } +} +/** + * Apply an auto detected renderer to data which doesn't yet have a renderer + */ +function _columnAutoRender(settings, colIdx) { + let data = settings.data; + for (let i = 0; i < data.length; i++) { + let d = data[i]; + if (d && d.tr) { + // We have to update the display here since there is no invalidation + // check for the data + let display = getCellData(settings, i, colIdx, 'display'); + d.displayData[colIdx] = display; + writeCell(d.cells[colIdx], display); + // No need to update sort / filter data since it has been + // invalidated and will be re-read with the renderer now applied + } } } /** - * Add attributes to a row based on the special `DT_*` parameters in a data - * source object. + * Apply a class name to a column's header cells * - * @param settings DataTables settings object - * @param row Row object for the row to be modified + * @param container The header / footer structure array + * @param colIdx Column index + * @param className Class name to apply */ -function rowAttributes(settings, row) { - var tr = row.tr; - var data = row.data; - if (tr) { - var id = settings.rowIdFn(data); - if (id) { - tr.id = id; - } - if (data.DT_RowClass) { - // Remove any classes added by DT_RowClass before - var a = data.DT_RowClass.split(' '); - row.addedClasses = row.addedClasses - ? util.unique(row.addedClasses.concat(a)) - : a; - Dom.s(tr) - .classRemove(row.addedClasses.join(' ')) - .classAdd(data.DT_RowClass); - } - if (data.DT_RowAttr) { - Dom.s(tr).attr(data.DT_RowAttr); - } - if (data.DT_RowData) { - Dom.s(tr).data(data.DT_RowData); +function _columnAutoClass(container, colIdx, className) { + container.forEach(function (row) { + if (row[colIdx] && row[colIdx].unique) { + Dom.s(row[colIdx].cell).classAdd(className); } - } + }); } /** - * Create the HTML header for the table + * Take the column definitions and static columns arrays and calculate how they + * relate to column indexes. The callback function will then apply the + * definition found for a column to a suitable configuration object. * - * @param settings DataTable instance - * @param side If the header or footer should be used - * @returns + * @param settings DataTables settings object + * @param aoColDefs The aoColumnDefs array that is to be applied + * @param aoCols The aoColumns array that defines columns individually + * @param headerLayout Layout for header as it was loaded + * @param fn Callback function - takes two parameters, the calculated column + * index and the definition for that column. */ -function buildHead(settings, side) { - let classes = settings.classes; - let columns = settings.columns; - let i, iLen, row; - let target = Dom.s(side === 'header' ? settings.thead : settings.tfoot); - let titleProp = side === 'header' ? 'title' : side; - // Footer might be defined - if (!target) { - return; - } - // If no cells yet and we have content for them, then create - if (side === 'header' || - util.array.pluck(settings.columns, titleProp).join('')) { - row = target.find('tr'); - // Add a row if needed - if (!row.count()) { - row = Dom.c('tr').appendTo(target); - } - // Add the number of cells needed to make up to the number of columns - if (row.count() === 1) { - let cellCount = 0; - row.find('td, th').each(el => { - cellCount += el.colSpan; - }); - for (i = cellCount, iLen = columns.length; i < iLen; i++) { - Dom.c('th') - .html(columns[i][titleProp] || '') - .appendTo(row); +function applyColumnDefs(settings, aoColDefs, aoCols, headerLayout, fn) { + var i, iLen, j, jLen, k, kLen; + var columns = settings.columns; + if (aoCols) { + for (i = 0, iLen = aoCols.length; i < iLen; i++) { + // Compat + if (aoCols[i] && aoCols[i].name) { + columns[i].name = aoCols[i].name; } } } - let detected = detectHeader(settings, target.get(0), true); - if (side === 'header') { - settings.header = detected; - target.find('tr').classAdd(classes.thead.row); + // Column definitions with aTargets + if (aoColDefs) { + // Loop over the definitions array - loop in reverse so first instance + // has priority + for (i = aoColDefs.length - 1; i >= 0; i--) { + let def = aoColDefs[i]; + /* Each definition can target multiple columns, as it is an array */ + let aTargets = def.target !== undefined + ? def.target + : def.targets !== undefined + ? def.targets + : def.aTargets; // legacy + if (!Array.isArray(aTargets)) { + aTargets = [aTargets]; + } + for (j = 0, jLen = aTargets.length; j < jLen; j++) { + var target = aTargets[j]; + if (typeof target === 'number' && target >= 0) { + /* Add columns that we don't yet know about */ + while (columns.length <= target) { + addColumn(settings); + } + /* Integer, basic index */ + fn(target, def); + } + else if (typeof target === 'number' && target < 0) { + /* Negative integer, right to left column counting */ + fn(columns.length + target, def); + } + else if (typeof target === 'string') { + for (k = 0, kLen = columns.length; k < kLen; k++) { + if (target === '_all') { + // Apply to all columns + fn(k, def); + } + else if (target.indexOf(':name') !== -1) { + // Column selector + if (columns[k].name === target.replace(':name', '')) { + fn(k, def); + } + } + else { + // Cell selector + headerLayout.forEach(function (row) { + if (row[k]) { + var cell = row[k].cell; + // Legacy support. Note that it means that + // we don't support an element name selector + // only, since they are treated as class + // names for 1.x compat. + if (target.match(/^[a-z][\w-]*$/i)) { + target = '.' + target; + } + if (cell.matches(target)) { + fn(k, def); + } + } + }); + } + } + } + } + } } - else { - settings.footer = detected; - target.find('tr').classAdd(classes.tfoot.row); + // Statically defined columns array + if (aoCols) { + for (i = 0, iLen = aoCols.length; i < iLen; i++) { + fn(i, aoCols[i]); + } } - // Every cell needs to be passed through the renderer - target - .children('tr') - .children('th, td') - .each(el => { - // Should just be able to do `renderer(settings, side)` here but - // Typescript doesn't like it, despite it already being constrained! - let runner = side === 'header' - ? renderer(settings, 'header') - : renderer(settings, 'footer'); - runner(settings, Dom.s(el), classes); - }); } /** - * Build a layout structure for a header or footer + * Get the width for a given set of columns * - * @param settings DataTables settings - * @param source Source layout array - * @param incColumns What columns should be included - * @returns Layout array in column index order + * @param settings DataTables settings object + * @param targets Columns - comma separated string or array of numbers + * @param original Use the original width (true) or calculated (false) + * @param incVisible Include visible columns (true) or not (false) + * @returns Combined CSS value */ -function headerLayout(settings, source, incColumns) { - var row, column, cell; - var local = []; - var structure = []; - var columns = settings.columns; - var columnCount = columns.length; - var rowspan, colspan; - if (!source) { - return; - } - // Default is to work on only visible columns - if (!incColumns) { - incColumns = util.array.range(columnCount).filter(function (idx) { - return columns[idx].visible; - }); - } - // Make a copy of the master layout array, but with only the columns we want - for (row = 0; row < source.length; row++) { - // Remove any columns we haven't selected - local[row] = source[row].slice().filter(function (c, i) { - return incColumns.includes(i); - }); - // Prep the structure array - it needs an element for each row - structure.push([]); +function columnsSumWidth(settings, targets, original, incVisible) { + if (!Array.isArray(targets)) { + targets = columnsFromHeader(targets); } - for (row = 0; row < local.length; row++) { - for (column = 0; column < local[row].length; column++) { - rowspan = 1; - colspan = 1; - // Check to see if there is already a cell (row/colspan) covering - // our target insert point. If there is, then there is nothing to - // do. - if (structure[row][column] === undefined) { - cell = local[row][column].cell; - // Expand for rowspan - while (local[row + rowspan] !== undefined && - local[row][column].cell == local[row + rowspan][column].cell) { - structure[row + rowspan][column] = null; - rowspan++; - } - // And for colspan - while (local[row][column + colspan] !== undefined && - local[row][column].cell == local[row][column + colspan].cell) { - // Which also needs to go over rows - for (var k = 0; k < rowspan; k++) { - structure[row + k][column + colspan] = null; - } - colspan++; - } - var titleSpan = Dom.s(cell).find('.dt-column-title'); - structure[row][column] = { - cell: cell, - colspan: colspan, - rowspan: rowspan, - title: titleSpan.count() - ? titleSpan.html() - : Dom.s(cell).html() - }; + let sum = 0; + let unit = 'px'; + let columns = settings.columns; + for (let i = 0, iLen = targets.length; i < iLen; i++) { + let column = columns[targets[i]]; + let definedWidth = original ? column.widthOrig : column.width; + if (column.visible === false) { + continue; + } + if (definedWidth === null || definedWidth === undefined) { + return null; // can't determine a defined width - browser defined + } + else if (typeof definedWidth === 'number') { + sum += definedWidth; + } + else { + let matched = definedWidth.match(/([\d\.]+)([^\d]*)/); + if (matched) { + sum += parseFloat(matched[1]); + unit = matched.length === 3 ? matched[2] : 'px'; } } } - return structure; + return sum + unit; } /** - * Draw the header (or footer) element based on the column visibility states. + * Determine what columns a header cell covers (can be multiple for colspan + * cases). * - * @param settings DataTables settings object - * @param source Layout array from detectHeader + * @param cell The header cell in question + * @returns An array of column indexes */ -function drawHead(settings, source) { - let layout = headerLayout(settings, source); - let tr; - if (!layout) { - return; +function columnsFromHeader(cell) { + let attr = Dom.s(cell).closest('[data-dt-column]').attr('data-dt-column'); + if (!attr) { + return []; } - for (let row = 0; row < source.length; row++) { - tr = source[row].row; - // All cells are going to be replaced, so empty out the row - if (tr) { - Dom.s(tr).detachChildren(); - } - for (let column = 0; column < layout[row].length; column++) { - let point = layout[row][column]; - if (point) { - Dom.s(point.cell) - .appendTo(tr) - .attr('rowspan', point.rowspan) - .attr('colspan', point.colspan); + return attr.split(',').map(function (val) { + return parseInt(val); + }); +} +/** + * Get cells from the header or footer, including a specific row and / or cell + * + * @param header The header or footer strcture + * @param row A specific row, or null + * @param column A specific column, or null + * @returns An array of all matching cells + */ +function columnCells(header, row = null, column = null) { + var out = []; + for (var i = 0; i < header.length; i++) { + if (row === null || row === i) { + for (var j = 0; j < header[i].length; j++) { + var cell = header[i][j].cell; + if ((column === null || column === j) && !out.includes(cell)) { + out.push(cell); + } } } } + return out; } /** - * Insert the required TR nodes into the table for display + * Get cells that apply for ordering handler or icons * - * @param settings DataTables settings object - * @param ajaxComplete true after ajax call to complete rendering + * @param settings Context + * @param notSelector DOM selector to exclude elements + * @returns Array of selected elements */ -function draw(settings, ajaxComplete) { - // Allow for state saving and a custom start position - setStartPosition(settings); - // Provide a pre-callback function which can be used to cancel the draw is - // false is returned - var aPreDraw = callbackFire(settings, 'preDraw', 'preDraw', [settings]); - if (aPreDraw.indexOf(false) !== -1) { - processingDisplay(settings, false); - return; +function columnOrderingCells(settings, notSelector) { + var cells = []; + var titleRow = settings.titleRow; + if (titleRow === true) { + // Top row (legacy `orderCellsTop`) + cells = columnCells(settings.header, 0); } - var rowEls = []; - var rowCount = 0; - var isServerSide = dataSource(settings) == 'ssp'; - var display = settings.display; - var start = settings.displayStart; - var end = displayEnd(settings); - var columns = settings.columns; - var body = Dom.s(settings.tbody); - settings.doingDraw = true; - /* Server-side processing draw intercept */ - if (settings.deferLoading) { - settings.deferLoading = false; - settings.drawCount++; - processingDisplay(settings, false); + else if (titleRow === false) { + // Bottom row (legacy `orderCellsTop`) + cells = columnCells(settings.header, settings.header.length - 1); } - else if (!isServerSide) { - settings.drawCount++; + else if (titleRow !== null) { + // Specific row + cells = columnCells(settings.header, titleRow); } - else if (!settings.destroying && !ajaxComplete) { - // Show loading message for server-side processing - if (settings.drawCount === 0) { - body.empty().append(_emptyRow(settings)); - } - ajaxUpdate(settings); + else { + // All + cells = columnCells(settings.header); + } + return Dom.s(cells) + .filter('th' + notSelector + ', td' + notSelector) + .filter(el => { + return (Dom.s(el) + .parent() + .filter(notSelector) + .length !== 0); + }); +} + +const footer = (settings, cell, classes) => { + cell.classAdd(classes.tfoot.cell); +}; +const header = (settings, cell, classes) => { + cell.classAdd(classes.thead.cell); + if (!settings.features.ordering) { + cell.classAdd(classes.order.none); + } + // Conditions to not apply the ordering icons + if (!columnOrderingCells(settings, ':not([data-dt-order="disable"])') + .get() + .includes(cell[0])) { return; } - if (display.length !== 0) { - var iStart = isServerSide ? 0 : start; - var iEnd = isServerSide ? settings.data.length : end; - for (var j = iStart; j < iEnd; j++) { - var dataIdx = display[j]; - var data = settings.data[dataIdx]; - // Row has been deleted - can't be displayed - if (data === null) { - continue; - } - // Row node hasn't been created yet - if (data.tr === null) { - createTr(settings, dataIdx); + // No additional mark-up required. Attach a sort listener to update on sort + // - note that using the `DT` namespace will allow the event to be removed + // automatically on destroy, while the `dt` namespaced event is the one we + // are listening for + Dom.s(settings.table).on('order.dt.DT column-visibility.dt.DT', function (e, ctx, column) { + if (settings !== ctx) { + // need to check if this is the host + return; // table, not a nested one + } + var sorting = ctx.sortDetails; + if (!sorting) { + return; + } + var orderedColumns = pluck(sorting, 'col'); + // This handler is only needed on column visibility if the column is + // part of the ordering. If it isn't, then we can bail out to save + // performance. It could be a separate event handler, but this is a + // balance between code reuse / size and performance console.log(e, + // e.name, column, orderedColumns, orderedColumns.includes(column)) + if (e.type === 'column-visibility' && + !orderedColumns.includes(column)) { + return; + } + var i; + var orderClasses = classes.order; + var columns = ctx.api.columns(cell); + var col = settings.columns[columns.flatten()[0]]; + var orderable = columns.orderable().includes(true); + var ariaType = ''; + var indexes = columns.indexes(); + var sortDirs = columns.orderable(true).flatten(); + var tabIndex = settings.tabIndex; + var canOrder = ctx.orderHandler && orderable; + cell.classRemove(orderClasses.isAsc + ' ' + orderClasses.isDesc) + .classToggle(orderClasses.none, !orderable) + .classToggle(orderClasses.canAsc, canOrder && sortDirs.includes('asc')) + .classToggle(orderClasses.canDesc, canOrder && sortDirs.includes('desc')); + // Determine if all of the columns that this cell covers are + // included in the current ordering + var isOrdering = true; + for (i = 0; i < indexes.length; i++) { + if (!orderedColumns.includes(indexes[i])) { + isOrdering = false; } - var nRow = data.tr; - // Add various classes as needed - for (var i = 0; i < columns.length; i++) { - var col = columns[i]; - var td = data.cells[i]; - Dom.s(td) - .classAdd(col.type ? ext.type.className[col.type] : null) // auto class - .classAdd(settings.classes.tbody.cell); // all cells + } + if (isOrdering) { + // Get the ordering direction for the columns under this cell + // Note that it is possible for a cell to be asc and desc + // sorting (column spanning cells) + var orderDirs = columns.order(); + cell.classAdd((orderDirs.includes('asc') ? orderClasses.isAsc : '') + + (orderDirs.includes('desc') ? orderClasses.isDesc : '')); + } + // Find the first visible column that has ordering applied to it - + // it get's the aria information, as the ARIA spec says that only + // one column should be marked with aria-sort + var firstVis = -1; // column index + for (i = 0; i < orderedColumns.length; i++) { + if (settings.columns[orderedColumns[i]].visible) { + firstVis = orderedColumns[i]; + break; } - // Row callback functions - might want to manipulate the row - // rowCount and j are not currently documented. Are they at all - // useful? - callbackFire(settings, 'row', null, [ - nRow, - data.data, - rowCount, - j, - dataIdx - ]); - rowEls.push(nRow); - rowCount++; } - } - else { - rowEls[0] = _emptyRow(settings); - } - /* Header and footer callbacks */ - callbackFire(settings, 'header', 'header', [ - Dom.s(settings.thead).children('tr').get(0), - getDataMaster(settings), - start, - end, - display - ]); - callbackFire(settings, 'footer', 'footer', [ - Dom.s(settings.tfoot).children('tr').get(0), - getDataMaster(settings), - start, - end, - display - ]); - body.detachChildren().append(rowEls); - // Empty table needs a specific class - Dom.s(settings.tableWrapper).classToggle('dt-empty-footer', Dom.s(settings.tfoot).find('tr').count() === 0); - // Call all required callback functions for the end of a draw - callbackFire(settings, 'draw', 'draw', [settings], true); - // Draw is complete, sorting and filtering must be as well - settings.wasOrdered = false; - settings.wasFiltered = false; - settings.doingDraw = false; -} -/** - * Redraw the table - taking account of the various features which are enabled - * - * @param settings DataTables settings object - * @param holdPosition Keep the current paging position. By default the paging - * is reset to the first page - * @param recompute Indicate if a rebuild of sort and filter should happen - */ -function reDraw(settings, holdPosition, recompute) { - let features = settings.features, doSort = features.ordering, doFilter = features.searching; - if (recompute === undefined || recompute === true) { - // Resolve any column types that are unknown due to addition or - // invalidation - columnTypes(settings); - columnWidths(settings); - if (doSort) { - sort(settings); + if (indexes[0] == firstVis) { + var firstSort = sorting[0]; + var sortOrder = col.orderSequence; + cell.attr('aria-sort', firstSort.dir === 'asc' ? 'ascending' : 'descending'); + // Determine if the next click will remove sorting or change the + // sort + ariaType = + sortOrder && !sortOrder[firstSort.index + 1] + ? 'Remove' + : 'Reverse'; } - if (doFilter) { - filterComplete(settings); + else { + cell.attrRemove('aria-sort'); + } + // Make the headers tab-able for keyboard navigation + if (orderable) { + var orderSpan = cell.find('.dt-column-order'); + orderSpan + .attr('role', 'button') + .attr('aria-label', orderable + ? col.ariaTitle + + ctx.api.i18n('aria.orderable' + ariaType) + : col.ariaTitle); + if (tabIndex !== -1) { + orderSpan.attr('tabindex', tabIndex); + } + } + }); +}; +const layout = (settings, container, items) => { + let classes = settings.classes.layout; + let row = Dom.c('div') + .attr('id', items.id || null) + .classAdd(items.className || classes.row) + .appendTo(container); + displayRowCells(items, function (key, val) { + var klass = ''; + if (val.table) { + row.classAdd(classes.tableRow); + klass += classes.tableCell + ' '; + } + if (key === 'start') { + klass += classes.start; + } + else if (key === 'end') { + klass += classes.end; } else { - // No filtering, so we want to just use the display master - settings.display = settings.displayMaster.slice(); + klass += classes.full; } - } - if (holdPosition !== true) { - settings.displayStart = 0; - } - else { - // Keep position, but make sure that there is actually data to display, - // otherwise we need to rewind a bit (e.g. if rows were deleted) - lengthOverflow(settings); - } - // Let any modules know about the draw hold position state (used by - // scrolling internally) - settings.drawHold = holdPosition; - draw(settings); - settings.api.one('draw', function () { - settings.drawHold = false; + Dom.c('div') + .attr({ + id: val.id || null, + class: val.className + ? val.className + : classes.cell + ' ' + klass + }) + .append(val.contents) + .appendTo(row); }); -} -/** - * Table is empty - create a row with an empty message in it - * - * @param settings DataTables context - */ -function _emptyRow(settings) { - let lang = settings.language; - let zero = lang.zeroRecords; - let dataSrc = dataSource(settings); - // Make use of the fact that settings.json is only set once the initial data - // has been loaded. Show loading when that isn't the case - if ((dataSrc === 'ssp' || dataSrc === 'ajax') && !settings.json) { - zero = lang.loadingRecords; - } - else if (lang.emptyTable && recordsTotal(settings) === 0) { - zero = lang.emptyTable; +}; +const pagingButton = (settings, buttonType, content, active, disabled) => { + var classes = settings.classes.paging; + var btnClasses = [classes.button]; + var btn; + if (active) { + btnClasses.push(classes.active); } - return Dom - .c('tr') - .append(Dom - .c('td') - .attr('colSpan', visibleColumns(settings)) - .classAdd(settings.classes.empty.row) - .html(zero)) - .get(0); -} -/** - * Use the DOM source to create up an array of header cells. The idea here is to - * create a layout grid (array) of rows x columns, which contains a reference to - * the cell at that point in the grid (regardless of col/rowspan), such that any - * column / row could be removed and the new grid constructed. - * - * @param settings DataTables context - * @param thead thead / tbody element - * @param write If cells should be written (if required) - * @returns Calculated layout array - */ -function detectHeader(settings, thead, write) { - let columns = settings.columns; - let rows = Dom.s(thead).children('tr'); - let row, loopCell; - let i, k, l, len, shifted, column, colspan, rowspan; - let titleRow = settings.titleRow; - let isHeader = thead && thead.nodeName.toLowerCase() === 'thead'; - let layout = []; - let isUnique; - let shift = function (a, b, j) { - let d = a[b]; - while (d[j]) { - j++; - } - return j; - }; - // We know how many rows there are in the layout - so prep it - for (i = 0, len = rows.count(); i < len; i++) { - layout.push([]); + if (disabled) { + btnClasses.push(classes.disabled); } - for (i = 0, len = rows.count(); i < len; i++) { - row = rows.get(i); - column = 0; - // For every cell in the row.. - loopCell = row.firstChild; - while (loopCell) { - if (loopCell.nodeName.toUpperCase() == 'TD' || - loopCell.nodeName.toUpperCase() == 'TH') { - let cell = Dom.s(loopCell); - let cols = []; - // Get the col and rowspan attributes from the DOM and sanitise - // them - colspan = parseInt(cell.attr('colspan') || '1') || 1; - rowspan = parseInt(cell.attr('rowspan') || '1') || 1; - colspan = - !colspan || colspan === 0 || colspan === 1 ? 1 : colspan; - rowspan = - !rowspan || rowspan === 0 || rowspan === 1 ? 1 : rowspan; - // There might be colspan cells already in this row, so shift - // our target accordingly - shifted = shift(layout, i, column); - // Cache calculation for unique columns - isUnique = colspan === 1 ? true : false; - // Perform header setup - if (write) { - if (isUnique) { - // Allow column options to be set from HTML attributes - columnOptions(settings, shifted, escapeObject(cell.data())); - // Get the width for the column. This can be defined - // from the width attribute, style attribute or - // `columns.width` option - let columnDef = columns[shifted]; - let width = cell.attr('width') || null; - let t = cell - .get(0) - .style.width.match(/width:\s*(\d+[pxem%]+)/); - if (t) { - width = t[1]; - } - columnDef.widthOrig = columnDef.width || width; - if (isHeader) { - // Column title handling - can be user set, or read - // from the DOM This happens before the render, so - // the original is still in place - if (columnDef.title !== null && - !columnDef.autoTitle) { - if ((titleRow === true && i === 0) || // top row - (titleRow === false && - i === rows.count() - 1) || // bottom row - titleRow === i || // specific row - titleRow === null) { - cell.html(columnDef.title); - } - } - if (!columnDef.title && isUnique) { - columnDef.title = util.string.stripHtml(cell.html()); - columnDef.autoTitle = true; - } - } - else { - // Footer specific operations - if (columnDef.footer) { - cell.html(columnDef.footer); - } - } - // Fall back to the aria-label attribute on the table - // header if no ariaTitle is provided. - if (!columnDef.ariaTitle) { - columnDef.ariaTitle = - cell.attr('aria-label') || columnDef.title; - } - // Column specific class names - if (columnDef.className) { - cell.classAdd(columnDef.className); - } - } - // Wrap the column title so we can write to it in future - if (cell.find('div.dt-column-title').count() === 0) { - Dom.c('div') - .classAdd('dt-column-title') - .append(Array.from(cell.get(0).childNodes)) - .appendTo(cell); - } - if (settings.orderIndicators && - isHeader && - cell.filter(':not([data-dt-order=disable])').count() !== - 0 && - cell.parent(':not([data-dt-order=disable])').count() !== - 0 && - cell.find('div.dt-column-order').count() === 0) { - Dom.c('div') - .classAdd('dt-column-order') - .appendTo(cell); - } - // We need to wrap the elements in the header in another - // element to use flexbox layout for those elements - var headerFooter = isHeader ? 'header' : 'footer'; - if (cell.find('div.dt-column-' + headerFooter).count() === - 0) { - Dom.c('div') - .classAdd('dt-column-' + headerFooter) - .append(Array.from(cell.get(0).childNodes)) - .appendTo(cell); - } - } - // If there is col / rowspan, copy the information into the - // layout grid - for (l = 0; l < colspan; l++) { - for (k = 0; k < rowspan; k++) { - layout[i + k][shifted + l] = { - cell: cell.get(0), - unique: isUnique - }; - layout[i + k].row = row; - } - cols.push(shifted + l); - } - // Assign an attribute so spanning cells can still be identified - // as belonging to a column - cell.attr('data-dt-column', util.unique(cols).join(',')); - } - loopCell = loopCell.nextSibling; - } + if (buttonType === 'ellipsis') { + btn = Dom.c('span').classAdd('ellipsis').html(content).get(0); } - return layout; -} -/** - * Set the start position for draw - * - * @param settings DataTables settings object - */ -function setStartPosition(settings) { - var bServerSide = dataSource(settings) == 'ssp'; - var iInitDisplayStart = settings.displayStartInit; - // Check and see if we have an initial draw position from state saving - if (iInitDisplayStart !== undefined && iInitDisplayStart !== -1) { - settings.displayStart = bServerSide - ? iInitDisplayStart - : iInitDisplayStart >= recordsDisplay(settings) - ? 0 - : iInitDisplayStart; - settings.displayStartInit = -1; + else { + btn = Dom.c('button') + .classAdd(btnClasses.join(' ')) + .attr('role', 'link') + .attr('type', 'button') + .html(content) + .get(0); + } + return { + display: btn, + clicker: btn + }; +}; +const pagingContainer = (settings, buttons) => { + // No wrapping element - just append directly to the host + return buttons; +}; +function displayRowCells(items, fn) { + if (items.start) { + fn('start', items.start); + } + if (items.end) { + fn('end', items.end); + } + if (items.full) { + fn('full', items.full); } } + /** - * Get the number of records in the current record set, before filtering - * - * @param ctx DataTables settings object - */ -function recordsTotal(ctx) { - return dataSource(ctx) == 'ssp' - ? ctx.recordsTotal * 1 - : ctx.displayMaster.length; -} -/** - * Get the number of records in the current record set, after filtering + * DataTables extensions * - * @param ctx DataTables settings object - */ -function recordsDisplay(ctx) { - return dataSource(ctx) == 'ssp' - ? ctx.recordsDisplay * 1 - : ctx.display.length; -} -/** - * Get the display end point - display index + * This namespace acts as a collection area for plug-ins that can be used to + * extend DataTables capabilities. Indeed many of the build in methods + * use this method to provide their own capabilities (sorting methods for + * example). * - * @param ctx DataTables settings object + * Note that this namespace is aliased to `jQuery.fn.dataTableExt` for legacy + * reasons */ -function displayEnd(ctx) { - var len = ctx.pageLength, start = ctx.displayStart, calc = start + len, records = ctx.display.length, features = ctx.features, paginate = features.paging; - if (features.serverSide) { - return paginate === false || len === -1 - ? start + records - : Math.min(start + len, ctx.recordsDisplay); - } - else { - return !paginate || calc > records || len === -1 ? records : calc; - } -} +const ext = { + /** + * DataTables build type (expanded by the download builder) + */ + builder: 'bs5/dt-3.0.4', + /** + * Buttons. For use with the Buttons extension for DataTables. This is + * defined here so other extensions can define buttons regardless of load + * order. It is _not_ used by DataTables core. + */ + buttons: {}, + /** + * ColumnControl buttons and content + */ + ccContent: {}, + /** + * Element class names + */ + classes: classes$1, + /** + * Error reporting. + * + * How should DataTables report an error. Can take the value 'alert', + * 'throw', 'none' or a function. + */ + errMode: 'alert', + /** HTML entity escaping */ + escape: { + /** When reading data-* attributes for initialisation options */ + attributes: false + }, + /** + * Legacy so v1 plug-ins don't throw js errors on load + */ + feature: legacy, + /** + * Feature plug-ins. + * + * This is an object of callbacks which provide the features for DataTables + * to be initialised via the `layout` option. + */ + features: features, + /** + * Row searching. + * + * This method of searching is complimentary to the default type based + * searching, and a lot more comprehensive as it allows you complete control + * over the searching logic. Each element in this array is a function + * (parameters described below) that is called for every row in the table, + * and your logic decides if it should be included in the searching data set + * or not. + */ + search: [], + /** + * Selector extensions + * + * The `selector` option can be used to extend the options available for the + * selector modifier options (`selector-modifier` object data type) that + * each of the three built in selector types offer (row, column and cell + + * their plural counterparts). For example the Select extension uses this + * mechanism to provide an option to select only rows, columns and cells + * that have been marked as selected by the end user (`{selected: true}`), + * which can be used in conjunction with the existing built in selector + * options. + */ + selector: { + cell: [], + column: [], + row: [] + }, + settings: [], + /** + * Legacy configuration options. Enable and disable legacy options that + * are available in DataTables. + * + * @type object + */ + legacy: { + /** + * Enable / disable DataTables 1.9 compatible server-side processing + * requests + */ + ajax: null + }, + /** + * Pagination plug-in methods. + * + * Each entry in this object is a function and defines which buttons should + * be shown by the pagination rendering method that is used for the table. + * The renderer addresses how the buttons are displayed in the document, + * while the functions here tell it what buttons to display. This is done by + * returning an array of button descriptions (what each button will do). + */ + pager: pager, + renderer: { + footer: { + _: footer + }, + header: { + _: header + }, + layout: { + _: layout + }, + pagingButton: { + _: pagingButton + }, + pagingContainer: { + _: pagingContainer + } + }, + /** + * Rendering helper function exposed for use by the styling integrations. + */ + rendererDisplayRowCells: displayRowCells, + /** + * Ordering plug-ins - custom data source + * + * The extension options for ordering of data available here is + * complimentary to the default type based ordering that DataTables + * typically uses. It allows much greater control over the data that is + * being used to order a column, but is necessarily therefore more complex. + */ + order: {}, + /** + * Type based plug-ins. + * + * Each column in DataTables has a type assigned to it, either by automatic + * detection or by direct assignment using the `type` option for the column. + * The type of a column will effect how it is ordering and search (plug-ins + * can also make use of the column type if required). + */ + type: store, + /** + * Unique DataTables instance counter + * + * @type int + * @private + */ + _unique: 0, + // + // Depreciated + // The following properties are retained for backwards compatibility only. + // The should not be used in new projects and will be removed in a future + // version + // + /** + * Software version + * @type string + */ + version: '3.0.4' +}; +// +// Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts +// +Object.assign(ext, { + afnFiltering: ext.search, + aTypes: ext.type.detect, + ofnSearch: ext.type.search, + oSort: ext.type.order, + afnSortData: ext.order, + aoFeatures: ext.feature, + oStdClasses: ext.classes, + oPagination: ext.pager, + sVersion: ext.version, + fnVersionCheck: check$1 +}); /** * Common run function for selector types @@ -9863,10 +9893,7 @@ function columnHeader(settings, column, row) { // backwards compatibility) for (var i = 0; i < header.length; i++) { if (header[i][column].unique && - Dom - .s(header[i][column].cell) - .find('.dt-column-title') - .text()) { + Dom.s(header[i][column].cell).find('.dt-column-title').text()) { target = i; } } @@ -9876,20 +9903,8 @@ function columnHeader(settings, column, row) { } return header[target][column].cell; } -function columnHeaderCells(header) { - var out = []; - for (var i = 0; i < header.length; i++) { - for (var j = 0; j < header[i].length; j++) { - var cell = header[i][j].cell; - if (!out.includes(cell)) { - out.push(cell); - } - } - } - return out; -} function selectColumns(settings, selector, opts) { - var columns = settings.columns, names, titles, nodes = columnHeaderCells(settings.header); + var columns = settings.columns, names, titles; var run = function (s) { var selInt = intVal(s); // Selector - all @@ -9943,8 +9958,8 @@ function selectColumns(settings, selector, opts) { } // Selector if (match && match[1]) { - return Dom - .s(nodes[mapIdx]) + let columnElements = columnCells(settings.header, null, col.idx); + return Dom.s(columnElements) .filter(match[1]) .count() > 0 ? mapIdx @@ -9980,11 +9995,10 @@ function selectColumns(settings, selector, opts) { return [s._DT_CellIndex.column]; } // Selector on the TH elements for the columns - var result = Dom - .s(nodes) + var result = Dom.s(columnCells(settings.header)) .filter(s) .mapTo(el => { - return columnsFromHeader(el); // `nodes` is column index complete and in order + return columnsFromHeader(el); }) .flat() .sort(function (a, b) { @@ -10121,9 +10135,7 @@ registerPlural('columns().titles()', 'column().title()', function (title, row) { row = title; title = undefined; } - var span = Dom - .s(this.column(column).header(row)) - .find('.dt-column-title'); + var span = Dom.s(this.column(column).header(row)).find('.dt-column-title'); if (title !== undefined) { span.html(title); return this; @@ -10194,9 +10206,7 @@ registerPlural('columns().widths()', 'column().width()', function () { // be read, regardless of colspan in the header and rows being present // in the body var columns = this.columns(':visible'); - var row = Dom - .c('tr') - .html('' + Array(columns.count()).join('') + ''); + var row = Dom.c('tr').html('' + Array(columns.count()).join('') + ''); Dom.s(this.table().body()).append(row); var widths = []; var indexes = columns.indexes(); @@ -10907,7 +10917,7 @@ register('search()', function (input, regex, smart, caseInsen) { } let target = ctx.searches['*']; if (!target) { - target = create$2(); + target = create$1(); } if (typeof regex === 'object') { // New style object of options @@ -10942,7 +10952,7 @@ register('search.fixed()', function (name, search, options) { else { let target = fixed[name]; if (!target || !util.is.plainObject(target)) { - target = create$2(); + target = create$1(); } if (options) { assign(target, options); @@ -10966,7 +10976,7 @@ register(['columns().search()', 'column().search()'], function (input, regex, sm let colIdxs = columns.join(','); let target = ctx.searches[colIdxs]; if (!target) { - target = create$2(); + target = create$1(); } // Delete the search for custom grouping types if removing if ((input === '' || input === null) && columns.length > 1) { @@ -11026,7 +11036,7 @@ register(['columns().search.fixed()', 'column().search.fixed()'], function (name else { let target = fixed[name]; if (!target || !util.is.plainObject(target)) { - target = create$2(); + target = create$1(); } if (options) { assign(target, options); @@ -11958,7 +11968,7 @@ register$2('search', function (settings, optsIn) { let searchName = opts.columns === '*' ? '*' : indexes.join(','); let appliedSearch = settings.searches[searchName]; if (!appliedSearch) { - appliedSearch = create$2(); + appliedSearch = create$1(); settings.searches[searchName] = appliedSearch; } appliedSearch.columns = indexes; @@ -12206,8 +12216,8 @@ function create(parts = {}) { var models = { Column: Settings, - Row: create$1, - Search: create$2, + Row: create$2, + Search: create$1, Settings: create }; @@ -12220,7 +12230,7 @@ const defaults = { autoWidth: true, caption: '', classes: {}, - column: defaults$4, + column: defaults$2, columnDefs: null, columns: null, createdRow: null, @@ -12392,8 +12402,8 @@ const DataTable = function (selector, options) { } table.trigger('options.dt', true, [init]); // Backwards compatibility parameter mapping - compatOpts(defaults); - compatCols(defaults$4); + compatOpts(defaults, true); + compatCols(defaults$2); // Allow data properties on the table element to be used as // initialisation options util.object.assign(init, escapeObject(table.data())); @@ -12522,7 +12532,7 @@ const DataTable = function (selector, options) { ]); map(settings.language, config, 'infoCallback'); // Setup global search - settings.searches['*'] = create$2(config.search); + settings.searches['*'] = create$1(config.search); /* Callback functions which are array driven */ callbackReg(settings, 'draw', config.drawCallback); callbackReg(settings, 'stateSaveParams', config.stateSaveParams); From 3347698712d3e99e652ad4394ef2fc0ce2800fdd Mon Sep 17 00:00:00 2001 From: "Berk D. Demir" <11135+bdd@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:34:41 -0700 Subject: [PATCH 07/30] Add `pm-34171-card-scanner` feature flag (#7477) Introduced in 2026.4.1 Mobile releases (Android and iOS), enables use of device camera to autofill a card type item. N.B. This feature relies on Google ML Kit. F-Droid policy disallows Google ML Kit. Because of this, F-Droid builds do not ship with card scanner feature. (See https://github.com/bitwarden/android/pull/6890) --- .env.template | 1 + src/config.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/.env.template b/.env.template index 8ed6c5c2..62231776 100644 --- a/.env.template +++ b/.env.template @@ -403,6 +403,7 @@ ## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2) ## - "pm-30529-webauthn-related-origins": ## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0) +## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1) ## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0) diff --git a/src/config.rs b/src/config.rs index 0bb22f7f..9f0ae2e1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1440,6 +1440,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "mutual-tls", "cxp-import-mobile", "cxp-export-mobile", + "pm-34171-card-scanner", // Platform Team "pm-30529-webauthn-related-origins", // Vault Team From cc67d644f62605cb46f4d16c4a2eed1a861cc8bb Mon Sep 17 00:00:00 2001 From: Stefan Melmuk <509385+stefan0xC@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:35:11 +0200 Subject: [PATCH 08/30] set user_created bool for each separate invitation (#7753) --- src/api/core/organizations.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 4f490854..36297d30 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1075,7 +1075,7 @@ async fn send_invite( && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("createNewCollections") == Some(&json!(true))); - let mut user_created: bool = false; + let mut user_created: bool; for email in &data.emails { let mut member_status = MembershipStatus::Invited as i32; let user = match User::find_by_mail(email, &conn).await { @@ -1110,6 +1110,7 @@ async fn send_invite( member_status = MembershipStatus::Accepted as i32; } } + user_created = false; user } }; From d2660324e67073a856f6664ae0eb10324b1c38e6 Mon Sep 17 00:00:00 2001 From: Abhi Shandilya Date: Wed, 23 Sep 2026 13:44:05 -0400 Subject: [PATCH 09/30] Fix revoked org members retaining access to org ciphers (#7554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cipher access restriction queries (direct collection access, group collection access, and full access via groups) never checked the organization membership status. Since users_collections and groups_users rows are kept when a membership is revoked, revoked (and not yet confirmed) members kept read, write, delete, attachment and collection-move access to org ciphers via the direct-by-UUID endpoints. Require a confirmed membership in the cipher's organization in all three queries, matching the behavior of the sync queries. Co-authored-by: Daniel García --- src/db/models/cipher.rs | 16 +++++++++++++++- src/db/models/group.rs | 5 ++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 721d9790..fae8aaec 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -657,16 +657,27 @@ impl Cipher { } async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> { + let user_uuid = user_uuid.to_string(); conn.run(move |conn| { // Check whether this cipher is in any collections accessible to the // user. If so, retrieve the access flags for each collection. + // The user must have a confirmed membership in the cipher's + // organization, since users_collections rows are kept when a + // membership is revoked or not confirmed yet. ciphers::table .filter(ciphers::uuid.eq(&self.uuid)) .inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) + .inner_join( + users_organizations::table.on(ciphers::organization_uuid + .eq(users_organizations::org_uuid.nullable()) + .and(users_organizations::user_uuid.eq(user_uuid)) + .and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))), + ) .inner_join( users_collections::table.on(ciphers_collections::collection_uuid .eq(users_collections::collection_uuid) - .and(users_collections::user_uuid.eq(user_uuid))), + // Only allow collection access via the confirmed membership. + .and(users_organizations::user_uuid.eq(users_collections::user_uuid))), ) .select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) .load::<(bool, bool, bool)>(conn) @@ -697,6 +708,9 @@ impl Cipher { .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), ) .filter(users_organizations::user_uuid.eq(user_uuid)) + // Only allow group access via a confirmed membership, since + // groups_users rows are kept when a membership is revoked. + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage)) .load::<(bool, bool, bool)>(conn) .expect("Error getting group access restrictions") diff --git a/src/db/models/group.rs b/src/db/models/group.rs index 32e9333f..d39a15e7 100644 --- a/src/db/models/group.rs +++ b/src/db/models/group.rs @@ -13,7 +13,7 @@ use crate::{ }; use macros::UuidFromParam; -use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId}; +use super::{CollectionId, Membership, MembershipId, MembershipStatus, OrganizationId, User, UserId}; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = groups)] @@ -268,6 +268,9 @@ impl Group { .and(users_organizations::org_uuid.eq(groups::organizations_uuid))), ) .filter(users_organizations::user_uuid.eq(user_uuid)) + // Only allow full access via a confirmed membership, since + // groups_users rows are kept when a membership is revoked. + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(groups::organizations_uuid.eq(org_uuid)) .filter(groups::access_all.eq(true)) .select(groups::access_all) From 32098ca7d1b94abce848b03e66545442a5d0e836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 23 Sep 2026 23:22:43 +0200 Subject: [PATCH 10/30] Require confirmed membership for user access checks, not for admin views (#7763) --- src/api/core/ciphers.rs | 16 +++---- src/api/core/organizations.rs | 22 ++++++---- src/db/models/cipher.rs | 5 +++ src/db/models/collection.rs | 19 ++++++++- src/db/models/group.rs | 2 + src/db/models/organization.rs | 80 ++++++++++------------------------- 6 files changed, 68 insertions(+), 76 deletions(-) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index a5b7e58b..a8c6aea0 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -2180,8 +2180,15 @@ impl CipherSyncData { } } + // Generate a HashMap with the Organization UUID as key and the Membership record + let members: HashMap = Membership::find_confirmed_by_user(user_id, conn) + .await + .into_iter() + .map(|m| (m.org_uuid.clone(), m)) + .collect(); + // Generate a list of Cipher UUID's containing a Vec with one or more Attachment records - let orgs = Membership::get_orgs_by_user(user_id, conn).await; + let orgs: Vec = members.keys().cloned().collect(); let attachments = Attachment::find_all_by_user_and_orgs(user_id, &orgs, conn).await; let mut cipher_attachments: HashMap> = HashMap::with_capacity(attachments.len()); for attachment in attachments { @@ -2196,13 +2203,6 @@ impl CipherSyncData { cipher_collections.entry(cipher).or_default().push(collection); } - // Generate a HashMap with the Organization UUID as key and the Membership record - let members: HashMap = Membership::find_confirmed_by_user(user_id, conn) - .await - .into_iter() - .map(|m| (m.org_uuid.clone(), m)) - .collect(); - // Generate a HashMap with the User_Collections UUID as key and the CollectionUser record let user_collections: HashMap = CollectionUser::find_by_user(user_id, conn) .await diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 36297d30..046be793 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -415,8 +415,9 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea let col_users = CollectionUser::find_by_organization_swap_user_uuid_with_member_uuid(&org_id, &conn).await; // Generate a HashMap to get the correct MembershipType per user to determine the manage permission // We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser + // This lists other members for admins, so it must not depend on the membership status let membership_type: HashMap = - Membership::find_confirmed_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect(); + Membership::find_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect(); // check if current user has full access to the organization (either directly or via any group) let has_full_access_to_org = member.has_full_access() @@ -818,11 +819,9 @@ async fn get_org_collection_detail( // Generate a HashMap to get the correct MembershipType per user to determine the manage permission // We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser - let membership_type: HashMap = Membership::find_confirmed_by_org(&org_id, &conn) - .await - .into_iter() - .map(|m| (m.uuid, m.atype)) - .collect(); + // This lists other members for admins, so it must not depend on the membership status + let membership_type: HashMap = + Membership::find_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect(); let users: Vec = CollectionUser::find_by_org_and_coll_swap_user_uuid_with_member_uuid(&org_id, &collection.uuid, &conn) @@ -960,8 +959,9 @@ async fn get_members( let mut users_json = Vec::new(); for u in Membership::find_by_org(&org_id, &conn).await { + // The user can be a manager instead of an admin, but we've checked above that they have full access users_json.push( - u.to_json_user_details( + u.to_json_details_for_admin( data.include_collections.unwrap_or(false), data.include_groups.unwrap_or(false), &conn, @@ -1516,7 +1516,9 @@ async fn get_user( // In this case, when groups are requested we also need to include collections. // Else these will not be shown in the interface, and could lead to missing collections when saved. let include_groups = data.include_groups.unwrap_or(false); - Ok(Json(user.to_json_user_details(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await)) + Ok(Json( + user.to_json_details_for_admin(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await, + )) } #[derive(Deserialize)] @@ -1835,6 +1837,10 @@ async fn post_org_import( if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } + // OrgMemberHeaders also allows invited and accepted members, which are not allowed to import + if headers.membership.status != MembershipStatus::Confirmed as i32 { + err!("You need to be a Member of the Organization to call this endpoint") + } let data: ImportData = data.into_inner(); // Validate the import before continuing diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index fae8aaec..4a8ba1c1 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -1003,6 +1003,7 @@ impl Cipher { .eq(ciphers_collections::collection_uuid) .and(collections_groups::groups_uuid.eq(groups::uuid))), ) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter( users_organizations::access_all .eq(true) // User has access all @@ -1034,6 +1035,7 @@ impl Cipher { .eq(ciphers_collections::collection_uuid) .and(users_collections::user_uuid.eq(user_uuid.clone()))), ) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter( users_organizations::access_all .eq(true) // User has access all @@ -1078,6 +1080,7 @@ impl Cipher { .eq(ciphers_collections::collection_uuid) .and(collections_groups::groups_uuid.eq(groups::uuid))), ) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter( users_organizations::access_all .eq(true) // User has access all @@ -1110,6 +1113,7 @@ impl Cipher { .eq(ciphers_collections::collection_uuid) .and(users_collections::user_uuid.eq(user_uuid.clone()))), ) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter( users_organizations::access_all .eq(true) // User has access all @@ -1161,6 +1165,7 @@ impl Cipher { .or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner .or_filter(groups::access_all.eq(true)) //Access via group .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .select(ciphers_collections::all_columns) .distinct() .load::<(CipherId, CollectionId)>(conn) diff --git a/src/db/models/collection.rs b/src/db/models/collection.rs index be108f13..21387e4d 100644 --- a/src/db/models/collection.rs +++ b/src/db/models/collection.rs @@ -367,6 +367,7 @@ impl Collection { .and(collections_groups::collections_uuid.eq(collections::uuid))), ) .filter(collections::uuid.eq(uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter( users_collections::collection_uuid .eq(uuid) @@ -406,6 +407,7 @@ impl Collection { .and(users_organizations::user_uuid.eq(user_uuid))), ) .filter(collections::uuid.eq(uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_collections::collection_uuid.eq(uuid).or( // Directly accessed collection users_organizations::access_all.eq(true).or( @@ -430,7 +432,8 @@ impl Collection { .inner_join( users_organizations::table.on(collections::org_uuid .eq(users_organizations::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone()))), + .and(users_organizations::user_uuid.eq(user_uuid.clone())) + .and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))), ) .left_join( users_collections::table.on(users_collections::collection_uuid @@ -476,7 +479,8 @@ impl Collection { .inner_join( users_organizations::table.on(collections::org_uuid .eq(users_organizations::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone()))), + .and(users_organizations::user_uuid.eq(user_uuid.clone())) + .and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))), ) .left_join( users_collections::table.on(users_collections::collection_uuid @@ -527,6 +531,7 @@ impl Collection { .and(collections_groups::collections_uuid.eq(collections::uuid))), ) .filter(collections::uuid.eq(&self.uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter( users_collections::collection_uuid .eq(&self.uuid) @@ -586,6 +591,7 @@ impl Collection { .and(collections_groups::collections_uuid.eq(collections::uuid))), ) .filter(collections::uuid.eq(&uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter( users_collections::collection_uuid .eq(&uuid) @@ -650,6 +656,7 @@ impl Collection { .and(collections_groups::collections_uuid.eq(collections::uuid))), ) .filter(collections::org_uuid.eq(&org_uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter( // Manage permission on a collection assigned directly or via a group. users_collections::manage.eq(true).or(collections_groups::manage.eq(true)), @@ -819,10 +826,18 @@ impl CollectionUser { .await } + // Only returns the collections of organizations the user is a confirmed member of pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { conn.run(move |conn| { users_collections::table + .inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid))) + .inner_join( + users_organizations::table.on(users_organizations::org_uuid + .eq(collections::org_uuid) + .and(users_organizations::user_uuid.eq(users_collections::user_uuid))), + ) .filter(users_collections::user_uuid.eq(user_uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .select(users_collections::all_columns) .load::(conn) .expect("Error loading users_collections") diff --git a/src/db/models/group.rs b/src/db/models/group.rs index d39a15e7..347b3235 100644 --- a/src/db/models/group.rs +++ b/src/db/models/group.rs @@ -249,6 +249,7 @@ impl Group { .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), ) .filter(users_organizations::user_uuid.eq(user_uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(groups::access_all.eq(true)) .select(groups::organizations_uuid) .distinct() @@ -387,6 +388,7 @@ impl CollectionGroup { .and(collections::org_uuid.eq(groups::organizations_uuid))), ) .filter(users_organizations::user_uuid.eq(user_uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .select(collections_groups::all_columns) .load::(conn) .expect("Error loading user collection groups") diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index d615a3fc..353a406e 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -1,7 +1,4 @@ -use std::{ - cmp::Ordering, - collections::{HashMap, HashSet}, -}; +use std::cmp::Ordering; use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; @@ -24,8 +21,8 @@ use crate::{ use macros::UuidFromParam; use super::{ - Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, - OrgPolicyType, TwoFactor, User, UserId, + Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType, + TwoFactor, User, UserId, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -536,7 +533,13 @@ impl Membership { }) } - pub async fn to_json_user_details(&self, include_collections: bool, include_groups: bool, conn: &DbConn) -> Value { + // Used by admins to view other members, so nothing here may depend on this member's status + pub async fn to_json_details_for_admin( + &self, + include_collections: bool, + include_groups: bool, + conn: &DbConn, + ) -> Value { let user = User::find_by_uuid(&self.user_uuid, conn).await.unwrap(); // Because BitWarden want the status to be -1 for revoked users we need to catch that here. @@ -559,52 +562,23 @@ impl Membership { // Check if a user is in a group which has access to all collections // If that is the case, we should not return individual collections! + // This is used by admins to view other members, so it must not depend on the membership status let full_access_group = - CONFIG.org_groups_enabled() && Group::is_in_full_access_group(&self.user_uuid, &self.org_uuid, conn).await; + CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await; // If collections are to be included, only include them if the user does not have full access via a group or defined to the user it self + // Only the collections assigned directly are returned, the ones assigned via a group are returned via a special group endpoint let collections: Vec = if include_collections && !(full_access_group || self.access_all) { - // Get all collections for the user here already to prevent more queries - let cu: HashMap = - CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) - .await - .into_iter() - .map(|cu| (cu.collection_uuid.clone(), cu)) - .collect(); - - // Get all collection groups for this user to prevent there inclusion - let cg: HashSet = CollectionGroup::find_by_user(&self.user_uuid, conn) - .await - .into_iter() - .map(|cg| cg.collections_uuid) - .collect(); - - Collection::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) + CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) .await .into_iter() - .filter_map(|c| { - let (read_only, hide_passwords, manage) = if self.has_full_access() { - (false, false, self.atype >= MembershipType::Manager) - } else if let Some(cu) = cu.get(&c.uuid) { - ( - cu.read_only, - cu.hide_passwords, - cu.manage || (self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords), - ) - // If previous checks failed it might be that this user has access via a group, but we should not return those elements here - // Those are returned via a special group endpoint - } else if cg.contains(&c.uuid) { - return None; - } else { - (true, true, false) - }; - - Some(json!({ - "id": c.uuid, - "readOnly": read_only, - "hidePasswords": hide_passwords, - "manage": manage, - })) + .map(|cu| { + json!({ + "id": cu.collection_uuid, + "readOnly": cu.read_only, + "hidePasswords": cu.hide_passwords, + "manage": cu.manage || (self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords), + }) }) .collect() } else { @@ -1032,17 +1006,6 @@ impl Membership { .await } - pub async fn get_orgs_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - conn.run(move |conn| { - users_organizations::table - .filter(users_organizations::user_uuid.eq(user_uuid)) - .select(users_organizations::org_uuid) - .load::(conn) - .unwrap_or_default() - }) - .await - } - pub async fn find_by_user_and_policy(user_uuid: &UserId, policy_type: OrgPolicyType, conn: &DbConn) -> Vec { conn.run(move |conn| { users_organizations::table @@ -1126,6 +1089,7 @@ impl Membership { .and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))), ) .filter(users_organizations::user_uuid.eq(user_uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter( users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]), ) From 061694d0cb3bbf5d4c7e920c892824f0020cff83 Mon Sep 17 00:00:00 2001 From: Mathijs van Veluw Date: Fri, 25 Sep 2026 21:53:06 +0200 Subject: [PATCH 11/30] Fix cortex-a53 build issues when using xx-cargo (#7774) When using xx-cargo it uses a different linker then the default one. This causes some default fixes Debian/Ubuntu add for the Cortex-A53 architectures to not be included. This commit fixes that by checking if the target architecture is arm64 and if so, will add the needed fix argument. This should prevent issues reported here #7754. We can not say this with 100% certainty since this bug sometimes appears and sometimes it does not. Fixes #7754 Signed-off-by: BlackDex --- docker/Dockerfile.debian | 24 ++++++++++++++++++++---- docker/Dockerfile.j2 | 12 ++++++++++-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index b8490609..62dff072 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -101,9 +101,17 @@ RUN . /env-cargo && \ # https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977 # https://github.com/dani-garcia/vaultwarden/discussions/7522 if xx-info is-cross; then \ - XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ - export XX_RUSTFLAGS; \ + XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ fi && \ + # Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang + # Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied + # This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip + # See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548 + # See: https://github.com/dani-garcia/vaultwarden/issues/7754 + if [ "$(xx-info arch)" = "arm64" ]; then \ + XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \ + fi && \ + export XX_RUSTFLAGS && \ PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \ find . -not -path "./target*" -delete @@ -123,9 +131,17 @@ RUN . /env-cargo && \ # https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977 # https://github.com/dani-garcia/vaultwarden/discussions/7522 if xx-info is-cross; then \ - XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ - export XX_RUSTFLAGS; \ + XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ + fi && \ + # Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang + # Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied + # This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip + # See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548 + # See: https://github.com/dani-garcia/vaultwarden/issues/7754 + if [ "$(xx-info arch)" = "arm64" ]; then \ + XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \ fi && \ + export XX_RUSTFLAGS && \ PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \ if [ "${CARGO_PROFILE}" = "dev" ] ; then \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ diff --git a/docker/Dockerfile.j2 b/docker/Dockerfile.j2 index 633d6955..2dc175b0 100644 --- a/docker/Dockerfile.j2 +++ b/docker/Dockerfile.j2 @@ -32,9 +32,17 @@ # https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977 # https://github.com/dani-garcia/vaultwarden/discussions/7522 if xx-info is-cross; then \ - XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ - export XX_RUSTFLAGS; \ + XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ fi && \ + # Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang + # Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied + # This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip + # See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548 + # See: https://github.com/dani-garcia/vaultwarden/issues/7754 + if [ "$(xx-info arch)" = "arm64" ]; then \ + XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \ + fi && \ + export XX_RUSTFLAGS && \ PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" {%- endmacro %} FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault From 4444442075b02d55abef7a071da642312d4abf1e Mon Sep 17 00:00:00 2001 From: Pier Carlo Cadoppi Date: Sat, 3 Oct 2026 12:49:37 +0100 Subject: [PATCH 12/30] Add `undetermined-cipher-scenario-logic` feature flag (#7802) Introduced in Browser 2026.2.0 via bitwarden/clients#18395. Rewrites the post-submit add/update login notification triggering logic, deferring cipher-type inference until the vault is unlocked. Client-side only. --- .env.template | 1 + src/config.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/.env.template b/.env.template index 62231776..5a7cce8d 100644 --- a/.env.template +++ b/.env.template @@ -409,6 +409,7 @@ ## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0) +## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0) # EXPERIMENTAL_CLIENT_FEATURE_FLAGS= ## Require new device emails. When a user logs in an email is required to be sent. diff --git a/src/config.rs b/src/config.rs index 9f0ae2e1..0eea0231 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1427,6 +1427,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "pm-5594-safari-account-switching", "pm-32413-multi-client-password-management", // Autofill Team + "undetermined-cipher-scenario-logic", "enable-basic-auth-response", "ssh-agent", "ssh-agent-v2", From cb89088bc311c5b5615c11c018122a4dfcf26557 Mon Sep 17 00:00:00 2001 From: Julian Speckmann Date: Sat, 3 Oct 2026 13:49:44 +0200 Subject: [PATCH 13/30] Add Windows native credential sync to supported feature flags (#7798) --- src/config.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config.rs b/src/config.rs index 0eea0231..05b01bdd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1435,6 +1435,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "ssh-key-vault-item", "pm-25373-windows-biometrics-v2", "pm-26340-linux-biometrics-v2", + "windows-native-credential-sync", // Mobile Team "anon-addy-self-host-alias", "simple-login-self-host-alias", From f9f011d50cc840d02d0b8a59f87511e943381948 Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:49:53 +0200 Subject: [PATCH 14/30] Hide the whole change-email section when EMAIL_CHANGE_ALLOWED is false (#7759) --- src/api/web.rs | 1 + src/static/templates/scss/vaultwarden.scss.hbs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/api/web.rs b/src/api/web.rs index d6d8d62c..aa67e614 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -89,6 +89,7 @@ fn vaultwarden_css() -> EtagCached> { // Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS let css_options = json!({ + "email_change_allowed": CONFIG.email_change_allowed(), "emergency_access_allowed": CONFIG.emergency_access_allowed(), "load_user_scss": true, "mail_2fa_enabled": CONFIG._enable_email_2fa(), diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 5bbe5db2..4b83ba04 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -220,6 +220,13 @@ auth-input-password > form > bit-form-field:nth-child(4) { @extend %vw-hide; } {{/unless}} + +{{#unless email_change_allowed}} +/* Hide the whole `Change email` section, including its heading, if not allowed */ +div:has(> app-change-email) { + @extend %vw-hide; +} +{{/unless}} /**** End Dynamic Vaultwarden Changes ****/ /**** Include a special user stylesheet for custom changes ****/ {{#if load_user_scss}} From 3714b504f6eb4f222b621ece7e1ba9fdfc813f05 Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:50:29 +0200 Subject: [PATCH 15/30] Fix Clippy warnings across all targets (#7782) --- src/api/admin.rs | 26 +++++++++++++------------- src/http_client.rs | 18 +++++++++--------- src/util.rs | 14 +++++++------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index 4bdf8e71..69c018ee 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -905,23 +905,23 @@ mod tests { fn validate_web_vault_compare() { // web_vault_compare(active, latest) // Test normal versions - assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1); - assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0); - assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1); + assert_eq!(web_vault_compare("2025.12.0", "2025.12.1"), -1); + assert_eq!(web_vault_compare("2025.12.1", "2025.12.1"), 0); + assert_eq!(web_vault_compare("2025.12.2", "2025.12.1"), 1); // Test patched/+build.n versions // Newer latest version - assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1); - assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1); - assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1); - assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1); + assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1"), -1); + assert_eq!(web_vault_compare("2025.12.1", "2025.12.1+build.1"), -1); + assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1"), -1); + assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2"), -1); // Equal versions - assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0); - assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0); + assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1"), 0); + assert_eq!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2"), 0); // Newer active version - assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1); - assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1); - assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1); - assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1); + assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1"), 1); + assert_eq!(web_vault_compare("2025.12.2", "2025.12.1+build.1"), 1); + assert_eq!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1"), 1); + assert_eq!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2"), 1); } } diff --git a/src/http_client.rs b/src/http_client.rs index 5ef293fc..6225e233 100644 --- a/src/http_client.rs +++ b/src/http_client.rs @@ -466,7 +466,7 @@ mod tests { #[test] fn dotted_decimal_loopback_normalizes() { let ip = parse_to_ip("127.0.0.1").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); assert!(!is_global_hardcoded(ip)); } @@ -474,21 +474,21 @@ mod tests { fn single_decimal_loopback_normalizes() { // 127.0.0.1 == 2130706433 let ip = parse_to_ip("2130706433").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); assert!(!is_global_hardcoded(ip)); } #[test] fn hex_loopback_normalizes() { let ip = parse_to_ip("0x7f000001").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); assert!(!is_global_hardcoded(ip)); } #[test] fn dotted_hex_loopback_normalizes() { let ip = parse_to_ip("0x7f.0.0.1").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); assert!(!is_global_hardcoded(ip)); } @@ -496,14 +496,14 @@ mod tests { fn octal_loopback_normalizes() { // 017700000001 == 127.0.0.1 let ip = parse_to_ip("017700000001").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); assert!(!is_global_hardcoded(ip)); } #[test] fn dotted_octal_loopback_normalizes() { let ip = parse_to_ip("0177.0.0.01").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); assert!(!is_global_hardcoded(ip)); } @@ -535,19 +535,19 @@ mod tests { #[test] fn get_valid_host_normalizes_decimal_int() { let h = get_valid_host("2130706433").expect("valid"); - assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1))); + assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST)); } #[test] fn get_valid_host_normalizes_hex() { let h = get_valid_host("0x7f000001").expect("valid"); - assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1))); + assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST)); } #[test] fn get_valid_host_normalizes_octal() { let h = get_valid_host("017700000001").expect("valid"); - assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1))); + assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST)); } // === diff --git a/src/util.rs b/src/util.rs index 6de2d803..2a5678bc 100644 --- a/src/util.rs +++ b/src/util.rs @@ -943,15 +943,15 @@ mod tests { use std::net::IpAddr; #[test] - #[ignore] + #[ignore = "exhaustive IPv4 check is too slow for the regular test suite"] fn test_ipv4_global() { for a in 0..u8::MAX { - println!("Iter: {}/255", a); + println!("Iter: {a}/255"); for b in 0..u8::MAX { for c in 0..u8::MAX { for d in 0..u8::MAX { let ip = IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, d)); - assert_eq!(ip.is_global(), is_global_hardcoded(ip), "IP mismatch: {}", ip) + assert_eq!(ip.is_global(), is_global_hardcoded(ip), "IP mismatch: {ip}"); } } } @@ -959,15 +959,15 @@ mod tests { } #[test] - #[ignore] + #[ignore = "random IPv6 check is too slow for the regular test suite"] fn test_ipv6_global() { - use rand::Rng; + use rand::RngExt; std::thread::scope(|s| { for t in 0..16 { - let handle = s.spawn(move || { + let _handle = s.spawn(move || { let mut v = [0u8; 16]; - let mut rng = rand::thread_rng(); + let mut rng = rand::rng(); for i in 0..20 { println!("Thread {t} Iter: {i}/50"); From 1c89e1772bfe51edf362fd033cf9c492ccd7ee55 Mon Sep 17 00:00:00 2001 From: Timshel Date: Sat, 3 Oct 2026 12:01:26 +0000 Subject: [PATCH 16/30] 2fa email fallback need a verified email (#7770) Co-authored-by: Timshel Co-authored-by: Mathijs van Veluw --- src/api/core/organizations.rs | 11 ++++++----- src/auth.rs | 2 +- src/db/models/user.rs | 6 +++++- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 046be793..4a3ddcb9 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -3038,11 +3038,12 @@ async fn recover_account( err!("Organization user must be confirmed for password reset functionality"); } - let fallback_2fa_email = if req.reset_two_factor && CONFIG.email_2fa_auto_fallback() { - TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none() - } else { - false - }; + let fallback_2fa_email = + if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() { + TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none() + } else { + false + }; // Sending email first ensure working email configuration and the resulting user notification. // Also this might add some protection against security flaws and misuse diff --git a/src/auth.rs b/src/auth.rs index 07373389..39ce2732 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -253,7 +253,7 @@ impl LoginJwtClaims { premium: true, name: user.name.clone(), email: user.email.clone(), - email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(), + email_verified: user.verified(), // --- // Disabled these keys to be added to the JWT since they could cause the JWT to get too large diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 3412b142..b4f507f4 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -257,6 +257,10 @@ impl User { /// Database methods impl User { + pub fn verified(&self) -> bool { + !CONFIG.mail_enabled() || self.verified_at.is_some() + } + pub async fn to_json(&self, conn: &DbConn) -> Value { let mut orgs_json = Vec::new(); for c in Membership::find_confirmed_by_user(&self.uuid, conn).await { @@ -299,7 +303,7 @@ impl User { "id": self.uuid, "name": self.name, "email": self.email, - "emailVerified": !CONFIG.mail_enabled() || self.verified_at.is_some(), + "emailVerified": self.verified(), "premium": true, "premiumFromOrganization": false, "culture": "en-US", From f4f1a8e105ec5fd72ec1dd1bed800bcf44deae2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Sat, 3 Oct 2026 23:05:56 +0200 Subject: [PATCH 17/30] Send API cleanup: remove legacy endpoints and align access with upstream (#7806) --- src/api/core/accounts.rs | 8 +- src/api/core/sends.rs | 196 ++++----------------------------------- src/auth/send.rs | 6 +- src/db/models/cipher.rs | 1 + src/db/models/send.rs | 30 ++---- 5 files changed, 32 insertions(+), 209 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 8cc5e55b..a6787da0 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -32,7 +32,7 @@ use crate::{ use super::{ ciphers::{CipherData, update_cipher_from_data}, - sends::{SendData, update_send_from_data}, + sends::SendData, }; pub fn routes() -> Vec { @@ -982,11 +982,13 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: // Update send data for send_data in data.account_data.sends { - let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else { + let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id)) else { err!("Send doesn't exist") }; - update_send_from_data(send, send_data, &headers, &conn, &nt, UpdateType::None).await?; + // Like upstream, only the key changes on rotation + send.akey = send_data.key; + send.save(&conn).await?; } // Update cipher data diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 042ce95b..bfff4e96 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -12,7 +12,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, - auth::{ClientIp, Headers, Host, SendHeaders}, + auth::{Headers, Host, SendHeaders}, config::PathType, db::{ DbConn, DbPool, @@ -46,11 +46,8 @@ pub fn routes() -> Vec { get_sends, get_send, post_send, - post_send_file, post_access, - post_access_legacy, post_access_file, - post_access_file_legacy, put_send, delete_send, put_remove_password, @@ -73,7 +70,7 @@ pub async fn purge_sends(pool: DbPool) { #[serde(rename_all = "camelCase")] pub struct SendData { r#type: i32, - key: String, + pub key: String, password: Option, max_access_count: Option, expiration_date: Option>, @@ -201,7 +198,7 @@ async fn post_send(data: Json, headers: Headers, conn: DbConn, nt: Not enforce_disable_hide_email_policy(&data, &headers, &conn).await?; if data.r#type == SendType::File as i32 { - err!("File sends should use /api/sends/file") + err!("File sends should use /api/sends/file/v2") } let mut send = create_send(data, headers.user.uuid)?; @@ -218,93 +215,11 @@ async fn post_send(data: Json, headers: Headers, conn: DbConn, nt: Not Ok(Json(send.to_json())) } -#[derive(FromForm)] -struct UploadData<'f> { - model: Json, - data: TempFile<'f>, -} - #[derive(FromForm)] struct UploadDataV2<'f> { data: TempFile<'f>, } -// @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads (v2). -// This method still exists to support older clients, probably need to remove it sometime. -// Upstream: https://github.com/bitwarden/server/blob/d0c793c95181dfb1b447eb450f85ba0bfd7ef643/src/Api/Controllers/SendsController.cs#L164-L167 -// 2025: This endpoint doesn't seem to exists anymore in the latest version -// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs -#[post("/sends/file", format = "multipart/form-data", data = "")] -async fn post_send_file(data: Form>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - enforce_disable_send_policy(&headers, &conn).await?; - - let UploadData { - model, - data, - } = data.into_inner(); - let model = model.into_inner(); - - let Some(size) = data.len().to_i64() else { - err!("Invalid send size"); - }; - if size < 0 { - err!("Send size can't be negative") - } - - enforce_disable_hide_email_policy(&model, &headers, &conn).await?; - - let size_limit = match CONFIG.user_send_limit() { - Some(0) => err!("File uploads are disabled"), - Some(limit_kb) => { - let Some(already_used) = Send::size_by_user(&headers.user.uuid, &conn).await else { - err!("Existing sends overflow") - }; - let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else { - err!("Send size overflow"); - }; - if left <= 0 { - err!("Send storage limit reached! Delete some sends to free up space") - } - i64::clamp(left, 0, SIZE_525_MB) - } - None => SIZE_525_MB, - }; - - if size > size_limit { - err!("Send storage limit exceeded with this file"); - } - - let mut send = create_send(model, headers.user.uuid)?; - if send.atype != SendType::File as i32 { - err!("Send content is not a file"); - } - - let file_id = crate::crypto::generate_send_file_id(); - - save_temp_file(&PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?; - - let mut data_value: Value = serde_json::from_str(&send.data)?; - if let Some(o) = data_value.as_object_mut() { - o.insert(String::from("id"), Value::String(file_id)); - o.insert(String::from("size"), Value::Number(size.into())); - o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(size))); - } - send.data = serde_json::to_string(&data_value)?; - - // Save the changes in the database - send.save(&conn).await?; - nt.send_send_update( - UpdateType::SyncSendCreate, - &send, - &send.update_users_revision(&conn).await, - &headers.device, - &conn, - ) - .await; - - Ok(Json(send.to_json())) -} - // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165 #[post("/sends/file/v2", data = "")] async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) -> JsonResult { @@ -450,63 +365,16 @@ async fn post_send_file_v2_data( #[post("/sends/access")] async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult { - let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else { + let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else { err_code!(SEND_INACCESSIBLE_MSG, 404) }; if !send.is_accessible() { err_code!(SEND_INACCESSIBLE_MSG, 404) } - process_access(send, conn, nt).await -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SendAccessData { - pub password: Option, -} - -// Legacy since web-2026.6.0 -#[post("/sends/access/", data = "")] -async fn post_access_legacy( - access_id: &str, - data: Json, - conn: DbConn, - ip: ClientIp, - nt: Notify<'_>, -) -> JsonResult { - crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; - - let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else { - err_code!(SEND_INACCESSIBLE_MSG, 404) - }; - - if let Some(max_access_count) = send.max_access_count - && send.access_count >= max_access_count - { - err_code!(SEND_INACCESSIBLE_MSG, 404); - } - - if !send.is_accessible() { - err_code!(SEND_INACCESSIBLE_MSG, 404) - } - - if send.password_hash.is_some() { - match data.into_inner().password { - Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } - Some(_) => err!("Invalid password", format!("IP: {}.", ip.ip)), - None => err_code!("Password not provided", format!("IP: {}.", ip.ip), 401), - } - } - // Files are incremented during the download - if send.atype == SendType::Text as i32 { - if !send.register_access(&conn).await? { - err_code!(SEND_INACCESSIBLE_MSG, 404) - } - } else { - send.save(&conn).await?; + if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? { + err_code!(SEND_INACCESSIBLE_MSG, 404) } - process_access(send, conn, nt).await } @@ -531,55 +399,27 @@ async fn post_access_file( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else { + let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else { err_code!(SEND_INACCESSIBLE_MSG, 404) }; if !send.is_accessible() { err_code!(SEND_INACCESSIBLE_MSG, 404) } - process_access_file(send, file_id, host, conn, nt).await -} - -// Legacy since web-2026.6.0 -#[post("/sends//access/file/", data = "")] -async fn post_access_file_legacy( - send_id: SendId, - file_id: SendFileId, - data: Json, - host: Host, - conn: DbConn, - ip: ClientIp, - nt: Notify<'_>, -) -> JsonResult { - crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; - - let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else { - err_code!(SEND_INACCESSIBLE_MSG, 404) - }; - - if let Some(max_access_count) = send.max_access_count - && send.access_count >= max_access_count - { - err_code!(SEND_INACCESSIBLE_MSG, 404) - } - - if !send.is_accessible() { + check_send_file_id(&send, &file_id)?; + if !send.register_access(&conn).await? { err_code!(SEND_INACCESSIBLE_MSG, 404) } + process_access_file(send, file_id, host, conn, nt).await +} - if send.password_hash.is_some() { - match data.into_inner().password { - Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } - Some(_) => err!("Invalid password."), - None => err_code!("Password not provided", 401), - } +fn check_send_file_id(send: &Send, file_id: &SendFileId) -> EmptyResult { + if send.atype != SendType::File as i32 { + err!("Send is not a file type send."); } - - if !send.register_access(&conn).await? { - err_code!(SEND_INACCESSIBLE_MSG, 404) + match serde_json::from_str::(&send.data) { + Ok(data) if &data.id == file_id => Ok(()), + _ => err_code!(SEND_INACCESSIBLE_MSG, 404), } - - process_access_file(send, file_id, host, conn, nt).await } async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult { @@ -642,7 +482,7 @@ async fn put_send(send_id: SendId, data: Json, headers: Headers, conn: Ok(Json(send.to_json())) } -pub async fn update_send_from_data( +async fn update_send_from_data( send: &mut Send, data: SendData, headers: &Headers, diff --git a/src/auth/send.rs b/src/auth/send.rs index 2554488a..518b7098 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -75,7 +75,7 @@ impl SendTokens { return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false); }; - let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else { + let Some(send) = Send::find_by_uuid(&send_id, conn).await else { return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false); }; @@ -103,10 +103,6 @@ impl SendTokens { } } - if !send.register_access(conn).await? { - return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true); - } - Ok(Self { access_claims: generate_send_access_claims(&send_id), }) diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 4a8ba1c1..f39b0bfc 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -711,6 +711,7 @@ impl Cipher { // Only allow group access via a confirmed membership, since // groups_users rows are kept when a membership is revoked. .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) + .filter(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())) .select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage)) .load::<(bool, bool, bool)>(conn) .expect("Error getting group access restrictions") diff --git a/src/db/models/send.rs b/src/db/models/send.rs index d7de7749..721378f3 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -140,6 +140,10 @@ impl Send { None } + fn access_id(&self) -> String { + BASE64URL_NOPAD.encode(Uuid::parse_str(&self.uuid).unwrap_or_default().as_bytes()) + } + pub fn to_json(&self) -> Value { let mut data = serde_json::from_str::>(&self.data).map(|d| d.data).unwrap_or_default(); @@ -150,7 +154,7 @@ impl Send { json!({ "id": self.uuid, - "accessId": BASE64URL_NOPAD.encode(Uuid::parse_str(&self.uuid).unwrap_or_default().as_bytes()), + "accessId": self.access_id(), "type": self.atype, "name": self.name, @@ -182,7 +186,7 @@ impl Send { } json!({ - "id": self.uuid, + "id": self.access_id(), "type": self.atype, "name": self.name, @@ -256,7 +260,7 @@ impl Send { /// Whether the Send is currently within its validity window: not disabled, not past its /// expiration date, and not past its deletion date. Does not consider `max_access_count` - /// (consumed at token issuance) or the password. + /// (counted on each access) or the password. pub fn is_accessible(&self) -> bool { let now = Utc::now().naive_utc(); if self.disabled { @@ -309,19 +313,6 @@ impl Send { Ok(()) } - pub async fn find_by_access_id(access_id: &str, conn: &DbConn) -> Option { - let Ok(uuid_vec) = BASE64URL_NOPAD.decode(access_id.as_bytes()) else { - return None; - }; - - let uuid = match Uuid::from_slice(&uuid_vec) { - Ok(u) => SendId::from(u.to_string()), - Err(_) => return None, - }; - - Self::find_by_uuid(&uuid, conn).await - } - pub async fn find_by_uuid(uuid: &SendId, conn: &DbConn) -> Option { conn.run(move |conn| sends::table.filter(sends::uuid.eq(uuid)).first::(conn).ok()).await } @@ -361,13 +352,6 @@ impl Send { Some(total) } - pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - conn.run(move |conn| { - sends::table.filter(sends::organization_uuid.eq(org_uuid)).load::(conn).expect("Error loading sends") - }) - .await - } - pub async fn find_by_past_deletion_date(conn: &DbConn) -> Vec { let now = Utc::now().naive_utc(); conn.run(move |conn| { From 1f99d9d276c41107eeaddd086ef840c28279cb7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Sun, 4 Oct 2026 22:16:55 +0200 Subject: [PATCH 18/30] Remove legacy API endpoints and compatibility code dropped upstream (#7809) Remove endpoints, response fields, feature flags and compatibility code that upstream Bitwarden has removed and that no current client uses: - Legacy POST /identity/accounts/register and POST /api/accounts/prelogin; register/finish is rate limited like the other unauthenticated endpoints. - Legacy organization routes: PUT groups/{id}/users, PUT policies/{type}/vnext, PUT users/{id}/reset-password, GET billing/metadata, and the POST aliases of the organization, collection, group and member PUT/DELETE routes. - POST /two-factor/disable (PUT stays). - ResetMasterPassword, captchaBypassToken, the legacy registration name field, userIsManagedByOrganization/managedByOrganization and accessAll. - The automatic email 2FA code for clients older than 2025.5.0. - DUO_USE_IFRAME and the Duo Traditional Prompt code. - Experimental client feature flags that no client reads anymore. - Web vault CSS selectors for web vaults older than 2025.5.1. Also accept the PascalCase fields Android 2026.9.0+ sends on registration and password change, and align a few account, login/2FA and organization checks with upstream. Adds tests for the registration body formats. --- .env.template | 20 +- src/api/admin.rs | 1 + src/api/core/accounts.rs | 190 +++++++++------ src/api/core/ciphers.rs | 2 +- src/api/core/organizations.rs | 228 +----------------- src/api/core/two_factor/authenticator.rs | 3 +- src/api/core/two_factor/duo.rs | 119 +-------- src/api/core/two_factor/duo_oidc.rs | 4 +- src/api/core/two_factor/email.rs | 13 +- src/api/core/two_factor/mod.rs | 15 +- src/api/core/two_factor/webauthn.rs | 3 +- src/api/identity.rs | 164 ++++--------- src/config.rs | 46 +--- src/db/models/group.rs | 21 -- src/db/models/organization.rs | 8 +- src/main.rs | 2 +- .../templates/scss/vaultwarden.scss.hbs | 25 -- 17 files changed, 210 insertions(+), 654 deletions(-) diff --git a/.env.template b/.env.template index 5a7cce8d..43ed914f 100644 --- a/.env.template +++ b/.env.template @@ -180,7 +180,7 @@ ## Defaults to every minute. Set blank to disable this job. # AUTH_REQUEST_PURGE_SCHEDULE="30 * * * * *" ## -## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt. +## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled. ## Defaults to every minute. Set blank to disable this job. # DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *" # @@ -391,23 +391,10 @@ ## The following flags are available: ## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0) ## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0) -## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0) ## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) -## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0) -## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0) -## - "pm-26340-linux-biometrics-v2": Enable the new implementation of biometrics on Linux. (Desktop >= 2025.11.0) -## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0) -## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0) -## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0) -## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2) -## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2) ## - "pm-30529-webauthn-related-origins": ## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0) ## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1) -## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0) -## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0) -## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0) -## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0) ## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0) # EXPERIMENTAL_CLIENT_FEATURE_FLAGS= @@ -588,11 +575,6 @@ # DUO_HOST= ## After that, you should be able to follow the rest of the guide linked above, ## ignoring the fields that ask for the values that you already configured beforehand. -## -## If you want to attempt to use Duo's 'Traditional Prompt' (deprecated, iframe based) set DUO_USE_IFRAME to 'true'. -## Duo no longer supports this, but it still works for some integrations. -## If you aren't sure, leave this alone. -# DUO_USE_IFRAME=false ## Email 2FA settings ## Email token size diff --git a/src/api/admin.rs b/src/api/admin.rs index 69c018ee..2a84f611 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -508,6 +508,7 @@ async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> Empty async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult { let mut user = get_user_or_404(&user_id, &conn).await?; TwoFactor::delete_all_by_user(&user.uuid, &conn).await?; + Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?; two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?; user.totp_recover = None; user.save(&conn).await diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index a6787da0..930107c9 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -59,7 +59,6 @@ pub fn routes() -> Vec { delete_account, revision_date, password_hint, - post_prelogin, verify_password, post_api_key, rotate_api_key, @@ -106,11 +105,8 @@ pub struct RegisterData { master_password_hint: Option, - name: Option, - organization_user_id: Option, - // Used only from the register/finish endpoint email_verification_token: Option, accept_emergency_access_id: Option, accept_emergency_access_invite_token: Option, @@ -163,7 +159,9 @@ struct RegisterDataOld { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RegisterDataCur { + #[serde(alias = "MasterPasswordAuthentication")] master_password_authentication: MasterPasswordAuthentication, + #[serde(alias = "MasterPasswordUnlock")] master_password_unlock: MasterPasswordUnlock, } @@ -197,10 +195,12 @@ struct KeysData { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MasterPasswordAuthentication { + #[serde(alias = "Kdf")] kdf: KDFData, + #[serde(alias = "Salt")] salt: String, - #[serde(alias = "masterPasswordAuthenticationHash")] + #[serde(alias = "masterPasswordAuthenticationHash", alias = "MasterPasswordAuthenticationHash")] hash: String, } @@ -257,12 +257,11 @@ async fn is_email_2fa_required(member_id: Option, conn: &DbConn) - false } -pub async fn register(data: Json, email_verification: bool, conn: DbConn) -> JsonResult { - let mut data: RegisterData = data.into_inner(); +pub async fn register(data: Json, conn: DbConn) -> JsonResult { + let data: RegisterData = data.into_inner(); let email = data.email.to_lowercase(); - let mut email_verified = false; - + let mut name = None; let mut pending_emergency_access = None; if data.unprocessable() { @@ -270,69 +269,65 @@ pub async fn register(data: Json, email_verification: bool, conn: } // First, validate the provided verification tokens - if email_verification { - match ( - &data.email_verification_token, - &data.accept_emergency_access_id, - &data.accept_emergency_access_invite_token, - &data.organization_user_id, - &data.org_invite_token, - ) { - // Normal user registration, when email verification is required - (Some(email_verification_token), None, None, None, None) => { - let claims = crate::auth::decode_register_verify(email_verification_token)?; - if claims.sub != data.email { - err!("Email verification token does not match email"); - } - - // During this call we don't get the name, so extract it from the claims - if claims.name.is_some() { - data.name = claims.name; - } - email_verified = claims.verified; + let mut email_verified = match ( + &data.email_verification_token, + &data.accept_emergency_access_id, + &data.accept_emergency_access_invite_token, + &data.organization_user_id, + &data.org_invite_token, + ) { + // Normal user registration, when email verification is required + (Some(email_verification_token), None, None, None, None) => { + let claims = crate::auth::decode_register_verify(email_verification_token)?; + if claims.sub != data.email { + err!("Email verification token does not match email"); } - // Emergency access registration - (None, Some(accept_emergency_access_id), Some(accept_emergency_access_invite_token), None, None) => { - if !CONFIG.emergency_access_allowed() { - err!("Emergency access is not enabled.") - } - let claims = crate::auth::decode_emergency_access_invite(accept_emergency_access_invite_token)?; + // During this call we don't get the name, so extract it from the claims + name = claims.name; + claims.verified + } + // Emergency access registration + (None, Some(accept_emergency_access_id), Some(accept_emergency_access_invite_token), None, None) => { + if !CONFIG.emergency_access_allowed() { + err!("Emergency access is not enabled.") + } - if claims.email != data.email { - err!("Claim email does not match email") - } - if &claims.emer_id != accept_emergency_access_id { - err!("Claim emer_id does not match accept_emergency_access_id") - } + let claims = crate::auth::decode_emergency_access_invite(accept_emergency_access_invite_token)?; - pending_emergency_access = Some((accept_emergency_access_id, claims)); - email_verified = true; + if claims.email != data.email { + err!("Claim email does not match email") + } + if &claims.emer_id != accept_emergency_access_id { + err!("Claim emer_id does not match accept_emergency_access_id") } - // Org invite - (None, None, None, Some(organization_user_id), Some(org_invite_token)) => { - let claims = decode_invite(org_invite_token)?; - - if claims.email != data.email { - err!("Claim email does not match email") - } - if &claims.member_id != organization_user_id { - err!("Claim org_user_id does not match organization_user_id") - } + pending_emergency_access = Some((accept_emergency_access_id, claims)); + true + } + // Org invite + (None, None, None, Some(organization_user_id), Some(org_invite_token)) => { + let claims = decode_invite(org_invite_token)?; - email_verified = true; + if claims.email != data.email { + err!("Claim email does not match email") } - _ => { - err!("Registration is missing required parameters") + if &claims.member_id != organization_user_id { + err!("Claim org_user_id does not match organization_user_id") } + + true } - } + + _ => { + err!("Registration is missing required parameters") + } + }; // Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden) // This also prevents issues with very long usernames causing to large JWT's. See #2419 - if let Some(ref name) = data.name + if let Some(ref name) = name && name.len() > 50 { err!("The field Name must be a string with a maximum length of 50."); @@ -394,7 +389,7 @@ pub async fn register(data: Json, email_verification: bool, conn: user.password_hint = password_hint; // Add extra fields if present - if let Some(name) = data.name { + if let Some(name) = name { user.name = name; } @@ -432,8 +427,7 @@ pub async fn register(data: Json, email_verification: bool, conn: } Ok(Json(json!({ - "object": "register", - "captchaBypassToken": "", + "object": "registerFinish", }))) } @@ -442,7 +436,7 @@ async fn post_set_password(data: Json, headers: Headers, conn: let data: SetPasswordData = data.into_inner(); let mut user = headers.user; - if user.private_key.is_some() { + if user.private_key.is_some() || !user.password_hash.is_empty() { err!("Account already initialized, cannot set password") } @@ -496,7 +490,6 @@ async fn post_set_password(data: Json, headers: Headers, conn: Ok(Json(json!({ "object": "set-password", - "captchaBypassToken": "", }))) } @@ -581,6 +574,10 @@ async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> Json let mut user = headers.user; + if user.private_key.is_some() || user.public_key.is_some() { + err!("User has existing keypair") + } + user.private_key = Some(data.encrypted_private_key); user.public_key = Some(data.public_key); @@ -702,8 +699,11 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AuthenticationData { + #[serde(alias = "Salt")] salt: String, + #[serde(alias = "Kdf")] kdf: KDFData, + #[serde(alias = "MasterPasswordAuthenticationHash")] master_password_authentication_hash: String, } @@ -1360,11 +1360,6 @@ pub struct PreloginData { email: String, } -#[post("/accounts/prelogin", data = "")] -async fn post_prelogin(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { - prelogin(data, ip, conn).await -} - pub async fn prelogin(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; @@ -1856,3 +1851,62 @@ pub async fn purge_auth_requests(pool: DbPool) { error!("Failed to get DB connection while purging auth requests"); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_register_data_current_format() { + // Web and iOS since the master password authentication/unlock split + let data: RegisterData = serde_json::from_str( + r#"{ + "email": "user@example.com", + "emailVerificationToken": "token", + "userAsymmetricKeys": {"publicKey": "pub", "encryptedPrivateKey": "priv"}, + "masterPasswordAuthentication": { + "kdf": {"kdfType": 0, "iterations": 600000}, + "salt": "user@example.com", + "masterPasswordAuthenticationHash": "hash" + }, + "masterPasswordUnlock": { + "kdf": {"kdfType": 0, "iterations": 600000}, + "salt": "user@example.com", + "masterKeyWrappedUserKey": "key" + } + }"#, + ) + .unwrap(); + + assert!(!data.unprocessable()); + assert_eq!(data.hash(), "hash"); + assert_eq!(data.key(), "key"); + assert_eq!(data.kdf().kdf_iterations, 600_000); + assert!(data.keys.is_some()); + assert_eq!(data.email_verification_token.as_deref(), Some("token")); + } + + #[test] + fn test_register_data_flat_format() { + // Android before 2026.9.0 + let data: RegisterData = serde_json::from_str( + r#"{ + "email": "user@example.com", + "emailVerificationToken": "token", + "masterPasswordHash": "hash", + "masterPasswordHint": null, + "userSymmetricKey": "key", + "userAsymmetricKeys": {"publicKey": "pub", "encryptedPrivateKey": "priv"}, + "kdf": 0, + "kdfIterations": 600000 + }"#, + ) + .unwrap(); + + assert!(!data.unprocessable()); + assert_eq!(data.hash(), "hash"); + assert_eq!(data.key(), "key"); + assert_eq!(data.kdf().kdf_iterations, 600_000); + assert!(data.keys.is_some()); + } +} diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index a8c6aea0..5010bd92 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -1869,7 +1869,7 @@ async fn delete_cipher_by_uuid( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CipherIdsData { - ids: Vec, + ids: HashSet, } async fn delete_multiple_ciphers( diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 4a3ddcb9..f64b8c53 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -15,10 +15,10 @@ use crate::{ db::{ DbConn, models::{ - Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, - Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, - OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, - UserId, + Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device, + EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, + MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, + TwoFactorType, User, UserId, }, }, mail, @@ -31,7 +31,6 @@ pub fn routes() -> Vec { get_organization, create_organization, delete_organization, - post_delete_organization, leave_organization, get_user_collections, get_org_collections, @@ -39,13 +38,10 @@ pub fn routes() -> Vec { get_org_collection_detail, get_collection_users, put_organization, - post_organization, post_organization_collections, post_bulk_access_collections, - post_organization_collection_update, put_organization_collection_update, delete_organization_collection, - post_organization_collection_delete, bulk_delete_organization_collections, post_bulk_collections, get_org_details, @@ -60,7 +56,6 @@ pub fn routes() -> Vec { get_org_user_mini_details, get_user, edit_member, - put_member, delete_member, bulk_delete_member, post_org_import, @@ -70,7 +65,6 @@ pub fn routes() -> Vec { get_master_password_policy, get_policy, put_policy, - put_policy_vnext, get_plans, post_org_keys, get_organization_keys, @@ -86,22 +80,16 @@ pub fn routes() -> Vec { post_groups, get_group, put_group, - post_group, get_group_details, delete_group, - post_delete_group, bulk_delete_groups, get_group_members, - put_group_members, - post_delete_group_member, put_reset_password_enrollment, get_reset_password_details, - put_reset_password, put_recover_account, get_org_export, post_api_key, rotate_api_key, - get_billing_metadata, get_billing_warnings, get_auto_enroll_status, get_self_host_billing_metadata, @@ -245,16 +233,6 @@ async fn delete_organization( } } -#[post("/organizations//delete", data = "")] -async fn post_delete_organization( - org_id: OrganizationId, - data: Json, - headers: OwnerHeaders, - conn: DbConn, -) -> EmptyResult { - delete_organization(org_id, data, headers, conn).await -} - #[post("/organizations//leave")] async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> EmptyResult { if headers.membership.status != MembershipStatus::Confirmed as i32 { @@ -300,16 +278,6 @@ async fn put_organization( headers: OwnerHeaders, data: Json, conn: DbConn, -) -> JsonResult { - post_organization(org_id, headers, data, conn).await -} - -#[post("/organizations/", data = "")] -async fn post_organization( - org_id: OrganizationId, - headers: OwnerHeaders, - data: Json, - conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); @@ -640,17 +608,6 @@ async fn put_organization_collection_update( headers: ManagerHeaders, data: Json, conn: DbConn, -) -> JsonResult { - post_organization_collection_update(org_id, col_id, headers, data, conn).await -} - -#[post("/organizations//collections/", data = "", rank = 2)] -async fn post_organization_collection_update( - org_id: OrganizationId, - col_id: CollectionId, - headers: ManagerHeaders, - data: Json, - conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); @@ -746,16 +703,6 @@ async fn delete_organization_collection( delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await } -#[post("/organizations//collections//delete")] -async fn post_organization_collection_delete( - org_id: OrganizationId, - col_id: CollectionId, - headers: ManagerHeaders, - conn: DbConn, -) -> EmptyResult { - delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await -} - #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct BulkCollectionIds { @@ -1532,17 +1479,6 @@ struct EditUserData { } #[put("/organizations//users/", data = "", rank = 1)] -async fn put_member( - org_id: OrganizationId, - member_id: MembershipId, - data: Json, - headers: AdminHeaders, - conn: DbConn, -) -> EmptyResult { - edit_member(org_id, member_id, data, headers, conn).await -} - -#[post("/organizations//users/", data = "", rank = 1)] async fn edit_member( org_id: OrganizationId, member_id: MembershipId, @@ -1922,7 +1858,7 @@ async fn post_org_import( #[serde(rename_all = "camelCase")] struct BulkCollectionsData { organization_id: OrganizationId, - cipher_ids: Vec, + cipher_ids: HashSet, collection_ids: HashSet, remove_collections: bool, } @@ -2201,18 +2137,6 @@ async fn put_policy( Ok(Json(policy.to_json())) } -// Deprecated with client v2026.5.0 -#[put("/organizations//policies//vnext", data = "")] -async fn put_policy_vnext( - org_id: OrganizationId, - pol_type: i32, - data: Json, - headers: AdminHeaders, - conn: DbConn, -) -> JsonResult { - put_policy(org_id, pol_type, data, headers, conn).await -} - #[get("/plans")] fn get_plans() -> Json { // Respond with a minimal json just enough to allow the creation of an new organization. @@ -2241,12 +2165,6 @@ fn get_plans() -> Json { })) } -#[get("/organizations/<_org_id>/billing/metadata")] -fn get_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json { - // Prevent a 404 error, which also causes Javascript errors. - Json(empty_data_json()) -} - #[get("/organizations/<_org_id>/billing/vnext/warnings")] fn get_billing_warnings(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json { Json(json!({ @@ -2266,14 +2184,6 @@ fn get_self_host_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHe })) } -fn empty_data_json() -> Value { - json!({ - "object": "list", - "data": [], - "continuationToken": null - }) -} - #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct BulkRevokeMembershipIds { @@ -2592,17 +2502,6 @@ impl CollectionData { } } -#[post("/organizations//groups/", data = "")] -async fn post_group( - org_id: OrganizationId, - group_id: GroupId, - data: Json, - headers: AdminHeaders, - conn: DbConn, -) -> JsonResult { - put_group(org_id, group_id, data, headers, conn).await -} - #[post("/organizations//groups", data = "")] async fn post_groups( org_id: OrganizationId, @@ -2712,7 +2611,6 @@ async fn add_update_group( "id": group.uuid, "organizationId": group.organizations_uuid, "name": group.name, - "accessAll": group.access_all, "externalId": group.external_id, "object": "group" }))) @@ -2739,16 +2637,6 @@ async fn get_group_details( Ok(Json(group.to_json_details(&conn).await)) } -#[post("/organizations//groups//delete")] -async fn post_delete_group( - org_id: OrganizationId, - group_id: GroupId, - headers: AdminHeaders, - conn: DbConn, -) -> EmptyResult { - delete_group_impl(&org_id, &group_id, &headers, &conn).await -} - #[delete("/organizations//groups/")] async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> EmptyResult { delete_group_impl(&org_id, &group_id, &headers, &conn).await @@ -2850,90 +2738,6 @@ async fn get_group_members( Ok(Json(json!(group_members))) } -#[put("/organizations//groups//users", data = "")] -async fn put_group_members( - org_id: OrganizationId, - group_id: GroupId, - headers: AdminHeaders, - data: Json>, - conn: DbConn, -) -> EmptyResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - if !CONFIG.org_groups_enabled() { - err!("Group support is disabled"); - } - - if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() { - err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization") - } - - let assigned_members = data.into_inner(); - - let org_memberships = Membership::find_by_org(&org_id, &conn).await; - let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect(); - if let Some(e) = assigned_members.iter().find(|m| !org_membership_ids.contains(m)) { - err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id)) - } - - GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?; - for assigned_member in assigned_members { - let mut user_entry = GroupUser::new(group_id.clone(), assigned_member.clone()); - user_entry.save(&conn).await?; - - log_event( - EventType::OrganizationUserUpdatedGroups, - &assigned_member, - &org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; - } - - Ok(()) -} - -#[post("/organizations//groups//delete-user/")] -async fn post_delete_group_member( - org_id: OrganizationId, - group_id: GroupId, - member_id: MembershipId, - headers: AdminHeaders, - conn: DbConn, -) -> EmptyResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - if !CONFIG.org_groups_enabled() { - err!("Group support is disabled"); - } - - if Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await.is_none() { - err!("User could not be found or does not belong to the organization."); - } - - if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() { - err!("Group could not be found or does not belong to the organization."); - } - - log_event( - EventType::OrganizationUserUpdatedGroups, - &member_id, - &org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; - - GroupUser::delete_by_group_and_member(&group_id, &member_id, &conn).await -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrganizationUserResetPasswordEnrollmentRequest { @@ -2993,19 +2797,6 @@ async fn put_recover_account( recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await } -// Deprecated since `v2026.4.2` -#[put("/organizations//users//reset-password", data = "")] -async fn put_reset_password( - org_id: OrganizationId, - member_id: MembershipId, - headers: AdminHeaders, - data: Json, - conn: DbConn, - nt: Notify<'_>, -) -> EmptyResult { - recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await -} - async fn recover_account( org_id: OrganizationId, member_id: MembershipId, @@ -3072,6 +2863,7 @@ async fn recover_account( if req.reset_two_factor { TwoFactor::delete_all_by_user(&user.uuid, &conn).await?; + Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?; if !fallback_2fa_email || two_factor::email::find_and_activate_email_2fa(&user.uuid, &conn).await.is_err() { two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn) .await?; @@ -3240,7 +3032,7 @@ async fn api_key( org_id: &OrganizationId, data: Json, rotate: bool, - headers: AdminHeaders, + headers: OwnerHeaders, conn: DbConn, ) -> JsonResult { if org_id != &headers.org_id { @@ -3249,7 +3041,7 @@ async fn api_key( let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - // Validate the admin users password/otp + // Validate the owner users password/otp data.validate(&user, true, &conn).await?; let org_api_key = if let Some(mut org_api_key) = OrganizationApiKey::find_by_org_uuid(org_id, &conn).await { @@ -3277,7 +3069,7 @@ async fn api_key( async fn post_api_key( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: OwnerHeaders, conn: DbConn, ) -> JsonResult { api_key(&org_id, data, false, headers, conn).await @@ -3287,7 +3079,7 @@ async fn post_api_key( async fn rotate_api_key( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: OwnerHeaders, conn: DbConn, ) -> JsonResult { api_key(&org_id, data, true, headers, conn).await diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index 692e8248..99b5c67c 100644 --- a/src/api/core/two_factor/authenticator.rs +++ b/src/api/core/two_factor/authenticator.rs @@ -7,7 +7,7 @@ use crate::{ crypto, db::{ DbConn, - models::{EventType, TwoFactor, TwoFactorType, UserId}, + models::{Device, EventType, TwoFactor, TwoFactorType, UserId}, }, util::NumberOrString, }; @@ -200,6 +200,7 @@ async fn disable_authenticator(data: Json, headers: He if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { if twofactor.data == data.key { twofactor.delete(&conn).await?; + Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?; log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) .await; } else { diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index ed112eb9..860979ff 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -1,5 +1,4 @@ use chrono::Utc; -use data_encoding::BASE64; use rocket::{Route, serde::json::Json}; use crate::{ @@ -222,13 +221,6 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) Ok(()) } -const DUO_EXPIRE: i64 = 300; -const APP_EXPIRE: i64 = 3600; - -const AUTH_PREFIX: &str = "AUTH"; -const DUO_PREFIX: &str = "TX"; -const APP_PREFIX: &str = "APP"; - async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus { let type_ = TwoFactorType::Duo as i32; @@ -251,118 +243,13 @@ async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus { DuoStatus::Disabled(false) } -// let (ik, sk, ak, host) = get_duo_keys(); -pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String, String)> { +// let (ik, sk, host) = get_duo_keys_email(); +pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String)> { let data = match User::find_by_mail(email, conn).await { Some(u) => get_user_duo_data(&u.uuid, conn).await.data(), _ => DuoData::global(), } .map_res("Can't fetch Duo Keys")?; - Ok((data.ik, data.sk, CONFIG.get_duo_akey().await, data.host)) -} - -pub async fn generate_duo_signature(email: &str, conn: &DbConn) -> ApiResult<(String, String)> { - let now = Utc::now().timestamp(); - - let (ik, sk, ak, host) = get_duo_keys_email(email, conn).await?; - - let duo_sign = sign_duo_values(&sk, email, &ik, DUO_PREFIX, now + DUO_EXPIRE); - let app_sign = sign_duo_values(&ak, email, &ik, APP_PREFIX, now + APP_EXPIRE); - - Ok((format!("{duo_sign}:{app_sign}"), host)) -} - -fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64) -> String { - let val = format!("{email}|{ikey}|{expire}"); - let cookie = format!("{prefix}|{}", BASE64.encode(val.as_bytes())); - - format!("{cookie}|{}", crypto::hmac_sign(key, &cookie)) -} - -pub async fn validate_duo_login(email: &str, response: &str, conn: &DbConn) -> EmptyResult { - let split: Vec<&str> = response.split(':').collect(); - if split.len() != 2 { - err!( - "Invalid response length", - ErrorEvent { - event: EventType::UserFailedLogIn2fa - } - ); - } - - let auth_sig = split[0]; - let app_sig = split[1]; - - let now = Utc::now().timestamp(); - - let (ik, sk, ak, _host) = get_duo_keys_email(email, conn).await?; - - let auth_user = parse_duo_values(&sk, auth_sig, &ik, AUTH_PREFIX, now)?; - let app_user = parse_duo_values(&ak, app_sig, &ik, APP_PREFIX, now)?; - - if !crypto::ct_eq(&auth_user, app_user) || !crypto::ct_eq(&auth_user, email) { - err!( - "Error validating duo authentication", - ErrorEvent { - event: EventType::UserFailedLogIn2fa - } - ) - } - - Ok(()) -} - -fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -> ApiResult { - let split: Vec<&str> = val.split('|').collect(); - if split.len() != 3 { - err!("Invalid value length") - } - - let u_prefix = split[0]; - let u_b64 = split[1]; - let u_sig = split[2]; - - let sig = crypto::hmac_sign(key, &format!("{u_prefix}|{u_b64}")); - - if !crypto::ct_eq(crypto::hmac_sign(key, &sig), crypto::hmac_sign(key, u_sig)) { - err!("Duo signatures don't match") - } - - if u_prefix != prefix { - err!("Prefixes don't match") - } - - let Ok(cookie_vec) = BASE64.decode(u_b64.as_bytes()) else { - err!("Invalid Duo cookie encoding") - }; - - let Ok(cookie) = String::from_utf8(cookie_vec) else { - err!("Invalid Duo cookie encoding") - }; - - let cookie_split: Vec<&str> = cookie.split('|').collect(); - if cookie_split.len() != 3 { - err!("Invalid cookie length") - } - - let username = cookie_split[0]; - let u_ikey = cookie_split[1]; - let expire = cookie_split[2]; - - if !crypto::ct_eq(ikey, u_ikey) { - err!("Invalid ikey") - } - - let expire: i64 = if let Ok(e) = expire.parse() { - e - } else { - err!("Invalid expire time") - }; - - if time >= expire { - err!("Expired authorization") - } - - Ok(username.into()) + Ok((data.ik, data.sk, data.host)) } diff --git a/src/api/core/two_factor/duo_oidc.rs b/src/api/core/two_factor/duo_oidc.rs index adb030af..e5e1df0f 100644 --- a/src/api/core/two_factor/duo_oidc.rs +++ b/src/api/core/two_factor/duo_oidc.rs @@ -383,7 +383,7 @@ pub async fn get_duo_auth_url( device_identifier: &DeviceId, conn: &DbConn, ) -> Result { - let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?; + let (ik, sk, host) = get_duo_keys_email(email, conn).await?; let callback_url = match make_callback_url(client_id) { Ok(url) => url, @@ -435,7 +435,7 @@ pub async fn validate_duo_login( let code = split[0]; let state = split[1]; - let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?; + let (ik, sk, host) = get_duo_keys_email(email, conn).await?; // Get the context by the state reported by the client. If we don't have one, // it means the context is either missing or expired. diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 3667b871..836d1c47 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -65,7 +65,7 @@ async fn send_email_login(data: Json, client_headers: Client let Some(user) = User::find_by_mail(email, &conn).await else { err!( "Username or password is incorrect. Try again", - format!("IP: {}. Username: {email}.", client_headers.ip.ip) + format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug()) ) }; @@ -74,7 +74,7 @@ async fn send_email_login(data: Json, client_headers: Client if !user.check_valid_password(master_password_hash) { err!( "Username or password is incorrect. Try again", - format!("IP: {}. Username: {email}.", client_headers.ip.ip) + format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug()) ) } } else if let Some(auth_request_id) = auth_request_id { @@ -188,7 +188,7 @@ async fn send_email(data: Json, headers: Headers, conn: DbConn) - } let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); - let twofactor_data = EmailTokenData::new(data.email, generated_token); + let twofactor_data = EmailTokenData::new(data.email, Some(generated_token)); // Uses EmailVerificationChallenge as type to show that it's not verified yet. let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json()); @@ -322,10 +322,10 @@ pub struct EmailTokenData { } impl EmailTokenData { - pub fn new(email: String, token: String) -> EmailTokenData { + pub fn new(email: String, token: Option) -> EmailTokenData { EmailTokenData { email, - last_token: Some(token), + last_token: token, token_sent: Utc::now().timestamp(), attempts: 0, } @@ -363,7 +363,8 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult { if user.verified_at.is_none() { err!("Auto-enabling of email 2FA failed because the users email address has not been verified!"); } - let twofactor_data = EmailTokenData::new(user.email.clone(), String::new()); + // The token is set when the first code is sent + let twofactor_data = EmailTokenData::new(user.email.clone(), None); let twofactor = TwoFactor::new(user.uuid.clone(), TwoFactorType::Email, twofactor_data.to_json()); twofactor.save(conn).await } diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 0eb6563e..14bdafd5 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -69,13 +69,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data } pub fn routes() -> Vec { - let mut routes = routes![ - get_twofactor, - get_recover, - disable_twofactor, - disable_twofactor_put, - get_device_verification_settings, - ]; + let mut routes = routes![get_twofactor, get_recover, disable_twofactor, get_device_verification_settings]; routes.append(&mut authenticator::routes()); routes.append(&mut duo::routes()); @@ -134,7 +128,7 @@ struct DisableTwoFactorData { r#type: NumberOrString, } -#[post("/two-factor/disable", data = "")] +#[put("/two-factor/disable", data = "")] async fn disable_twofactor(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: DisableTwoFactorData = data.into_inner(); let user = headers.user; @@ -167,11 +161,6 @@ async fn disable_twofactor(data: Json, headers: Headers, c }))) } -#[put("/two-factor/disable", data = "")] -async fn disable_twofactor_put(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - disable_twofactor(data, headers, conn).await -} - pub async fn enforce_2fa_policy( user: &User, act_user_id: &UserId, diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 07b964e5..a3a9b21b 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -24,7 +24,7 @@ use crate::{ crypto::ct_eq, db::{ DbConn, - models::{EventType, TwoFactor, TwoFactorType, UserId}, + models::{Device, EventType, TwoFactor, TwoFactorType, UserId}, }, error::Error, util::NumberOrString, @@ -338,6 +338,7 @@ async fn delete_webauthn(data: Json, headers: Headers, conn: DbCo tf.data = serde_json::to_string(&data)?; tf.save(&conn).await?; drop(tf); + Device::clear_twofactor_remember_by_user(&headers.user.uuid, &conn).await?; // If entry is migrated from u2f, delete the u2f entry as well if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await diff --git a/src/api/identity.rs b/src/api/identity.rs index 6808ddde..702a0d0e 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -17,15 +17,14 @@ use crate::{ accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, log_user_event, two_factor::{ - authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, - yubikey, + authenticator, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey, }, }, master_password_policy, push::register_push_device, }, auth, - auth::{AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims}, + auth::{AuthMethod, ClientHeaders, ClientIp, Secure, generate_organization_api_key_login_claims}, crypto, db::{ DbConn, @@ -46,7 +45,6 @@ pub fn routes() -> Vec { login, post_prelogin, prelogin_password, - identity_register, register_verification_email, register_finish, prevalidate, @@ -57,12 +55,7 @@ pub fn routes() -> Vec { } #[post("/connect/token", data = "")] -async fn login( - data: Form, - client_header: ClientHeaders, - client_version: Option, - conn: DbConn, -) -> JsonResult { +async fn login(data: Form, client_header: ClientHeaders, conn: DbConn) -> JsonResult { let data: ConnectData = data.into_inner(); let mut user_id: Option = None; @@ -83,7 +76,7 @@ async fn login( check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; - password_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await + password_login(data, &mut user_id, &conn, &client_header.ip).await } "client_credentials" => { check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; @@ -105,7 +98,7 @@ async fn login( check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; - sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await + sso_login(data, &mut user_id, &conn, &client_header.ip).await } "authorization_code" => err!("SSO sign-in is not available"), "send_access" => { @@ -190,13 +183,7 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR } // After exchanging the code we need to check first if 2FA is needed before continuing -async fn sso_login( - data: ConnectData, - user_id: &mut Option, - conn: &DbConn, - ip: &ClientIp, - client_version: Option<&ClientVersion>, -) -> JsonResult { +async fn sso_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { AuthMethod::Sso.check_scope(data.scope.as_ref())?; // Ratelimit the login @@ -353,7 +340,7 @@ async fn sso_login( Some((mut user, sso_user)) => { let mut device = get_device(&data, conn, &user).await?; - let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; + let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?; if user.private_key.is_none() { // User was invited a stub was created @@ -385,13 +372,7 @@ async fn sso_login( authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await } -async fn password_login( - data: ConnectData, - user_id: &mut Option, - conn: &DbConn, - ip: &ClientIp, - client_version: Option<&ClientVersion>, -) -> JsonResult { +async fn password_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { // Validate scope AuthMethod::Password.check_scope(data.scope.as_ref())?; @@ -400,24 +381,14 @@ async fn password_login( // Get the user let username = data.username.as_ref().unwrap().trim(); + let log_username = username.escape_debug(); let Some(mut user) = User::find_by_mail(username, conn).await else { - err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {username}.", ip.ip)) + err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {log_username}.", ip.ip)) }; // Set the user_id here to be passed back used for event logging. *user_id = Some(user.uuid.clone()); - // Check if the user is disabled - if !user.enabled { - err!( - "This user has been disabled", - format!("IP: {}. Username: {username}.", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) - } - let password = data.password.as_ref().unwrap(); // If we get an auth request, we don't check the user's password, but the access code of the auth request @@ -425,7 +396,7 @@ async fn password_login( let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, conn).await else { err!( "Auth request not found. Try again.", - format!("IP: {}. Username: {username}.", ip.ip), + format!("IP: {}. Username: {log_username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } @@ -443,7 +414,7 @@ async fn password_login( { err!( "Username or access code is incorrect. Try again", - format!("IP: {}. Username: {username}.", ip.ip), + format!("IP: {}. Username: {log_username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } @@ -452,13 +423,24 @@ async fn password_login( } else if !user.check_valid_password(password) { err!( "Username or password is incorrect. Try again", - format!("IP: {}. Username: {username}.", ip.ip), + format!("IP: {}. Username: {log_username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } ) } + // Check if the user is disabled + if !user.enabled { + err!( + "This user has been disabled", + format!("IP: {}. Username: {log_username}.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + // Change the KDF Iterations (only when not logging in with an auth request) if data.auth_request.is_none() { kdf_upgrade(&mut user, password, conn).await?; @@ -491,7 +473,7 @@ async fn password_login( // We still want the login to fail until they actually verified the email address err!( "Please verify your email before trying again.", - format!("IP: {}. Username: {username}.", ip.ip), + format!("IP: {}. Username: {log_username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn } @@ -500,7 +482,7 @@ async fn password_login( let mut device = get_device(&data, conn, &user).await?; - let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; + let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?; let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); @@ -583,7 +565,6 @@ async fn authenticated_response( "KdfIterations": user.client_kdf_iter, "KdfMemory": user.client_kdf_memory, "KdfParallelism": user.client_kdf_parallelism, - "ResetMasterPassword": false, // TODO: Same as above "ForcePasswordReset": false, "MasterPasswordPolicy": master_password_policy, "scope": auth_tokens.scope(), @@ -737,7 +718,6 @@ async fn user_api_key_login( "KdfIterations": user.client_kdf_iter, "KdfMemory": user.client_kdf_memory, "KdfParallelism": user.client_kdf_parallelism, - "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing "ForcePasswordReset": false, "scope": AuthMethod::UserApiKey.scope(), "AccountKeys": account_keys, @@ -803,7 +783,6 @@ async fn twofactor_auth( data: &ConnectData, device: &mut Device, ip: &ClientIp, - client_version: Option<&ClientVersion>, conn: &DbConn, ) -> ApiResult> { let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; @@ -832,17 +811,12 @@ async fn twofactor_auth( if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id) && !twofactor_ids.contains(&selected_id) { - err_json!( - json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, - "Invalid two factor provider" - ) + err_json!(json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "Invalid two factor provider") } - let Some(ref twofactor_code) = data.two_factor_token else { - err_json!( - json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, - "2FA token not provided" - ) + // Like upstream, a blank token counts as not provided + let Some(twofactor_code) = data.two_factor_token.as_deref().filter(|t| !t.trim().is_empty()) else { + err_json!(json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA token not provided") }; let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled); @@ -856,20 +830,14 @@ async fn twofactor_auth( Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?, Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?, Some(TwoFactorType::Duo) => { - if CONFIG.duo_use_iframe() { - // Legacy iframe prompt flow - duo::validate_duo_login(&user.email, twofactor_code, conn).await?; - } else { - // OIDC based flow - duo_oidc::validate_duo_login( - &user.email, - twofactor_code, - data.client_id.as_ref().unwrap(), - data.device_identifier.as_ref().unwrap(), - conn, - ) - .await?; - } + duo_oidc::validate_duo_login( + &user.email, + twofactor_code, + data.client_id.as_ref().unwrap(), + data.device_identifier.as_ref().unwrap(), + conn, + ) + .await?; } Some(TwoFactorType::Email) => { email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?; @@ -891,7 +859,7 @@ async fn twofactor_auth( device.save(true, conn).await?; } err_json!( - json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, + json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA Remember token not provided or expired" ) } @@ -946,7 +914,6 @@ async fn json_err_twofactor( providers: &[i32], user_id: &UserId, data: &ConnectData, - client_version: Option<&ClientVersion>, conn: &DbConn, ) -> ApiResult { let mut result = json!({ @@ -975,27 +942,17 @@ async fn json_err_twofactor( err!("User does not exist") }; - if CONFIG.duo_use_iframe() { - // Legacy iframe prompt flow - let (signature, host) = duo::generate_duo_signature(&email, conn).await?; - result["TwoFactorProviders2"][provider.to_string()] = json!({ - "Host": host, - "Signature": signature, - }); - } else { - // OIDC based flow - let auth_url = duo_oidc::get_duo_auth_url( - &email, - data.client_id.as_ref().unwrap(), - data.device_identifier.as_ref().unwrap(), - conn, - ) - .await?; + let auth_url = duo_oidc::get_duo_auth_url( + &email, + data.client_id.as_ref().unwrap(), + data.device_identifier.as_ref().unwrap(), + conn, + ) + .await?; - result["TwoFactorProviders2"][provider.to_string()] = json!({ - "AuthUrl": auth_url, - }); - } + result["TwoFactorProviders2"][provider.to_string()] = json!({ + "AuthUrl": auth_url, + }); } Some(tf_type @ TwoFactorType::YubiKey) => { @@ -1015,19 +972,6 @@ async fn json_err_twofactor( err!("No twofactor email registered") }; - // Starting with version 2025.5.0 the client will call `/api/two-factor/send-email-login`. - let disabled_send = if let Some(cv) = client_version { - let ver_match = semver::VersionReq::parse(">=2025.5.0").unwrap(); - ver_match.matches(&cv.0) - } else { - false - }; - - // Send email immediately if email is the only 2FA option. - if providers.len() == 1 && !disabled_send { - email::send_token(user_id, conn).await?; - } - let email_data = email::EmailTokenData::from_json(&twofactor.data)?; result["TwoFactorProviders2"][provider.to_string()] = json!({ "Email": email::obscure_email(&email_data.email), @@ -1065,11 +1009,6 @@ async fn prelogin_password(data: Json, ip: ClientIp, conn: DbConn) prelogin(data, ip, conn).await } -#[post("/accounts/register", data = "")] -async fn identity_register(data: Json, conn: DbConn) -> JsonResult { - register(data, false, conn).await -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RegisterVerificationData { @@ -1141,8 +1080,9 @@ async fn register_verification_email( } #[post("/accounts/register/finish", data = "")] -async fn register_finish(data: Json, conn: DbConn) -> JsonResult { - register(data, true, conn).await +async fn register_finish(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { + crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; + register(data, conn).await } // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts diff --git a/src/config.rs b/src/config.rs index 05b01bdd..b03e006c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -561,7 +561,7 @@ make_config! { /// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request. /// Defaults to every minute. Set blank to disable this job. auth_request_purge_schedule: String, false, def, "30 * * * * *".to_owned(); - /// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt. + /// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled. /// Defaults to once every minute. Set blank to disable this job. duo_context_purge_schedule: String, false, def, "30 * * * * *".to_owned(); /// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login. @@ -865,16 +865,12 @@ make_config! { duo: _enable_duo { /// Enabled _enable_duo: bool, true, def, true; - /// Attempt to use deprecated iframe-based Traditional Prompt (Duo WebSDK 2) - duo_use_iframe: bool, false, def, false; /// Client Id duo_ikey: String, true, option; /// Client Secret duo_skey: Pass, true, option; /// Host duo_host: String, true, option; - /// Application Key (generated automatically) - _duo_akey: Pass, false, option; }, /// SMTP Email Settings @@ -1418,30 +1414,16 @@ pub enum PathType { // Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31 // iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7 pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ - // Architecture - "desktop-ui-migration-milestone-1", - "desktop-ui-migration-milestone-2", - "desktop-ui-migration-milestone-3", - "desktop-ui-migration-milestone-4", // Auth Team "pm-5594-safari-account-switching", "pm-32413-multi-client-password-management", // Autofill Team "undetermined-cipher-scenario-logic", "enable-basic-auth-response", - "ssh-agent", "ssh-agent-v2", // Key Management Team - "ssh-key-vault-item", - "pm-25373-windows-biometrics-v2", - "pm-26340-linux-biometrics-v2", "windows-native-credential-sync", // Mobile Team - "anon-addy-self-host-alias", - "simple-login-self-host-alias", - "mutual-tls", - "cxp-import-mobile", - "cxp-export-mobile", "pm-34171-card-scanner", // Platform Team "pm-30529-webauthn-related-origins", @@ -1518,15 +1500,6 @@ impl Config { Ok(()) } - async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> { - let builder = { - let usr = &self.inner.read().unwrap()._usr; - let mut overrides = Vec::new(); - usr.merge(&other, false, &mut overrides) - }; - self.update_config(builder, false).await - } - /// Tests whether an email's domain is allowed. A domain is allowed if it /// is in signups_domains_whitelist, or if no whitelist is set (so there /// are no domain restrictions in effect). @@ -1622,23 +1595,6 @@ impl Config { inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail) } - pub async fn get_duo_akey(&self) -> String { - if let Some(akey) = self._duo_akey() { - akey - } else { - let akey_s = crate::crypto::encode_random_bytes::<64>(&data_encoding::BASE64); - - // Save the new value - let builder = ConfigBuilder { - _duo_akey: Some(akey_s.clone()), - ..Default::default() - }; - self.update_config_partial(builder).await.ok(); - - akey_s - } - } - pub fn is_webauthn_2fa_supported(&self) -> bool { Url::parse(&self.domain()).expect("DOMAIN not a valid URL").domain().is_some() } diff --git a/src/db/models/group.rs b/src/db/models/group.rs index 347b3235..007ad375 100644 --- a/src/db/models/group.rs +++ b/src/db/models/group.rs @@ -104,7 +104,6 @@ impl Group { "id": self.uuid, "organizationId": self.organizations_uuid, "name": self.name, - "accessAll": self.access_all, "externalId": self.external_id, "collections": collections_groups, "object": "groupDetails" @@ -574,26 +573,6 @@ impl GroupUser { } } - pub async fn delete_by_group_and_member( - group_uuid: &GroupId, - member_uuid: &MembershipId, - conn: &DbConn, - ) -> EmptyResult { - match Membership::find_by_uuid(member_uuid, conn).await { - Some(member) => User::update_uuid_revision(&member.user_uuid, conn).await, - None => warn!("Member could not be found!"), - } - - conn.run(move |conn| { - diesel::delete(groups_users::table) - .filter(groups_users::groups_uuid.eq(group_uuid)) - .filter(groups_users::users_organizations_uuid.eq(member_uuid)) - .execute(conn) - .map_res("Error deleting group users") - }) - .await - } - pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { let group_users = GroupUser::find_by_group(group_uuid, org_uuid, conn).await; for group_user in group_users { diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 353a406e..9816c579 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -515,8 +515,7 @@ impl Membership { "limitCollectionDeletion": true, "limitItemDeletion": false, "allowAdminAccessToAllCollectionItems": true, - "userIsManagedByOrganization": false, // Means not managed via the Members UI, like SSO - "userIsClaimedByOrganization": false, // The new key instead of the obsolete userIsManagedByOrganization + "userIsClaimedByOrganization": false, // Means not managed via the Members UI, like SSO "permissions": permissions, @@ -626,7 +625,6 @@ impl Membership { "status": status, "type": membership_type, - "accessAll": self.access_all, "twoFactorEnabled": twofactor_enabled, "resetPasswordEnrolled": self.reset_password_key.is_some(), "hasMasterPassword": !user.password_hash.is_empty(), @@ -634,7 +632,6 @@ impl Membership { "permissions": permissions, "ssoBound": false, // Not supported - "managedByOrganization": false, // This key is obsolete replaced by claimedByOrganization "claimedByOrganization": false, // Means not managed via the Members UI, like SSO "usesKeyConnector": false, // Not supported "accessSecretsManager": false, // Not supported (Not AGPLv3 Licensed) @@ -685,7 +682,6 @@ impl Membership { "status": status, "type": self.atype, - "accessAll": self.access_all, "collections": coll_uuids, "object": "organizationUserDetails", @@ -1028,6 +1024,7 @@ impl Membership { conn.run(move |conn| { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid))) .left_join( ciphers_collections::table.on(ciphers_collections::collection_uuid @@ -1054,6 +1051,7 @@ impl Membership { conn.run(move |conn| { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .inner_join( groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)), ) diff --git a/src/main.rs b/src/main.rs index 437354af..f27729d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -720,7 +720,7 @@ fn schedule_jobs(pool: db::DbPool) { } // Clean unused, expired Duo authentication contexts. - if !CONFIG.duo_context_purge_schedule().is_empty() && CONFIG._enable_duo() && !CONFIG.duo_use_iframe() { + if !CONFIG.duo_context_purge_schedule().is_empty() && CONFIG._enable_duo() { sched.add(Job::new(CONFIG.duo_context_purge_schedule().parse().unwrap(), || { runtime.spawn(purge_duo_contexts(pool.clone())); })); diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 4b83ba04..d062c026 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -42,45 +42,27 @@ a[href$="/settings/sponsored-families"] { {{/if}} /* Hide the `Enterprise Single Sign-On` button on the login page */ -{{#if (webver ">=2025.5.1")}} {{#if (not sso_enabled)}} .vw-sso-login { @extend %vw-hide; } {{/if}} -{{else}} -app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(4) { - @extend %vw-hide; -} -{{/if}} /* Hide the `Log in with passkey` settings */ app-user-layout app-password-settings app-webauthn-login-settings { @extend %vw-hide; } /* Hide Log in with passkey on the login page */ -{{#if (webver ">=2025.5.1")}} .vw-passkey-login { @extend %vw-hide; } -{{else}} -app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(3) { - @extend %vw-hide; -} -{{/if}} /* Hide the or text followed by the two buttons hidden above */ -{{#if (webver ">=2025.5.1")}} {{#if (or (not sso_enabled) sso_only)}} .vw-or-text { @extend %vw-hide; } {{/if}} -{{else}} -app-root ng-component > form > div:nth-child(1) > div:nth-child(3) > div:nth-child(2) { - @extend %vw-hide; -} -{{/if}} /* Hide the `Other` button on the login page */ {{#if (or (not sso_enabled) sso_only)}} @@ -154,17 +136,10 @@ ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="DuckDuckGo"]) { take a while for the change to take effect. To avoid the button appearing when it shouldn't, we'll keep this style in place for a couple of versions */ /* Hide the register link on the login screen */ -{{#if (webver "<2025.3.0")}} -app-login form div + div + div + div + hr, -app-login form div + div + div + div + hr + p { - @extend %vw-hide; -} -{{else}} app-root a[routerlink="/signup"] { @extend %vw-hide; } {{/if}} -{{/if}} {{#if remember_2fa_disabled}} /* Hide checkbox to remember 2FA token for 30 days */ From 895ec2b1e7ac0f78d2c8ee5f88fe048c20fdfb19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Mon, 5 Oct 2026 14:38:01 +0200 Subject: [PATCH 19/30] Align API behaviour with upstream and return errors on malformed input (#7810) - Attachments: reject a declared v2 attachment size above the upload limit, keep display sizes within the largest unit, and make download links valid for 1 minute like upstream. - Ciphers: moving a personal cipher into an organization needs collection write access or full access, like upstream. - Events: /events/collect only accepts the client event types upstream accepts. - Client IP: for list headers like X-Forwarded-For, use the rightmost address that isn't in IP_HEADER_TRUSTED_PROXIES, reading every occurrence. - Prelogin: unknown emails get stable KDF settings from upstream's list. - Login with device: an auth request can be used for one login, like upstream. - Directory Connector import: never revokes, restores or removes Owners, like upstream. - SSO: check the PKCE verifier again when the code exchange is reused during 2FA, and accept the same redirect URIs as upstream (desktop localhost callback for AppImage and dev builds, CLI on ports 8065-8070). - Identity: send the merged master password policy in the 2FA response and use PascalCase policy keys in identity responses, like upstream. Organization API key login no longer needs device fields. Recovery code login normalises the code, uses upstream's error message, logs a failed 2FA event and sends a "two-step login recovered" email. - Add GET /api/accounts/keys. - Return errors instead of panicking on malformed event dates, cipher data, key rotation cipher ids, bulk confirm entries and YubiKey OTPs. --- .env.template | 2 + src/api/core/accounts.rs | 45 +++++++- src/api/core/ciphers.rs | 21 ++-- src/api/core/emergency_access.rs | 2 +- src/api/core/events.rs | 43 ++++---- src/api/core/organizations.rs | 8 +- src/api/core/public.rs | 26 +---- src/api/core/sends.rs | 8 +- src/api/core/two_factor/yubikey.rs | 4 +- src/api/icons.rs | 4 +- src/api/identity.rs | 80 ++++++++------ src/api/mod.rs | 18 +++ src/auth.rs | 103 ++++++++++++++++-- src/config.rs | 4 +- src/db/models/attachment.rs | 2 +- src/db/models/auth_request.rs | 17 ++- src/db/models/organization.rs | 2 +- src/db/models/user.rs | 39 ++++--- src/mail.rs | 15 +++ src/sso.rs | 32 +++--- src/sso_client.rs | 19 ++-- .../templates/email/recover_twofactor.hbs | 9 ++ .../email/recover_twofactor.html.hbs | 26 +++++ src/util.rs | 6 +- 24 files changed, 381 insertions(+), 154 deletions(-) create mode 100644 src/static/templates/email/recover_twofactor.hbs create mode 100644 src/static/templates/email/recover_twofactor.html.hbs diff --git a/.env.template b/.env.template index 43ed914f..f34ea249 100644 --- a/.env.template +++ b/.env.template @@ -338,6 +338,8 @@ ## "local" accepts it from any non global address, which covers a reverse proxy running on the same ## host or container network. Use "all" to accept it from anywhere, or list the addresses of your ## proxy as IPs and CIDR ranges if it connects from a public address. +## For a list header like X-Forwarded-For, the client IP is the rightmost address that isn't one of +## these proxies, so with several proxies in a row (a CDN in front of nginx, for example) list all of them. # IP_HEADER_TRUSTED_PROXIES=local ## Icon service diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 930107c9..b3499a93 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -42,6 +42,7 @@ pub fn routes() -> Vec { post_profile, put_avatar, get_public_keys, + get_keys, post_keys, post_password, post_set_password, @@ -568,6 +569,24 @@ async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> Js }))) } +#[get("/accounts/keys")] +fn get_keys(headers: Headers) -> JsonResult { + let user = headers.user; + + // The SDK reads a 404 as the user having no key pair yet + if user.private_key.is_none() || user.public_key.is_none() { + err_code!("User has no key pair", Status::NotFound.code) + } + + Ok(Json(json!({ + "key": (!user.akey.is_empty()).then_some(&user.akey), + "publicKey": user.public_key, + "privateKey": user.private_key, + "accountKeys": user.account_keys_json(), + "object": "keys", + }))) +} + #[post("/accounts/keys", data = "")] async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: KeysData = data.into_inner(); @@ -994,7 +1013,8 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: // Update cipher data for cipher_data in data.account_data.ciphers { if cipher_data.organization_id.is_none() { - let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap()) + let Some(saved_cipher) = + cipher_data.id.as_ref().and_then(|id| existing_ciphers.iter_mut().find(|c| &c.uuid == id)) else { err!("Cipher doesn't exist") }; @@ -1360,14 +1380,33 @@ pub struct PreloginData { email: String, } +/// Like upstream, an unknown email gets KDF settings picked from a list of common ones by a keyed hash of the email, +/// so they stay the same between requests. The salt stays null, as for existing users. +fn unknown_email_kdf(email: &str) -> (i32, i32, Option, Option) { + const PBKDF2: i32 = UserKdfType::Pbkdf2 as i32; + const ARGON2ID: i32 = UserKdfType::Argon2id as i32; + // Same list as upstream, the default is in it twice to give it more weight + const KDF_SETTINGS: [(i32, i32, Option, Option); 6] = [ + (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None), + (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None), + (PBKDF2, 100_000, None, None), + (PBKDF2, 5_000, None, None), + (ARGON2ID, 3, Some(64), Some(4)), + (ARGON2ID, 6, Some(32), Some(4)), + ]; + KDF_SETTINGS[crate::auth::prelogin_kdf_index(email, KDF_SETTINGS.len())] +} + pub async fn prelogin(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; let data: PreloginData = data.into_inner(); + // Normalized once, so the lookup and the settings for an unknown email use the same value + let email = data.email.trim().to_lowercase(); - let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await { + let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&email, &conn).await { Some(user) => (user.client_kdf_type, user.client_kdf_iter, user.client_kdf_memory, user.client_kdf_parallelism), - None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None), + None => unknown_email_kdf(&email), }; Ok(Json(json!({ diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 5010bd92..46766896 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -29,7 +29,7 @@ use crate::{ util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, }; -use super::folders::FolderData; +use super::{folders::FolderData, sends::SIZE_525_MB}; pub fn routes() -> Vec { // Note that many routes have an `admin` variant; this seems to be @@ -441,10 +441,10 @@ pub async fn update_cipher_from_data( // We do not mind which data is in it, the keep our model more flexible when there are upstream changes. // But, we at least know we do not need to store and return this specific key. fn clean_cipher_data(mut json_data: Value) -> Value { - if json_data.is_array() { - json_data.as_array_mut().unwrap().iter_mut().for_each(|ref mut f| { - f.as_object_mut().unwrap().remove("response"); - }); + if let Some(items) = json_data.as_array_mut() { + for item in items.iter_mut().filter_map(Value::as_object_mut) { + item.remove("response"); + } } json_data } @@ -486,9 +486,10 @@ pub async fn update_cipher_from_data( Some(member) => { // A non-empty list of collections implies the caller already validated the user's write // access to them, so we can move the cipher into the organization on that basis. + // Write access to the cipher itself only counts when it already belongs to this organization. if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty()) || member.has_full_access() - || cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await + || (!transfer_cipher && cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await) { cipher.organization_uuid = Some(org_id); // After some discussion in PR #1329 re-added the user_uuid = None again. @@ -552,7 +553,9 @@ pub async fn update_cipher_from_data( let type_data = if let Some(mut data) = type_data_opt { // Remove the 'Response' key from the base object. - data.as_object_mut().unwrap().remove("response"); + if let Some(data_obj) = data.as_object_mut() { + data_obj.remove("response"); + } // Remove the 'Response' key from every Uri. if data["uris"].is_array() { data["uris"] = clean_cipher_data(data["uris"].clone()); @@ -1182,6 +1185,10 @@ async fn post_attachment_v2( if file_size < 0 { err!("Attachment size can't be negative") } + // Matches the "file" upload limit in main.rs, nothing larger can be uploaded + if file_size > SIZE_525_MB { + err!("Max file size is 525 MB.") + } let attachment_id = crypto::generate_attachment_id(); let attachment = Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key)); diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 2eb95502..6afc683c 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -225,7 +225,7 @@ async fn send_invite(data: Json, headers: Headers, co let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await { None => { if !CONFIG.invitations_allowed() { - err!(format!("Grantee user does not exist: {email}")) + err!(format!("Grantee user does not exist: {}", email.escape_debug())) } if !CONFIG.is_email_domain_allowed(&email) { diff --git a/src/api/core/events.rs b/src/api/core/events.rs index a5b5b6b1..80906e40 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -6,12 +6,13 @@ use serde_json::Value; use crate::{ CONFIG, - api::{EmptyResult, JsonResult}, + api::{ApiResult, EmptyResult, JsonResult}, auth::{AdminHeaders, Headers}, db::{ DbConn, DbPool, models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId}, }, + error::MapResult, util::parse_date, }; @@ -29,6 +30,14 @@ struct EventRange { continuation_token: Option, } +impl EventRange { + fn date_range(&self) -> ApiResult<(NaiveDateTime, NaiveDateTime)> { + let start_date = parse_date(&self.start).map_res("Invalid start date")?; + let end_date = parse_date(self.continuation_token.as_ref().unwrap_or(&self.end)).map_res("Invalid end date")?; + Ok((start_date, end_date)) + } +} + // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87 #[get("/organizations//events?")] async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult { @@ -39,12 +48,7 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin // Return an empty vec when we org events are disabled. // This prevents client errors let events_json: Vec = if CONFIG.org_events_enabled() { - let start_date = parse_date(&data.start); - let end_date = if let Some(before_date) = &data.continuation_token { - parse_date(before_date) - } else { - parse_date(&data.end) - }; + let (start_date, end_date) = data.date_range()?; Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn) .await @@ -69,12 +73,7 @@ async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Heade let events_json: Vec = if CONFIG.org_events_enabled() && Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await { - let start_date = parse_date(&data.start); - let end_date = if let Some(before_date) = &data.continuation_token { - parse_date(before_date) - } else { - parse_date(&data.end) - }; + let (start_date, end_date) = data.date_range()?; Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect() } else { @@ -102,12 +101,7 @@ async fn get_user_events( // Return an empty vec when we org events are disabled. // This prevents client errors let events_json: Vec = if CONFIG.org_events_enabled() { - let start_date = parse_date(&data.start); - let end_date = if let Some(before_date) = &data.continuation_token { - parse_date(before_date) - } else { - parse_date(&data.end) - }; + let (start_date, end_date) = data.date_range()?; Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn) .await @@ -168,9 +162,10 @@ async fn post_events_collect(data: Json>, headers: Headers, } for event in data.iter() { - let event_date = parse_date(&event.date); + let event_date = parse_date(&event.date).map_res("Invalid event date")?; + // Only the event types upstream accepts from clients, the rest are written server-side. match event.r#type { - 1000..=1099 => { + t if t == EventType::UserClientExportedVault as i32 => { log_user_event_impl( event.r#type, &headers.user.uuid, @@ -181,7 +176,7 @@ async fn post_events_collect(data: Json>, headers: Headers, ) .await; } - 1600..=1699 => { + t if t == EventType::OrganizationClientExportedVault as i32 => { // Only allow logging events for an organization the user is actually a member of. if let Some(org_id) = &event.organization_id && Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some() @@ -219,7 +214,8 @@ async fn post_events_collect(data: Json>, headers: Headers, .await; } } - _ => { + // Client-side cipher events (viewed, copied, toggled, autofilled). + 1107..=1114 | 1117 | 1119..=1132 => { // The cipher determines the organization the event is logged to, so make sure the // user can actually access it instead of trusting the provided cipher uuid. if let Some(cipher_uuid) = &event.cipher_id @@ -240,6 +236,7 @@ async fn post_events_collect(data: Json>, headers: Headers, .await; } } + _ => {} } } Ok(()) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index f64b8c53..6464f774 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1028,7 +1028,7 @@ async fn send_invite( let user = match User::find_by_mail(email, &conn).await { None => { if !CONFIG.invitations_allowed() { - err!(format!("User does not exist: {email}")) + err!(format!("User does not exist: {}", email.escape_debug())) } if !CONFIG.is_email_domain_allowed(email) { @@ -1046,7 +1046,7 @@ async fn send_invite( } Some(user) => { if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { - err!(format!("User already in organization: {email}")) + err!(format!("User already in organization: {}", email.escape_debug())) } if !CONFIG.mail_enabled() { @@ -1321,7 +1321,9 @@ async fn bulk_confirm_invite( match data.keys { Some(keys) => { for invite in keys { - let member_id = invite.id.unwrap(); + let Some(member_id) = invite.id else { + err!("Member id is required") + }; let user_key = invite.key.unwrap_or_default(); let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { Ok(()) => String::new(), diff --git a/src/api/core/public.rs b/src/api/core/public.rs index 3db25df9..fbb1f855 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -58,25 +58,13 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn let org_id = token.0; let data = data.into_inner(); + // Like upstream, the import never removes Owners, so it doesn't revoke or restore them either. for user_data in &data.members { let mut user_created: bool = false; if user_data.deleted { // If user is marked for deletion and it exists, revoke it if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await { - // Only revoke a user if it is not the last confirmed owner - let revoked = if member.atype == MembershipType::Owner - && member.status == MembershipStatus::Confirmed as i32 - { - if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 { - warn!("Can't revoke the last owner"); - false - } else { - member.revoke() - } - } else { - member.revoke() - }; - + let revoked = member.atype != MembershipType::Owner && member.revoke(); let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); if revoked || ext_modified { member.save(&conn).await?; @@ -84,7 +72,7 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn } // If user is part of the organization, restore it } else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await { - let mut restored = member.restore(); + let mut restored = member.atype != MembershipType::Owner && member.restore(); let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); // Enforce org policies as every other restore path does. // If the user is not allowed, we revoke again and continue so the external_id is still updated. @@ -180,14 +168,8 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn for member in Membership::find_by_org(&org_id, &conn).await { if let Some(ref user_external_id) = member.external_id && !sync_members.contains(user_external_id) + && member.atype != MembershipType::Owner { - if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 { - // Removing owner, check that there is at least one other confirmed owner - if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 { - warn!("Can't delete the last owner"); - continue; - } - } member.delete(&conn).await?; } } diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index bfff4e96..2a604b74 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -23,7 +23,7 @@ use crate::{ const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available"; static ANON_PUSH_DEVICE: LazyLock = LazyLock::new(|| { - let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z"); + let dt = DateTime::UNIX_EPOCH.naive_utc(); Device { uuid: String::from("00000000-0000-0000-0000-000000000000").into(), created_at: dt, @@ -39,7 +39,7 @@ static ANON_PUSH_DEVICE: LazyLock = LazyLock::new(|| { }); // The max file size allowed by Bitwarden clients and add an extra 5% to avoid issues -const SIZE_525_MB: i64 = 550_502_400; +pub(crate) const SIZE_525_MB: i64 = 550_502_400; pub fn routes() -> Vec { routes![ @@ -328,8 +328,8 @@ async fn post_send_file_v2_data( "Send file name does not match.", format!( "Expected file name '{}' got '{}'", - send_data.fileName, - raw_file_name.dangerous_unsafe_unsanitized_raw() + send_data.fileName.escape_debug(), + raw_file_name.dangerous_unsafe_unsanitized_raw().as_str().escape_debug() ) ), _ => err!("Send file name does not match or is not provided."), diff --git a/src/api/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs index eb3d6dfd..54f0adb5 100644 --- a/src/api/core/two_factor/yubikey.rs +++ b/src/api/core/two_factor/yubikey.rs @@ -217,7 +217,9 @@ pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> Emp } let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata"); - let response_id = &response[..12]; + let Some(response_id) = response.get(..12) else { + err!("Invalid Yubikey OTP"); + }; if !yubikey_metadata.keys.contains(&response_id.to_owned()) { err!("Given Yubikey is not registered"); diff --git a/src/api/icons.rs b/src/api/icons.rs index 8f3e730b..05027959 100644 --- a/src/api/icons.rs +++ b/src/api/icons.rs @@ -85,7 +85,7 @@ static ICON_SIZE_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"(?x)(\d+ #[get("//icon.png")] fn icon_external(host: &str) -> Cached> { let Ok(host) = get_valid_host(host) else { - warn!("Invalid host: {host}"); + warn!("Invalid host: {}", host.escape_debug()); return Cached::ttl(None, CONFIG.icon_cache_negttl(), true); }; @@ -113,7 +113,7 @@ async fn icon_internal(host: &str) -> Cached<(ContentType, Vec)> { const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png"); let Ok(host) = get_valid_host(host) else { - warn!("Invalid host: {host}"); + warn!("Invalid host: {}", host.escape_debug()); return Cached::ttl( (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), CONFIG.icon_cache_negttl(), diff --git a/src/api/identity.rs b/src/api/identity.rs index 702a0d0e..ac05386a 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -20,7 +20,7 @@ use crate::{ authenticator, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey, }, }, - master_password_policy, + identity_master_password_policy, push::register_push_device, }, auth, @@ -83,10 +83,6 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?; check_is_some(data.scope.as_ref(), "scope cannot be blank")?; - check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?; - check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; - check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; - api_key_login(data, &mut user_id, &conn, &client_header.ip).await } "authorization_code" if CONFIG.sso_enabled() => { @@ -408,6 +404,7 @@ async fn password_login(data: ConnectData, user_id: &mut Option, conn: & if auth_request.user_uuid != user.uuid || !auth_request.approved.unwrap_or(false) + || auth_request.authentication_date.is_some() || request_expired || ip.ip.to_string() != auth_request.request_ip || !auth_request.check_access_code(password) @@ -484,6 +481,19 @@ async fn password_login(data: ConnectData, user_id: &mut Option, conn: & let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?; + // Like upstream, an auth request can be used for one login only. Marked after 2FA so a 2FA prompt doesn't use it up. + if let Some(ref auth_request_id) = data.auth_request + && !AuthRequest::set_authentication_date(auth_request_id, conn).await? + { + err!( + "Username or access code is incorrect. Try again", + format!("IP: {}. Username: {log_username}.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn, + } + ) + } + let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await @@ -521,7 +531,7 @@ async fn authenticated_response( // Save to update `device.updated_at` to track usage and toggle new status device.save(true, conn).await?; - let master_password_policy = master_password_policy(user, conn).await; + let master_password_policy = identity_master_password_policy(user, conn).await; let has_master_password = !user.password_hash.is_empty(); let master_password_unlock = if has_master_password { @@ -594,7 +604,14 @@ async fn api_key_login(data: ConnectData, user_id: &mut Option, conn: &D // Validate scope match data.scope.as_ref() { - Some(scope) if scope == &AuthMethod::UserApiKey.scope() => user_api_key_login(data, user_id, conn, ip).await, + Some(scope) if scope == &AuthMethod::UserApiKey.scope() => { + // Like upstream, only the user API key logs in a device + check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?; + check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; + check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; + + user_api_key_login(data, user_id, conn, ip).await + } Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await, _ => err!("Scope not supported"), } @@ -811,12 +828,12 @@ async fn twofactor_auth( if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id) && !twofactor_ids.contains(&selected_id) { - err_json!(json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "Invalid two factor provider") + err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "Invalid two factor provider") } // Like upstream, a blank token counts as not provided let Some(twofactor_code) = data.two_factor_token.as_deref().filter(|t| !t.trim().is_empty()) else { - err_json!(json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA token not provided") + err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "2FA token not provided") }; let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled); @@ -859,16 +876,22 @@ async fn twofactor_auth( device.save(true, conn).await?; } err_json!( - json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, + json_err_twofactor(&twofactor_ids, user, data, conn).await?, "2FA Remember token not provided or expired" ) } } } Some(TwoFactorType::RecoveryCode) => { - // Check if recovery code is correct - if !user.check_valid_recovery_code(twofactor_code) { - err!("Recovery code is incorrect. Try again.") + // Like upstream, spaces and case don't matter + let recovery_code = twofactor_code.replace(' ', "").trim().to_lowercase(); + if !user.check_valid_recovery_code(&recovery_code) { + err!( + "Two-step token is invalid. Try again.", + ErrorEvent { + event: EventType::UserFailedLogIn2fa + } + ) } // Remove all twofactors from the user @@ -883,6 +906,13 @@ async fn twofactor_auth( log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await; + if CONFIG.mail_enabled() + && let Err(e) = + mail::send_recover_twofactor(&user.email, &ip.ip.to_string(), &Utc::now().naive_utc()).await + { + error!("Error sending two-step login recovered email: {e:#?}"); + } + // Remove the recovery code, not needed without twofactors user.totp_recover = None; user.save(conn).await?; @@ -910,20 +940,14 @@ fn selected_data(tf: Option) -> ApiResult { tf.map(|t| t.data).map_res("Two factor doesn't exist") } -async fn json_err_twofactor( - providers: &[i32], - user_id: &UserId, - data: &ConnectData, - conn: &DbConn, -) -> ApiResult { +async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, conn: &DbConn) -> ApiResult { + let user_id = &user.uuid; let mut result = json!({ "error" : "invalid_grant", "error_description" : "Two factor required.", "TwoFactorProviders" : providers.iter().map(ToString::to_string).collect::>(), "TwoFactorProviders2" : {}, // { "0" : null } - "MasterPasswordPolicy": { - "Object": "masterPasswordPolicy" - } + "MasterPasswordPolicy": identity_master_password_policy(user, conn).await, }); for provider in providers { @@ -936,14 +960,8 @@ async fn json_err_twofactor( } Some(TwoFactorType::Duo) => { - let email = if let Some(u) = User::find_by_uuid(user_id, conn).await { - u.email - } else { - err!("User does not exist") - }; - let auth_url = duo_oidc::get_duo_auth_url( - &email, + &user.email, data.client_id.as_ref().unwrap(), data.device_identifier.as_ref().unwrap(), conn, @@ -1212,7 +1230,7 @@ async fn oidcsignin_redirect( let state = sso::decode_state(&base64_state)?; let Some(mut sso_auth) = SsoAuth::find(&state, conn).await else { - err!(format!("Cannot retrieve sso_auth for {state}")) + err!(format!("Cannot retrieve sso_auth for {}", state.escape_debug())) }; // Browser-binding check @@ -1221,7 +1239,7 @@ async fn oidcsignin_redirect( let provided_hash = cookie_value.as_deref().map(|v| crypto::sha256_hex(v.as_bytes())); match (sso_auth.binding_hash.as_deref(), provided_hash.as_deref()) { (Some(expected), Some(actual)) if crypto::ct_eq(expected, actual) => {} - _ => err!(format!("SSO session binding mismatch for {state}")), + _ => err!(format!("SSO session binding mismatch for {}", state.escape_debug())), } cookies .remove(Cookie::build(SSO_BINDING_COOKIE).path(format!("{}/identity/connect/", CONFIG.domain_path())).build()); diff --git a/src/api/mod.rs b/src/api/mod.rs index 9a79ce95..1b3b5a7b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -124,3 +124,21 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value { mpp_json["Object"] = json!("masterPasswordPolicy"); mpp_json } + +// Identity responses use PascalCase keys like upstream, some clients (Android) only read those +async fn identity_master_password_policy(user: &User, conn: &DbConn) -> Value { + match master_password_policy(user, conn).await { + Value::Object(policy) => Value::Object( + policy + .into_iter() + .map(|(mut key, value)| { + if let Some(first) = key.get_mut(..1) { + first.make_ascii_uppercase(); + } + (key, value) + }) + .collect(), + ), + policy => policy, + } +} diff --git a/src/auth.rs b/src/auth.rs index 39ce2732..cad9b298 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -14,6 +14,7 @@ use ipnet::IpNet; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind}; use num_traits::FromPrimitive; use openssl::rsa::Rsa; +use ring::hmac; use serde::{de::DeserializeOwned, ser::Serialize}; use rocket::{ @@ -65,6 +66,7 @@ static JWT_2FA_REMEMBER_ISSUER: LazyLock = LazyLock::new(|| format!("{}| static PRIVATE_RSA_KEY: OnceLock = OnceLock::new(); static PUBLIC_RSA_KEY: OnceLock = OnceLock::new(); +static PRELOGIN_KDF_KEY: OnceLock = OnceLock::new(); pub async fn initialize_keys() -> Result<(), Error> { use std::io::Error as IoError; @@ -99,9 +101,22 @@ pub async fn initialize_keys() -> Result<(), Error> { if PUBLIC_RSA_KEY.set(dec).is_err() { err!("PUBLIC_RSA_KEY must only be initialized once") } + + // Derived from the RSA key, so it stays the same across restarts without storing another secret + let prelogin_kdf_key = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, &priv_key_buffer), b"prelogin-kdf"); + if PRELOGIN_KDF_KEY.set(hmac::Key::new(hmac::HMAC_SHA256, prelogin_kdf_key.as_ref())).is_err() { + err!("PRELOGIN_KDF_KEY must only be initialized once") + } Ok(()) } +/// Returns an index in `0..len` derived from the email, the same for every request +pub fn prelogin_kdf_index(email: &str, len: usize) -> usize { + let tag = hmac::sign(PRELOGIN_KDF_KEY.wait(), email.as_bytes()); + let tag = tag.as_ref(); + usize::from(u16::from_be_bytes([tag[0], tag[1]])) % len +} + pub fn encode_jwt(claims: &T) -> String { match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) { Ok(token) => token, @@ -422,7 +437,7 @@ pub fn generate_file_download_claims(cipher_id: CipherId, file_id: AttachmentId) let time_now = Utc::now(); FileDownloadClaims { nbf: time_now.timestamp(), - exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(), + exp: (time_now + TimeDelta::try_minutes(1).unwrap()).timestamp(), iss: JWT_FILE_DOWNLOAD_ISSUER.to_string(), sub: cipher_id, file_id, @@ -1091,6 +1106,31 @@ fn ip_header_is_trusted(remote: Option) -> bool { trusted.split(',').filter_map(parse_trusted_proxy).any(|net| net.contains(&remote)) } +/// Each proxy appends the address it received the request from to a list header (`X-Forwarded-For: client, proxy1`). +/// Like ASP.NET's ForwardedHeaders, walk the entries of every occurrence of the header from the right and take the +/// first one that isn't a trusted proxy. If they all are, the leftmost one is the client. +fn client_ip_from_header(req: &Request<'_>, header: &str, is_trusted: impl Fn(IpAddr) -> bool) -> Option { + let values: Vec<&str> = req.headers().get(header).collect(); + client_ip_from_header_values(&values, header, is_trusted) +} + +fn client_ip_from_header_values(values: &[&str], header: &str, is_trusted: impl Fn(IpAddr) -> bool) -> Option { + let entries: Vec<&str> = values.iter().flat_map(|value| value.split(',')).map(str::trim).collect(); + + let mut client_ip = None; + for entry in entries.into_iter().rev() { + let Ok(ip) = entry.parse() else { + warn!("'{header}' header is malformed: {entry}"); + break; + }; + client_ip = Some(ip); + if !is_trusted(ip) { + break; + } + } + client_ip +} + #[rocket::async_trait] impl<'r> FromRequest<'r> for ClientIp { type Error = (); @@ -1099,15 +1139,7 @@ impl<'r> FromRequest<'r> for ClientIp { let remote = req.remote().map(|r| r.ip()); let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) { - req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| { - match ip.find(',') { - Some(idx) => &ip[..idx], - None => ip, - } - .parse() - .map_err(|_| warn!("'{}' header is malformed: {ip}", CONFIG.ip_header())) - .ok() - }) + client_ip_from_header(req, &CONFIG.ip_header(), |ip| ip_header_is_trusted(Some(ip))) } else { if CONFIG._ip_header_enabled() && req.headers().get_one(&CONFIG.ip_header()).is_some() { // Log the canonical IP, which is what the user filter will need to match against @@ -1226,7 +1258,7 @@ impl AuthMethod { match scope { None => err!("Missing scope"), Some(scope) if scope == &method_scope => Ok(method_scope), - Some(scope) => err!(format!("Scope ({scope}) not supported")), + Some(scope) => err!(format!("Scope ({}) not supported", scope.escape_debug())), } } } @@ -1345,3 +1377,52 @@ pub async fn refresh_tokens( Ok((device, auth_tokens)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn client_ip(values: &[&str]) -> Option { + let is_trusted = |ip: IpAddr| match ip { + IpAddr::V4(v4) => v4.is_private() || v4.is_loopback(), + IpAddr::V6(v6) => v6.is_loopback(), + }; + client_ip_from_header_values(values, "X-Forwarded-For", is_trusted) + } + + fn ip(s: &str) -> Option { + Some(s.parse().unwrap()) + } + + #[test] + fn ip_header_single_value() { + assert_eq!(client_ip(&["203.0.113.5"]), ip("203.0.113.5")); + assert_eq!(client_ip(&["10.0.0.7"]), ip("10.0.0.7")); + assert_eq!(client_ip(&["2001:db8::1"]), ip("2001:db8::1")); + } + + #[test] + fn ip_header_uses_rightmost_untrusted_entry() { + // Entries already in the request come before the one the proxy appended + assert_eq!(client_ip(&["198.51.100.1, 203.0.113.5"]), ip("203.0.113.5")); + // Trusted proxies on the right are skipped + assert_eq!(client_ip(&["198.51.100.1, 203.0.113.5, 10.0.0.2, 127.0.0.1"]), ip("203.0.113.5")); + // All trusted, so the leftmost entry is the client + assert_eq!(client_ip(&["192.168.1.10, 10.0.0.2"]), ip("192.168.1.10")); + } + + #[test] + fn ip_header_reads_every_occurrence() { + assert_eq!(client_ip(&["198.51.100.1", "203.0.113.5"]), ip("203.0.113.5")); + assert_eq!(client_ip(&["198.51.100.1", "203.0.113.5, 10.0.0.2"]), ip("203.0.113.5")); + } + + #[test] + fn ip_header_malformed_entries() { + assert_eq!(client_ip(&[]), None); + assert_eq!(client_ip(&[""]), None); + assert_eq!(client_ip(&["203.0.113.5, not-an-ip"]), None); + // Stops at the malformed entry and keeps the closest proxy + assert_eq!(client_ip(&["not-an-ip, 10.0.0.2"]), ip("10.0.0.2")); + } +} diff --git a/src/config.rs b/src/config.rs index b03e006c..7d35461c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -675,7 +675,8 @@ make_config! { /// other address use the remote IP instead, so a client can't spoof the header. /// Either the string "local" (the default, any non-global address, which covers a reverse proxy /// running on the same host or container network), the string "all" to accept it from anywhere, - /// or a comma separated list of IPs and CIDR ranges. + /// or a comma separated list of IPs and CIDR ranges. For a list header like X-Forwarded-For, the + /// client IP is the rightmost address that isn't a trusted proxy, so list every proxy in the chain. ip_header_trusted_proxies: String, true, def, "local".to_owned(); /// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google. /// To specify a custom icon service, set a URL template with exactly one instance of `{}`, @@ -1726,6 +1727,7 @@ where reg!("email/protected_action", ".html"); reg!("email/pw_hint_none", ".html"); reg!("email/pw_hint_some", ".html"); + reg!("email/recover_twofactor", ".html"); reg!("email/register_verify_email", ".html"); reg!("email/send_2fa_removed_from_org", ".html"); reg!("email/send_emergency_access_invite", ".html"); diff --git a/src/db/models/attachment.rs b/src/db/models/attachment.rs index 0536dde5..659648d0 100644 --- a/src/db/models/attachment.rs +++ b/src/db/models/attachment.rs @@ -61,7 +61,7 @@ impl Attachment { let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone())); Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id)) } else { - Ok(operator.presign_read(&self.get_file_path(), Duration::from_mins(5)).await?.uri().to_string()) + Ok(operator.presign_read(&self.get_file_path(), Duration::from_mins(1)).await?.uri().to_string()) } } diff --git a/src/db/models/auth_request.rs b/src/db/models/auth_request.rs index cc4b60fd..b06c9e56 100644 --- a/src/db/models/auth_request.rs +++ b/src/db/models/auth_request.rs @@ -4,7 +4,7 @@ use diesel::prelude::*; use serde_json::Value; use crate::{ - api::EmptyResult, + api::{ApiResult, EmptyResult}, crypto::ct_eq, db::{DbConn, schema::auth_requests}, error::MapResult, @@ -166,6 +166,21 @@ impl AuthRequest { .await } + /// Marks the request as used to log in. Returns false when it already was, so it only works once. + pub async fn set_authentication_date(uuid: &AuthRequestId, conn: &DbConn) -> ApiResult { + let now = Utc::now().naive_utc(); + conn.run(move |conn| { + diesel::update(auth_requests::table) + .filter(auth_requests::uuid.eq(uuid)) + .filter(auth_requests::authentication_date.is_null()) + .set(auth_requests::authentication_date.eq(now)) + .execute(conn) + .map(|updated| updated == 1) + .map_res("Error updating auth request") + }) + .await + } + pub fn check_access_code(&self, access_code: &str) -> bool { ct_eq(&self.access_code, access_code) } diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 9816c579..72684782 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -342,7 +342,7 @@ impl OrganizationApiKey { impl Organization { pub async fn save(&self, conn: &DbConn) -> EmptyResult { if !crate::util::is_valid_email(&self.billing_email) { - err!(format!("BillingEmail {} is not a valid email address", self.billing_email)) + err!(format!("BillingEmail {} is not a valid email address", self.billing_email.escape_debug())) } for member in &Membership::find_by_org(&self.uuid, conn).await { diff --git a/src/db/models/user.rs b/src/db/models/user.rs index b4f507f4..ead01373 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -261,6 +261,25 @@ impl User { !CONFIG.mail_enabled() || self.verified_at.is_some() } + /// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair + pub fn account_keys_json(&self) -> Value { + if self.private_key.is_some() { + json!({ + "publicKeyEncryptionKeyPair": { + "wrappedPrivateKey": self.private_key, + "publicKey": self.public_key, + "signedPublicKey": null, + "object": "publicKeyEncryptionKeyPair", + }, + "securityState": null, + "signatureKeyPair": null, + "object": "privateKeys" + }) + } else { + Value::Null + } + } + pub async fn to_json(&self, conn: &DbConn) -> Value { let mut orgs_json = Vec::new(); for c in Membership::find_confirmed_by_user(&self.uuid, conn).await { @@ -281,21 +300,7 @@ impl User { UserStatus::Enabled }; - let account_keys = if self.private_key.is_some() { - json!({ - "publicKeyEncryptionKeyPair": { - "wrappedPrivateKey": self.private_key, - "publicKey": self.public_key, - "signedPublicKey": null, - "object": "publicKeyEncryptionKeyPair", - }, - "securityState": null, - "signatureKeyPair": null, - "object": "privateKeys" - }) - } else { - Value::Null - }; + let account_keys = self.account_keys_json(); json!({ "_status": status as i32, @@ -325,7 +330,7 @@ impl User { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { if !crate::util::is_valid_email(&self.email) { - err!(format!("User email {} is not a valid email address", self.email)) + err!(format!("User email {} is not a valid email address", self.email.escape_debug())) } self.updated_at = Utc::now().naive_utc(); @@ -473,7 +478,7 @@ impl Invitation { pub async fn save(&self, conn: &DbConn) -> EmptyResult { if !crate::util::is_valid_email(&self.email) { - err!(format!("Invitation email {} is not a valid email address", self.email)) + err!(format!("Invitation email {} is not a valid email address", self.email.escape_debug())) } db_run! { conn: diff --git a/src/mail.rs b/src/mail.rs index b20f2853..2efb8d70 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -531,6 +531,21 @@ pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTi send_email(address, &subject, body_html, body_text).await } +pub async fn send_recover_twofactor(address: &str, ip: &str, dt: &NaiveDateTime) -> EmptyResult { + let fmt = "%A, %B %_d, %Y at %r %Z"; + let (subject, body_html, body_text) = get_text( + "email/recover_twofactor", + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "ip": ip, + "datetime": crate::util::format_naive_datetime_local(dt, fmt), + }), + )?; + + send_email(address, &subject, body_html, body_text).await +} + pub async fn send_incomplete_2fa_login( address: &str, ip: &str, diff --git a/src/sso.rs b/src/sso.rs index 01fbd906..6b575a3e 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -2,7 +2,6 @@ use std::{sync::LazyLock, time::Duration}; use chrono::Utc; use derive_more::{AsRef, Deref, Display, From, Into}; -use regex::Regex; use url::Url; use crate::{ @@ -175,16 +174,16 @@ pub fn decode_state(base64_state: &str) -> ApiResult { if let Ok(valid) = String::from_utf8(vec) { OIDCState(valid) } else { - err!(format!("Invalid utf8 chars in {base64_state} after base64 decoding")) + err!(format!("Invalid utf8 chars in {} after base64 decoding", base64_state.escape_debug())) } } else { - err!(format!("Failed to decode {base64_state} using base64")) + err!(format!("Failed to decode {} using base64", base64_state.escape_debug())) }; Ok(state) } -// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs +// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/StaticClientStore.cs pub async fn authorize_url( state: OIDCState, client_challenge: OIDCCodeChallenge, @@ -193,20 +192,21 @@ pub async fn authorize_url( binding_hash: Option, conn: DbConn, ) -> ApiResult { + // The desktop app uses the localhost callback when it can't register the custom scheme (AppImage, dev builds) + let localhost_redirect_uri = + (8065..=8070).map(|port| format!("http://localhost:{port}")).find(|uri| uri == raw_redirect_uri); + let redirect_uri = match client_id { "web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()), - "desktop" | "mobile" => "bitwarden://sso-callback".to_owned(), + "desktop" => localhost_redirect_uri.unwrap_or_else(|| "bitwarden://sso-callback".to_owned()), + "mobile" => "bitwarden://sso-callback".to_owned(), "cli" => { - let port_regex = Regex::new(r"^http://localhost:([0-9]{4})$").unwrap(); - if let Some(port) = - port_regex.captures(raw_redirect_uri).and_then(|captures| captures.get(1).map(|c| c.as_str())) - { - format!("http://localhost:{port}") - } else { - err!("Failed to extract port number") - } + let Some(uri) = localhost_redirect_uri else { + err!(format!("Unsupported redirect uri {}", raw_redirect_uri.escape_debug())) + }; + uri } - _ => err!(format!("Unsupported client {client_id}")), + _ => err!(format!("Unsupported client {}", client_id.escape_debug())), }; let (auth_url, sso_auth) = Client::authorize_url(state, client_challenge, redirect_uri, binding_hash).await?; @@ -242,7 +242,8 @@ impl OIDCIdentifier { // During the 2FA flow we will // - retrieve the user information and then only discover he needs 2FA. -// - second time we will rely on `SsoAuth.auth_response` since the `code` has already been exchanged. +// - second time we will rely on `SsoAuth.auth_response` since the `code` has already been exchanged, +// after checking the PKCE verifier again like upstream does on every redemption. // The `SsoAuth` will ensure that the user is authorized only once. pub async fn exchange_code( code: &OIDCCode, @@ -256,6 +257,7 @@ pub async fn exchange_code( }; if let Some(authenticated_user) = sso_auth.auth_response.clone() { + Client::check_client_verifier(&client_verifier, &sso_auth)?; return Ok((sso_auth, authenticated_user)); } diff --git a/src/sso_client.rs b/src/sso_client.rs index bc766586..a5dcaa83 100644 --- a/src/sso_client.rs +++ b/src/sso_client.rs @@ -212,6 +212,16 @@ impl Client { Ok((auth_url, SsoAuth::new(state, client_challenge, nonce.secret().clone(), redirect_uri, binding_hash))) } + // Check the client's PKCE verifier against the challenge it sent when starting the flow + pub fn check_client_verifier(client_verifier: &OIDCCodeVerifier, sso_auth: &SsoAuth) -> EmptyResult { + let verifier = PkceCodeVerifier::new(client_verifier.to_string()); + let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier); + if challenge.as_str() != &*sso_auth.client_challenge { + err!("PKCE client challenge failed") + } + Ok(()) + } + pub async fn exchange_code( &self, code: OIDCCode, @@ -234,15 +244,10 @@ impl Client { let mut exchange = self.core_client.exchange_code(oidc_code); - let verifier = PkceCodeVerifier::new(client_verifier.into()); if CONFIG.sso_pkce() { - exchange = exchange.set_pkce_verifier(verifier); + exchange = exchange.set_pkce_verifier(PkceCodeVerifier::new(client_verifier.into())); } else { - let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier); - if challenge.as_str() != String::from(sso_auth.client_challenge.clone()) { - err!("PKCE client challenge failed") - // Might need to notify admin ? how ? - } + Self::check_client_verifier(&client_verifier, sso_auth)?; } match exchange.request_async(&self.http_client).await { diff --git a/src/static/templates/email/recover_twofactor.hbs b/src/static/templates/email/recover_twofactor.hbs new file mode 100644 index 00000000..92e4330a --- /dev/null +++ b/src/static/templates/email/recover_twofactor.hbs @@ -0,0 +1,9 @@ +Two-Step Login Recovered From {{ip}} + +Two-step login on your account has been disabled by using the account's recovery code. + +* Date: {{datetime}} +* IP Address: {{ip}} + +If you did not perform this action, you should immediately take steps to secure your account. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/recover_twofactor.html.hbs b/src/static/templates/email/recover_twofactor.html.hbs new file mode 100644 index 00000000..847cbae0 --- /dev/null +++ b/src/static/templates/email/recover_twofactor.html.hbs @@ -0,0 +1,26 @@ +Two-Step Login Recovered From {{ip}} + +{{> email/email_header }} + + + + + + + + + + + + + +
+ Two-step login on your account has been disabled by using the account's recovery code. +
+ Date: {{datetime}} +
+ IP Address: {{ip}} +
+ If you did not perform this action, you should immediately take steps to secure your account. +
+{{> email/email_footer }} diff --git a/src/util.rs b/src/util.rs index 2a5678bc..9d319542 100644 --- a/src/util.rs +++ b/src/util.rs @@ -380,7 +380,7 @@ pub fn get_display_size(size: i64) -> String { let mut unit_counter = 0; loop { - if size > 1024. { + if size > 1024. && unit_counter < UNITS.len() - 1 { size /= 1024.; unit_counter += 1; } else { @@ -522,8 +522,8 @@ pub fn format_datetime_http(dt: &DateTime) -> String { expiry_time.to_rfc2822().replace("+0000", "GMT") } -pub fn parse_date(date: &str) -> NaiveDateTime { - DateTime::parse_from_rfc3339(date).unwrap().naive_utc() +pub fn parse_date(date: &str) -> Option { + DateTime::parse_from_rfc3339(date).ok().map(|dt| dt.naive_utc()) } /// Returns true or false if an email address is valid or not From 7c188967e7a467b19b2757bc3326f9cf3ccae327 Mon Sep 17 00:00:00 2001 From: Mathijs van Veluw Date: Mon, 5 Oct 2026 14:39:21 +0200 Subject: [PATCH 20/30] Update crates, Rust and other dependencies (#7814) - Update Rust to v1.99.0 - Updated GitHub Actions - Updated DataTables Signed-off-by: BlackDex --- .github/workflows/trivy.yml | 2 +- .github/workflows/typos.yml | 2 +- .pre-commit-config.yaml | 2 +- Cargo.lock | 360 ++++++++--------- Cargo.toml | 22 +- docker/Dockerfile.alpine | 8 +- docker/Dockerfile.debian | 2 +- rust-toolchain.toml | 2 +- src/static/scripts/datatables.css | 4 +- src/static/scripts/datatables.js | 650 ++++++++++++++++++------------ 10 files changed, 583 insertions(+), 471 deletions(-) diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index f0fbf192..81c51847 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -50,6 +50,6 @@ jobs: severity: CRITICAL,HIGH - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index 3c880e91..45f90bb9 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -23,4 +23,4 @@ jobs: # When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too - name: Spell Check Repo - uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2 + uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d2a509e2..ab96a84f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: # When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too - repo: https://github.com/crate-ci/typos - rev: 512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2 + rev: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3 hooks: - id: typos always_run: true diff --git a/Cargo.lock b/Cargo.lock index 2ccd4b1a..6c9d9237 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,15 +32,15 @@ dependencies = [ [[package]] name = "alloc-no-stdlib" -version = "2.0.4" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" [[package]] name = "alloc-stdlib" -version = "0.2.4" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" dependencies = [ "alloc-no-stdlib", ] @@ -151,9 +151,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.48" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" dependencies = [ "compression-codecs", "compression-core", @@ -323,9 +323,9 @@ dependencies = [ [[package]] name = "asyncband" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2d85fd3d291fabcc40c7232c92c280ec1754fd7b5d7ea769f143222143e179a" +checksum = "5161fb43cb8f231f06983cb2b8f9ee9cee14be0b1356d8383398cc0f312800d1" [[package]] name = "atomic" @@ -399,9 +399,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.9.4" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b43ad47adc2517efe3d706559d94b97e50e80e0321b3cadbc9f77cee88adcd" +checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -424,9 +424,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.111.0" +version = "1.114.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65700d75f6da78c89a61a49f1539979400a4609d3329376b1dc2133d7a1b2b90" +checksum = "12be2f9c8eef7f5fc919c96d538e629698469a02b4cb75408b26b1bd984ebe79" dependencies = [ "arc-swap", "aws-credential-types", @@ -442,7 +442,6 @@ dependencies = [ "aws-types", "bytes", "fastrand", - "http 0.2.12", "http 1.5.0", "regex-lite", "tracing", @@ -450,9 +449,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.113.0" +version = "1.116.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1147c254af7b6cee3ace90ae7da6f21366ce9a9b899035fa8a0fdf586eb75a" +checksum = "d7645db8724ea3b82fdccfb67e1b0f637c9d8ab0e7ef29d84884d8d8d73f805d" dependencies = [ "arc-swap", "aws-credential-types", @@ -468,7 +467,6 @@ dependencies = [ "aws-types", "bytes", "fastrand", - "http 0.2.12", "http 1.5.0", "regex-lite", "tracing", @@ -476,9 +474,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.116.0" +version = "1.119.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "035c7392e751d20bf1060d625d023587eb14099646110a9cff7c697bfe4b253a" +checksum = "03e490aa904849b38e770922faac779dd245f5cf8be81f19065acf18d276e4ad" dependencies = [ "arc-swap", "aws-credential-types", @@ -495,7 +493,6 @@ dependencies = [ "aws-smithy-xml", "aws-types", "fastrand", - "http 0.2.12", "http 1.5.0", "regex-lite", "tracing", @@ -503,9 +500,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.5.3" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31d955e76ff96acd555bf06fa0fa6d5bf9335fa84ae7c64481b20ae61d231f70" +checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -515,7 +512,6 @@ dependencies = [ "form_urlencoded", "hex", "hmac 0.13.0", - "http 0.2.12", "http 1.5.0", "percent-encoding", "sha2 0.11.0", @@ -536,9 +532,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.64.0" +version = "0.64.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" +checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -590,9 +586,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.14.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3296253d3a91b3f938a3f2bcce4daebadcb4aa4228153fcec90f9d23532b4484" +checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -602,9 +598,7 @@ dependencies = [ "aws-smithy-types", "bytes", "fastrand", - "http 0.2.12", "http 1.5.0", - "http-body 0.4.6", "http-body 1.1.0", "http-body-util", "pin-project-lite", @@ -615,15 +609,14 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.16.2" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d881a7b7ad179fd6611680c9de89f716fb00ab40299a9a7b8c6913e8f7511a8" +checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", "aws-smithy-types", "bytes", - "http 0.2.12", "http 1.5.0", "pin-project-lite", "tokio", @@ -655,16 +648,14 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.6.4" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b791f3ac597193fe1d08b82366986eb1f5bc31f2ac6c194c0855276116c76cd" +checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1" dependencies = [ "base64-simd", "bytes", "bytes-utils", - "http 0.2.12", "http 1.5.0", - "http-body 0.4.6", "http-body 1.1.0", "http-body-util", "itoa", @@ -756,9 +747,9 @@ dependencies = [ [[package]] name = "bigdecimal" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" dependencies = [ "autocfg", "libm", @@ -827,9 +818,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.4" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -838,9 +829,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.3" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -891,9 +882,9 @@ dependencies = [ [[package]] name = "cached" -version = "4.0.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a6cf8262820194a1488ece477f5fb5ba9256ef25229d525470e71d6e9a5835" +checksum = "292f39b5c459538e148825347c400cb2005a618b897d0e6be183bad677d460b7" dependencies = [ "ahash", "async-lock", @@ -901,7 +892,7 @@ dependencies = [ "cached_proc_macro_types", "hashbrown 0.17.1", "parking_lot", - "thiserror 2.0.20", + "thiserror 2.0.21", "web-time", ] @@ -926,9 +917,9 @@ checksum = "f5813789573ae815c8b4be58c4428e0e7ae05f0227678ba9de332ded585b9159" [[package]] name = "cc" -version = "1.4.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", "jobserver", @@ -1007,9 +998,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" dependencies = [ "brotli", "compression-core", @@ -1446,7 +1437,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -1811,13 +1802,12 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.41" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", "core_detect", - "multiversion", "multiversion_no_op", "rustversion", "scopeguard", @@ -1916,9 +1906,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flate2" @@ -2245,7 +2235,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", ] @@ -2321,8 +2311,8 @@ dependencies = [ "idna", "ipnet", "jni", - "rand 0.10.2", - "thiserror 2.0.20", + "rand 0.10.3", + "thiserror 2.0.21", "tinyvec", "tokio", "tracing", @@ -2341,9 +2331,9 @@ dependencies = [ "jni", "once_cell", "prefix-trie", - "rand 0.10.2", + "rand 0.10.3", "ring", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "url", @@ -2366,11 +2356,11 @@ dependencies = [ "ndk-context", "once_cell", "parking_lot", - "rand 0.10.2", + "rand 0.10.3", "resolv-conf", "smallvec", "system-configuration", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", ] @@ -2544,31 +2534,32 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http 1.5.0", "hyper 1.11.1", "hyper-util", "rustls 0.23.45", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tower-service", ] [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http 1.5.0", "http-body 1.1.0", + "httparse", "hyper 1.11.1", "ipnet", "libc", @@ -2867,7 +2858,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -2927,9 +2918,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -2980,9 +2971,9 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" dependencies = [ "spin 0.9.9", ] @@ -3013,16 +3004,16 @@ dependencies = [ "serde", "socket2 0.6.5", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tracing", "url", ] [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libm" @@ -3128,16 +3119,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "mea" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c709842c4ce65cb91e2666ad5319dfc1efc3af0d34f02075eddca9000d9f8afb" -dependencies = [ - "hashbrown 0.17.1", - "slab", -] - [[package]] name = "memchr" version = "2.8.3" @@ -3198,9 +3179,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -3246,27 +3227,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "multiversion" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" -dependencies = [ - "multiversion-macros", -] - -[[package]] -name = "multiversion-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" -dependencies = [ - "proc-macro2", - "quote", - "rustversion", - "syn 3.0.6", -] - [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -3400,9 +3360,9 @@ dependencies = [ [[package]] name = "num-modular" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd8e500409e6cd603b03e477c26a6caecdc27ac58979a53e881c75eafc079f44" +checksum = "26ac76200f74e658124f95fa63e1a82b2fd2181c5b2fdde80b3d89d2d3f905e7" [[package]] name = "num-order" @@ -3482,9 +3442,9 @@ dependencies = [ [[package]] name = "opendal" -version = "0.59.2" +version = "0.59.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fe43e16d96bed57937eb7c4a28559bf4c5fc4a3951656505d1fdd4f856349b4" +checksum = "3cbd193aec6d62efaff87611dfae52c132aed650bb3cb0c684c0a9394f09a42a" dependencies = [ "opendal-core", "opendal-service-fs", @@ -3493,9 +3453,9 @@ dependencies = [ [[package]] name = "opendal-core" -version = "0.59.2" +version = "0.59.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de4566a412776e3f65d53dd9fceced5b432a0a9c9a1e8d50ccf58823795e5c5b" +checksum = "54b5555d753dd6a6383dc0891254b72f3cdc40c011ba0372b560e0ae5aa29f5c" dependencies = [ "anyhow", "asyncband", @@ -3507,7 +3467,7 @@ dependencies = [ "log", "md-5", "percent-encoding", - "quick-xml", + "quick-xml 0.41.0", "reqsign-core", "serde", "serde_json", @@ -3519,9 +3479,9 @@ dependencies = [ [[package]] name = "opendal-http-transport-reqwest" -version = "0.59.2" +version = "0.59.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f772ce6c137ab43647726116d505a649a88d41928fcd813438eb30b02312bcbb" +checksum = "4d6588678f3040a3eab1819364dcbc6a746a8286d1ceb23a70a6614faa01dc6c" dependencies = [ "bytes", "futures", @@ -3533,9 +3493,9 @@ dependencies = [ [[package]] name = "opendal-service-fs" -version = "0.59.2" +version = "0.59.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0e7d8974aebd33be899200c9d0d50b2141fd5fa5c462b29e20653e3ddbfe339" +checksum = "ec5c16fe91b77c0364f66546a1c32b2999e9c3c3c75c2e908a282268e0890bac" dependencies = [ "bytes", "log", @@ -3547,9 +3507,9 @@ dependencies = [ [[package]] name = "opendal-service-s3" -version = "0.59.2" +version = "0.59.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2942f2d8d3d4953c0e8d07cd1244879628d084948e48ce6b6d808c5938f7858d" +checksum = "1d83e716c25a81fe2ca701894b8e14b9966acca3812af250e50132accb19da02" dependencies = [ "base64 0.23.1", "bytes", @@ -3558,7 +3518,7 @@ dependencies = [ "log", "md-5", "opendal-core", - "quick-xml", + "quick-xml 0.41.0", "reqsign-aws-v4", "reqsign-core", "reqsign-file-read-tokio", @@ -3800,9 +3760,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" dependencies = [ "memchr", "ucd-trie", @@ -3810,9 +3770,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" dependencies = [ "pest", "pest_generator", @@ -3820,9 +3780,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" dependencies = [ "pest", "pest_meta", @@ -3833,9 +3793,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" dependencies = [ "pest", ] @@ -3925,9 +3885,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" [[package]] name = "piper" @@ -4007,9 +3967,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -4022,9 +3982,9 @@ dependencies = [ [[package]] name = "pq-sys" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "574ddd6a267294433f140b02a726b0640c43cf7c6f717084684aaa3b285aba61" +checksum = "9152654059e4fb5b66a4052acf3ee43a7a7132332e44cdb72a81ee93bd038950" dependencies = [ "libc", "pkg-config", @@ -4129,6 +4089,16 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.47" @@ -4190,9 +4160,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -4329,9 +4299,9 @@ dependencies = [ [[package]] name = "reqsign-aws-core" -version = "3.1.1" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bac4749b7dfa7bfaccd01eb03e9dc795ed37e3f20d6f0f38e2c67ee85ad6bc86" +checksum = "d38c902d8ec745bd9eadd1abbcbe227f4c1fc2926b2ec8929f89234af3114500" dependencies = [ "bytes", "form_urlencoded", @@ -4339,7 +4309,7 @@ dependencies = [ "http 1.5.0", "log", "percent-encoding", - "quick-xml", + "quick-xml 0.42.0", "reqsign-core", "rust-ini", "serde", @@ -4350,14 +4320,14 @@ dependencies = [ [[package]] name = "reqsign-aws-v4" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff250f0fd0b913fbd565e405acc553da0f13bde30bfb5403178c9d0313cdc15f" +checksum = "906bfd028f96bfe73fdb892ce26f3b3f99b4f30feb9bd024be58bd6657f68b2b" dependencies = [ "bytes", "http 1.5.0", "log", - "quick-xml", + "quick-xml 0.42.0", "reqsign-aws-core", "reqsign-core", "serde", @@ -4365,20 +4335,19 @@ dependencies = [ [[package]] name = "reqsign-core" -version = "3.3.1" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff052daffb0599681c50f85c59e7236438976efe991ab864edd9f3b235501a0f" +checksum = "85fed85c436b6b920e4b36310f699a77771a91e38ae483df48447bd7d162671c" dependencies = [ "anyhow", + "asyncband", "base64 0.23.1", "bytes", - "futures", "hex", "hmac 0.13.0", "http 1.5.0", "jiff", "log", - "mea", "percent-encoding", "sha1 0.11.0", "sha2 0.11.0", @@ -4387,9 +4356,9 @@ dependencies = [ [[package]] name = "reqsign-file-read-tokio" -version = "3.0.6" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3235df90a6bca681aa47dd86f2393d122a6d77042aa8a7c81e218cd45c5bfc0" +checksum = "586a8e198057773f9616901fd723a4bc186812b9765bc0f20368661d6169d906" dependencies = [ "anyhow", "reqsign-core", @@ -4429,7 +4398,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-util", "tower", "tower-http", @@ -4622,7 +4591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -4735,9 +4704,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -4756,9 +4725,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" @@ -5024,9 +4993,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ "base64 0.23.1", "bs58", @@ -5045,9 +5014,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ "darling 0.24.1", "proc-macro2", @@ -5174,15 +5143,15 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -5192,9 +5161,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "socket2" @@ -5424,11 +5393,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -5444,9 +5413,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", @@ -5521,9 +5490,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -5559,9 +5528,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls 0.23.45", "tokio", @@ -5935,9 +5904,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.1" +version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -6008,7 +5977,7 @@ dependencies = [ "pastey 0.2.3", "percent-encoding", "pico-args", - "rand 0.10.2", + "rand 0.10.3", "regex", "reqsign-aws-v4", "reqsign-core", @@ -6093,9 +6062,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -6106,19 +6075,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6126,9 +6096,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", @@ -6139,9 +6109,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -6161,9 +6131,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -6611,9 +6581,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", @@ -6636,18 +6606,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 7dcae503..764fbd53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace.package] edition = "2024" -rust-version = "1.96.1" +rust-version = "1.97.1" license = "AGPL-3.0-only" repository = "https://github.com/dani-garcia/vaultwarden" publish = false @@ -77,7 +77,7 @@ dotenvy = { version = "0.15.7", default-features = false } # Numerical libraries num-traits = "0.2.19" num-derive = "0.5.1" -bigdecimal = "0.4.10" +bigdecimal = "0.4.11" # Web framework rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] } @@ -91,7 +91,7 @@ dashmap = "6.2.1" # Async futures futures = "0.3.34" -tokio = { version = "1.53.1", features = [ +tokio = { version = "1.53.2", features = [ "fs", "io-util", "net", @@ -123,13 +123,13 @@ diesel-derive-newtype = "2.1.3" libsqlite3-sys = { version = "0.38.2", optional = true } # Crypto-related libraries -rand = "0.10.2" +rand = "0.10.3" ring = "0.17.14" rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false } subtle = "2.6.1" # UUID generation -uuid = { version = "1.26.1", features = ["v4"] } +uuid = { version = "1.27.0", features = ["v4"] } # Date and time libraries chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } @@ -215,7 +215,7 @@ bytes = "1.12.1" svg-hush = "0.9.7" # Cache function results (Used for version check and favicon fetching) -cached = { version = "4.0.0", features = ["async"] } +cached = { version = "4.0.1", features = ["async"] } # Used for custom short lived cookie jar during favicon extraction cookie = "0.18.2" @@ -257,8 +257,8 @@ rpassword = "7.5.4" grass_compiler = { version = "0.13.4", default-features = false } # File are accessed through Apache OpenDAL -opendal = { version = "0.59.2", default-features = false, features = ["services-fs"] } -opendal-http-transport-reqwest = { version = "0.59.2", default-features = false, features = ["rustls-no-provider"], optional = true } +opendal = { version = "0.59.4", default-features = false, features = ["services-fs"] } +opendal-http-transport-reqwest = { version = "0.59.4", default-features = false, features = ["rustls-no-provider"], optional = true } # For retrieving AWS credentials, including temporary SSO credentials aws-config = { version = "1.12.0", optional = true, default-features = false, features = [ @@ -268,10 +268,10 @@ aws-config = { version = "1.12.0", optional = true, default-features = false, fe "sso", ] } aws-credential-types = { version = "1.3.0", optional = true } -aws-smithy-runtime-api = { version = "1.16.2", optional = true } +aws-smithy-runtime-api = { version = "1.19.0", optional = true } http = { version = "1.5.0", optional = true } -reqsign-aws-v4 = { version = "3.3.0", optional = true } -reqsign-core = { version = "3.3.1", optional = true } +reqsign-aws-v4 = { version = "3.3.1", optional = true } +reqsign-core = { version = "3.3.2", optional = true } # Strip debuginfo from the release builds # The debug symbols are to provide better panic traces diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine index a91deb07..6e6a79a3 100644 --- a/docker/Dockerfile.alpine +++ b/docker/Dockerfile.alpine @@ -32,10 +32,10 @@ FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330 ########################## ALPINE BUILD IMAGES ########################## ## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 ## And for Alpine we define all build images here, they will only be loaded when actually used -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.98.1 AS build_amd64 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.98.1 AS build_arm64 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.98.1 AS build_armv7 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.98.1 AS build_armv6 +FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.0 AS build_amd64 +FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.0 AS build_arm64 +FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.0 AS build_armv7 +FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.0 AS build_armv6 ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 62dff072..44d068ef 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -36,7 +36,7 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 -FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.98.1-slim-trixie AS build +FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.0-slim-trixie AS build # hadolint ignore=DL3067 COPY --from=xx / / ARG TARGETARCH diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 2be20926..a66db95c 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.98.1" +channel = "1.99.0" components = [ "rustfmt", "clippy" ] profile = "minimal" diff --git a/src/static/scripts/datatables.css b/src/static/scripts/datatables.css index 3a32d7ed..20f6d63f 100644 --- a/src/static/scripts/datatables.css +++ b/src/static/scripts/datatables.css @@ -4,10 +4,10 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-3.0.4 + * https://datatables.net/download/#bs5/dt-3.1.3 * * Included libraries: - * DataTables 3.0.4 + * DataTables 3.1.3 */ /*! DataTables Bootstrap 5 integration diff --git a/src/static/scripts/datatables.js b/src/static/scripts/datatables.js index 3d4c727d..b0502a69 100644 --- a/src/static/scripts/datatables.js +++ b/src/static/scripts/datatables.js @@ -4,13 +4,13 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-3.0.4 + * https://datatables.net/download/#bs5/dt-3.1.3 * * Included libraries: - * DataTables 3.0.4 + * DataTables 3.1.3 */ -/*! DataTables 3.0.4 +/*! DataTables 3.1.3 * Copyright (c) SpryMedia Ltd - datatables.net/license */ @@ -318,7 +318,7 @@ function htmlNum(d, decimalPoint, formatted, allowEmpty) { } return !html(d) ? null - : num$1(stripHtml(d), decimalPoint, formatted, allowEmpty) + : num(stripHtml(d), decimalPoint, formatted, allowEmpty) ? true : null; } @@ -341,7 +341,7 @@ function jquery(input) { * @param allowEmpty Allow an empty value to be considered a number * @returns `true` if numeric */ -function num$1(d, decimalPoint, formatted, allowEmpty) { +function num(d, decimalPoint, formatted, allowEmpty) { let type = typeof d; if (type === 'number' || type === 'bigint') { return true; @@ -383,7 +383,7 @@ var is = /*#__PURE__*/Object.freeze({ html: html, htmlNum: htmlNum, jquery: jquery, - num: num$1, + num: num, plainObject: plainObject }); @@ -534,12 +534,134 @@ var object = /*#__PURE__*/Object.freeze({ map: map$1 }); +// Can be assigned in DateTable.use() +var __win; +var __bootstrap; +var __foundation; +var __luxon$1; +var __moment$1; +var __dateTime; +var __dataTable; +var __jquery; +function getWin() { + if (__win) { + return __win; + } + if (typeof globalThis !== 'undefined' && globalThis.window) { + return globalThis.window; + } + if (typeof window !== 'undefined') { + return window; + } + return {}; +} +/** + * Set the libraries that DataTables uses, or the global objects. + * Note that the arguments can be either way around (legacy support) + * and the second is optional. See docs. + */ +function external (arg1, arg2) { + // Reverse arguments for legacy support + var module = typeof arg1 === 'string' ? arg2 : arg1; + var type = typeof arg2 === 'string' ? arg2 : arg1; + // Getter + if (module === undefined && typeof type === 'string') { + switch (type) { + case 'lib': + case 'jq': + if (__jquery) { + return __jquery; + } + let local = getWin().jQuery; + if (local && local.fn) { + return local; + } + return null; + case 'win': + return getWin(); + case 'doc': + return getWin().document; + case 'datatable': + return __dataTable; + case 'datetime': + return __dateTime; + case 'luxon': + return __luxon$1 || getWin().luxon || null; + case 'moment': + return __moment$1 || getWin().moment || null; + case 'bootstrap': + // Use local if set, otherwise try window, which could be undefined + return __bootstrap || getWin().bootstrap || null; + case 'foundation': + // Ditto + return __foundation || getWin().Foundation || null; + default: + return null; + } + } + // Setter + if (type === 'lib' || + type === 'jq' || + (module && module.fn && module.fn.jquery)) { + __jquery = module; + jQuerySetup(); + } + else if (type === 'datatable' || (module && module.isDataTable)) { + __dataTable = module; + } + else if (type === 'win' || (module && module.document)) { + __win = module; + } + else if (type === 'datetime' || (module && module.type === 'DateTime')) { + __dateTime = module; + } + else if (type === 'luxon' || (module && module.FixedOffsetZone)) { + __luxon$1 = module; + } + else if (type === 'moment' || (module && module.isMoment)) { + __moment$1 = module; + } + else if (type === 'bootstrap' || + (module && module.Modal && module.Modal.NAME === 'modal')) { + // This is currently for BS5 only. BS3/4 attach to jQuery, so no need to use `.use()` + __bootstrap = module; + } + else if (type === 'foundation' || (module && module.Reveal)) { + __foundation = module; + } +} +/** + * Attach jQuery to DataTables + */ +function jQuerySetup() { + if (!__dataTable || !__jquery) { + return; + } + // Provide access to the host jQuery object (circular reference) + __dataTable.$ = __jquery; + // jQuery integration - expose the core function. + __jquery.fn.dataTable = __dataTable; + // jQuery wrapper - returning a DataTable instance + __jquery.fn.DataTable = function (options) { + let table = new __dataTable(this.toArray(), options); + return table; + }; + // Legacy aliases + __jquery.fn.dataTableSettings = __dataTable.ext.settings; + __jquery.fn.dataTableExt = __dataTable.ext; + // All properties that are available to $.fn.dataTable should also be available + // on $.fn.DataTable + each(__dataTable, function (prop, val) { + __jquery.fn.DataTable[prop] = val; + }); +} + const defaults$5 = { cache: true, contentType: 'application/x-www-form-urlencoded; charset=UTF-8', headers: {}, traditional: false, - url: location.href + url: '' }; /** * Trigger an Ajax call to the server based on the configuration parameters @@ -698,8 +820,9 @@ function convertSpaces(sendData, options) { */ function isCrossDomain(url) { // Use the current page as the base to handle relative URLs correctly - const target = new URL(url, window.location.origin); - return target.origin !== window.location.origin; + const win = external('win'); + const target = new URL(url, win.location.origin); + return target.origin !== win.location.origin; } /** * Get the HTTP method from the Ajax request options @@ -1238,107 +1361,6 @@ var data = /*#__PURE__*/Object.freeze({ set: set$1 }); -// Can be assigned in DateTable.use() -var __bootstrap; -var __foundation; -var __luxon$1; -var __moment$1; -var __dateTime; -var __dataTable; -var __jquery; -/** - * Set the libraries that DataTables uses, or the global objects. - * Note that the arguments can be either way around (legacy support) - * and the second is optional. See docs. - */ -function external (arg1, arg2) { - // Reverse arguments for legacy support - var module = typeof arg1 === 'string' ? arg2 : arg1; - var type = typeof arg2 === 'string' ? arg2 : arg1; - // Getter - if (module === undefined && typeof type === 'string') { - switch (type) { - case 'lib': - case 'jq': - return __jquery !== undefined ? __jquery : window.jQuery || null; - case 'win': - return window; - case 'datatable': - return __dataTable; - case 'datetime': - return __dateTime; - case 'luxon': - return __luxon$1 || window.luxon || null; - case 'moment': - return __moment$1 || window.moment || null; - case 'bootstrap': - // Use local if set, otherwise try window, which could be undefined - return __bootstrap || window.bootstrap || null; - case 'foundation': - // Ditto - return __foundation || window.Foundation || null; - default: - return null; - } - } - // Setter - if (type === 'lib' || - type === 'jq' || - (module && module.fn && module.fn.jquery)) { - __jquery = module; - jQuerySetup(); - } - else if (type === 'datatable' || (module && module.isDataTable)) { - __dataTable = module; - } - else if (type === 'win' || (module && module.document)) { - window = module; - document = module.document; - } - else if (type === 'datetime' || (module && module.type === 'DateTime')) { - __dateTime = module; - } - else if (type === 'luxon' || (module && module.FixedOffsetZone)) { - __luxon$1 = module; - } - else if (type === 'moment' || (module && module.isMoment)) { - __moment$1 = module; - } - else if (type === 'bootstrap' || - (module && module.Modal && module.Modal.NAME === 'modal')) { - // This is currently for BS5 only. BS3/4 attach to jQuery, so no need to use `.use()` - __bootstrap = module; - } - else if (type === 'foundation' || (module && module.Reveal)) { - __foundation = module; - } -} -/** - * Attach jQuery to DataTables - */ -function jQuerySetup() { - if (!__dataTable || !__jquery) { - return; - } - // Provide access to the host jQuery object (circular reference) - __dataTable.$ = __jquery; - // jQuery integration - expose the core function. - __jquery.fn.dataTable = __dataTable; - // jQuery wrapper - returning a DataTable instance - __jquery.fn.DataTable = function (options) { - let table = new __dataTable(this.toArray(), options); - return table; - }; - // Legacy aliases - __jquery.fn.dataTableSettings = __dataTable.ext.settings; - __jquery.fn.dataTableExt = __dataTable.ext; - // All properties that are available to $.fn.dataTable should also be available - // on $.fn.DataTable - each(__dataTable, function (prop, val) { - __jquery.fn.DataTable[prop] = val; - }); -} - function debounce(fn, timeout = 250) { let timer; return function (...args) { @@ -1648,7 +1670,8 @@ function parseEventName(original) { */ function add(el, nameFull, handler, selector, one) { let jq = external('jq'); - if (jq) { + let doc = external('doc'); + if (jq && el.constructor !== EventTarget) { let method = one ? 'one' : 'on'; if (selector) { jq(el)[method](nameFull, selector, handler); @@ -1665,8 +1688,8 @@ function add(el, nameFull, handler, selector, one) { } // Special handling for the "ready" event - it will trigger when the content // is ready, but also if it is already ready, when added. - if (el === document && eventName === 'DOMContentLoaded' && nameFull.includes('ready')) { - if (document.readyState === 'complete') { + if (el === doc && eventName === 'DOMContentLoaded' && nameFull.includes('ready')) { + if (doc.readyState === 'complete') { handler(new Event('DOMContentLoaded')); return; } @@ -1737,7 +1760,7 @@ function add(el, nameFull, handler, selector, one) { */ function remove(el, nameFull, handler, selector) { let jq = external('jq'); - if (jq) { + if (jq && el.constructor !== EventTarget) { if (selector) { jq(el).off(nameFull, selector, handler); } @@ -1805,7 +1828,8 @@ function remove(el, nameFull, handler, selector) { */ function trigger(el, nameFull, bubbles = false, args = [], eventProps = null, returnEvent = false) { let jq = external('jq'); - if (jq) { + let win = external('win'); + if (jq && el.constructor !== EventTarget) { let method = bubbles ? 'trigger' : 'triggerHandler'; let ev = jq.Event(nameFull); each(eventProps, (key, val) => { @@ -1824,10 +1848,19 @@ function trigger(el, nameFull, bubbles = false, args = [], eventProps = null, re if (!eventName) { return false; } - let isMouseEvent = _mouseEvents.includes(eventName.toLowerCase()); - let event = isMouseEvent - ? new MouseEvent(eventName, { bubbles, cancelable: true }) - : new Event(eventName, { bubbles, cancelable: true }); + let event; + // If running in Node, we might be using JSDom which has its own event + // classes. The EventTarget is always the global, separate from the window + // events. 99% of the time, they will be the same. + if (el.constructor === EventTarget) { + event = new Event(eventName, { bubbles, cancelable: true }); + } + else { + let isMouseEvent = _mouseEvents.includes(eventName.toLowerCase()); + event = isMouseEvent + ? new win.MouseEvent(eventName, { bubbles, cancelable: true }) + : new win.Event(eventName, { bubbles, cancelable: true }); + } // Set the extra properties for the event setEventProp(event, 'namespace', namespaces.join('.')); setEventProp(event, '_args', args || []); @@ -1849,7 +1882,7 @@ var win = { */ height() { var _a; - return ((_a = document.querySelector('html')) === null || _a === void 0 ? void 0 : _a.clientHeight) || 0; + return ((_a = external('doc').querySelector('html')) === null || _a === void 0 ? void 0 : _a.clientHeight) || 0; }, /** * Remove an event handler from the window @@ -1910,17 +1943,21 @@ var win = { */ width() { var _a; - return ((_a = document.querySelector('html')) === null || _a === void 0 ? void 0 : _a.clientWidth) || 0; + return ((_a = external('doc').querySelector('html')) === null || _a === void 0 ? void 0 : _a.clientWidth) || 0; } }; function create$3(name) { - let el = document.createElement(name); + let el = external('doc').createElement(name); return new Dom(el); } function select(selector) { return new Dom(selector); } +/** + * Event target for events which don't use the document + */ +const _staticEventTarget = new EventTarget(); /** * `Dom` is a class that provides a chaining UI for simple DOM manipulation and * selection. @@ -1951,7 +1988,7 @@ class Dom { add(selector, sort = true) { if (selector) { if (typeof selector === 'string') { - let elements = Array.from(document.querySelectorAll(selector)); + let elements = Array.from(external('doc').querySelectorAll(selector)); addArray(this, elements); } else if (selector instanceof Dom) { @@ -2387,7 +2424,7 @@ class Dom { include === 'inner' || include === 'outer') { let el = this[0]; - let computed = window.getComputedStyle(this[0]); + let computed = external('win').getComputedStyle(this[0]); let rectHeight = el.getBoundingClientRect().height; if (!include || include === 'content') { // Content. Minus scrollbar if there is one. This is basically @@ -2465,7 +2502,7 @@ class Dom { if (this.count() === 0) { return false; } - return document.body.contains(this[0]); + return external('doc').body.contains(this[0]); } /** * Determine if the first element in the result set is visible or not. @@ -2575,10 +2612,10 @@ class Dom { }; } let box = this[0].getBoundingClientRect(); - let docElem = document.documentElement; + let docElem = external('doc').documentElement; return { - top: box.top + window.pageYOffset - docElem.clientTop, - left: box.left + window.pageXOffset - docElem.clientLeft + top: box.top + external('win').pageYOffset - docElem.clientTop, + left: box.left + external('win').pageXOffset - docElem.clientLeft }; } /** @@ -2589,7 +2626,7 @@ class Dom { * @returns Instance with the result set as the offset parents */ offsetParent() { - return this.map(el => el.offsetParent || document.body); + return this.map(el => el.offsetParent || external('doc').body); } on(arg1, arg2, arg3) { let { handler, names, selector } = normaliseEventParams(arg1, arg2, arg3); @@ -2885,7 +2922,7 @@ class Dom { include === 'inner' || include === 'outer') { let el = this[0]; - let computed = window.getComputedStyle(el); + let computed = external('win').getComputedStyle(el); let rectWidth = el.getBoundingClientRect().width; if (!include || include === 'content') { // Content. Minus scrollbar if there is one. This is basically @@ -2938,6 +2975,15 @@ Dom.c = create$3; * @returns Dom instance for manipulating the new element */ Dom.create = create$3; +/** + * Non-DOM event listener. Add an event listener with no document. + * + * @param name Event name + * @param fn Event callback + */ +Dom.on = function (name, fn) { + add(_staticEventTarget, name, fn, null, false); +}; /** * Select items from the document and wrap in a `Dom` instance (alias of * `select`) @@ -2959,6 +3005,20 @@ Dom.select = select; * false to disable and have it jump to the end. */ Dom.transitions = true; +/** + * Trigger an event non-DOM events. + * + * @param name Event name. This can optionally include period separated + * namespaces. Multiple events can be added by space separation of the + * names. + * @param args Arguments to pass to the event handlers (after the event + * object, which is always the first parameter). + * @param props An object of key/value pairs which should be added to the + * event object that is created and fired for the events. + */ +Dom.trigger = function (name, args, props) { + trigger(_staticEventTarget, name, true, args, props); +}; /** * Window object methods */ @@ -3010,10 +3070,10 @@ function documentOrder(a, b) { let position = a.compareDocumentPosition(b); if (position & Node.DOCUMENT_POSITION_DISCONNECTED) { // One is disconnected - find which - if (document.body.contains(a)) { + if (external('doc').body.contains(a)) { return -1; } - else if (document.body.contains(b)) { + else if (external('doc').body.contains(b)) { return 1; } return 0; @@ -3475,7 +3535,7 @@ function invalidateRow(settings, rowIdx, src, colIdx) { else { // Reading from data object, update the DOM var cells = row.cells; - var display = getRowDisplay(settings, rowIdx); + var display = getDisplay(settings, rowIdx); if (cells.length) { if (colIdx !== undefined) { writeCell(cells[colIdx], display[colIdx]); @@ -3734,8 +3794,7 @@ function calculateColumnWidths(settings) { // Construct a worst case table with the widest, assign any user defined // widths, then insert it into the DOM and allow the browser to do all // the hard work of calculating table widths - var tmpTable = Dom - .s(table.cloneNode()) + var tmpTable = Dom.s(table.cloneNode()) .css('visibility', 'hidden') .css('margin', '0') .attrRemove('id'); @@ -3781,7 +3840,7 @@ function calculateColumnWidths(settings) { } if (longestData.length) { for (i = 0; i < longestData[0].length; i++) { - var tr = Dom.c('tr').appendTo(tmpTable.find('tbody')); + var tr = Dom.c('tr').appendTo(tmpTable.children('tbody')); for (j = 0; j < visibleColumns.length; j++) { columnIdx = visibleColumns[j]; column = columns[columnIdx]; @@ -3789,8 +3848,7 @@ function calculateColumnWidths(settings) { var autoClass = ext.type.className[column.type]; var padding = column.contentPadding || (scrollX ? '-' : ''); var text = longest + padding; - var cell = Dom - .c('td') + var cell = Dom.c('td') .classAdd(autoClass) .classAdd(column.className) .appendTo(tr); @@ -3812,8 +3870,7 @@ function calculateColumnWidths(settings) { // with minimal height, so it has no effect on if the container scrolls // or not. Otherwise it might trigger scrolling when it actually isn't // needed - var holder = Dom - .c('div') + var holder = Dom.c('div') .css(scrollX || scrollY ? { position: 'absolute', @@ -3883,8 +3940,7 @@ function calculateColumnWidths(settings) { // This flag allows the above to be satisfied. var first = Dom.s(settings.tableWrapper).isVisible(); // Use an empty div to attach the observer so it isn't impacted by height changes - var resizer = Dom - .c('div') + var resizer = Dom.c('div') .css({ width: '100%', height: '0' @@ -3937,14 +3993,19 @@ function wrapperWidth(settings) { */ function getWideStrings(settings, colIdx) { var column = settings.columns[colIdx]; - // Do we need to recalculate (i.e. was invalidated), or just use the cached data? - if (!column.wideStrings) { + // Do we need to recalculate (i.e. was invalidated), or just use the cached + // data? Recalculate if display based for the column. + if (!column.wideStrings || column.widthCalc === 'display') { var allStrings = []; var collection = []; + let rows = settings.displayMaster; + if (column.widthCalc === 'display') { + rows = settings.display.slice(settings.displayStart, settings.displayStart + settings.pageLength); + } // Create an array with the string information for the column - for (var i = 0, iLen = settings.displayMaster.length; i < iLen; i++) { - var rowIdx = settings.displayMaster[i]; - var data = getRowDisplay(settings, rowIdx)[colIdx]; + for (var i = 0, len = rows.length; i < len; i++) { + var rowIdx = rows[i]; + var data = getDisplay(settings, rowIdx, colIdx); var cellString = data && typeof data === 'object' && data.nodeType ? data.innerHTML : data + ''; @@ -4123,20 +4184,20 @@ function featureTable(settings) { let scrollBody = children.eq(1); let scrollFoot = children.eq(2); // When the body is scrolled, then we also want to scroll the header and - // footer. Note that each element has its own scroll listener, and that in - // turn sets the scroll for the other elements. However this doesn't lead to - // an infinite loop as `scroll` is only triggered if the value changes. + // footer. Equally we want changes in the header / footer to transition the + // body. The header and footer are `overflow: hidden`, so the user can't + // scroll those elements other than triggering a focus action in them. scrollBody.on('scroll.DT', () => { let scrollLeft = scrollBody.scrollLeft(); scrollHead.scrollLeft(scrollLeft); scrollFoot.scrollLeft(scrollLeft); }); - scrollHead.on('scroll.DT', () => { + scrollHead.on('focusin.DT', () => { let scrollLeft = scrollHead.scrollLeft(); scrollBody.scrollLeft(scrollLeft); scrollFoot.scrollLeft(scrollLeft); }); - scrollFoot.on('scroll.DT', () => { + scrollFoot.on('focusin.DT', () => { let scrollLeft = scrollFoot.scrollLeft(); scrollHead.scrollLeft(scrollLeft); scrollBody.scrollLeft(scrollLeft); @@ -4310,7 +4371,9 @@ function scrollDraw(settings) { .find('[role]') .attrRemove('role'); table.find('tbody tr:not([role])').attr('role', 'row'); - table.find('tbody td:not([role]), tbody th:not([role])').attr('role', 'cell'); + table + .find('tbody td:not([role]), tbody th:not([role])') + .attr('role', 'cell'); scrollAria(headerCopy); scrollAria(footerCopy); // Adjust the position of the header in case we loose the y-scrollbar @@ -5756,8 +5819,12 @@ function ajaxDataSrcParam(settings, param, json) { return json[old] !== undefined ? json[old] : json[param]; } -const __filter_div = Dom.c('div').get(0); -const __filter_div_textContent = __filter_div.textContent !== undefined; +let __filter_div; +let __filter_div_textContent; +function createFilterDiv() { + __filter_div = Dom.c('div').get(0); + __filter_div_textContent = __filter_div.textContent !== undefined; +} /** * Filter the table using both the global filter and column based filtering * @@ -5945,6 +6012,9 @@ function filterData(settings) { let column; let j, jen, cellData, row; let wasInvalidated = false; + if (!__filter_div) { + createFilterDiv(); + } for (let rowIdx = 0; rowIdx < data.length; rowIdx++) { if (!data[rowIdx]) { continue; @@ -5991,14 +6061,7 @@ function filterData(settings) { return wasInvalidated; } -/** - * Render and cache a row's display data for the columns, if required - * - * @param settings DataTables settings object - * @param rowIdx Row index - * @returns Array with display information - */ -function getRowDisplay(settings, rowIdx) { +function getDisplay(settings, rowIdx, colIdx = null) { var rowModal = settings.data[rowIdx]; var columns = settings.columns; if (!rowModal) { @@ -6007,11 +6070,29 @@ function getRowDisplay(settings, rowIdx) { if (!rowModal.displayData) { // Need to render and cache rowModal.displayData = []; - for (var colIdx = 0, len = columns.length; colIdx < len; colIdx++) { - rowModal.displayData.push(getCellData(settings, rowIdx, colIdx, 'display')); + } + const displayData = rowModal.displayData; + // Check if we need to actually perform the render to get the display data + if (!displayData._complete) { + if (colIdx !== null) { + // Single cell + if (!displayData[colIdx]) { + displayData[colIdx] = getCellData(settings, rowIdx, colIdx, 'display'); + } + } + else { + // Whole row + for (var i = 0, len = columns.length; i < len; i++) { + if (!displayData[i]) { + displayData[i] = getCellData(settings, rowIdx, i, 'display'); + displayData._complete = true; + } + } } } - return rowModal.displayData; + // At this point the item(s) we want will have been created - possibly all, + // but that doesn't matter, as long as we've got the one we want. + return colIdx !== null ? displayData[colIdx] : displayData; } /** * Create a new TR element (and it's TD children) for a row @@ -6023,10 +6104,10 @@ function getRowDisplay(settings, rowIdx) { * @param tds Array of TD|TH elements for the row - must be given if trIn is. */ function createTr(settings, rowIdx, trIn, tds) { - var row = settings.data[rowIdx], cells = [], tr, td, column, i, iLen, create, trClass = settings.classes.tbody.row; + var row = settings.data[rowIdx], cells = [], tr, td, column, i, iLen, create, trClass = settings.classes.tbody.row, doc = external('doc'); if (row && row.tr === null) { let rowData = row.data; - tr = trIn || document.createElement('tr'); + tr = trIn || doc.createElement('tr'); row.tr = tr; row.cells = cells; Dom.s(tr).classAdd(trClass); @@ -6041,7 +6122,7 @@ function createTr(settings, rowIdx, trIn, tds) { column = settings.columns[i]; create = trIn && tds && tds[i] ? false : true; td = create - ? document.createElement(column.cellType) + ? doc.createElement(column.cellType) : tds[i]; if (!td) { log(settings, 0, 'Incorrect column count', 18); @@ -6051,7 +6132,7 @@ function createTr(settings, rowIdx, trIn, tds) { column: i }; cells.push(td); - var display = getRowDisplay(settings, rowIdx); + var display = getDisplay(settings, rowIdx); // Need to create the HTML if new, or if a rendering function is // defined if (create || @@ -6444,10 +6525,8 @@ function _emptyRow(settings) { else if (lang.emptyTable && recordsTotal(settings) === 0) { zero = lang.emptyTable; } - return Dom - .c('tr') - .append(Dom - .c('td') + return Dom.c('tr') + .append(Dom.c('td') .attr('colSpan', visibleColumns(settings)) .classAdd(settings.classes.empty.row) .html(zero)) @@ -6574,15 +6653,12 @@ function detectHeader(settings, thead, write) { cell.parent(':not([data-dt-order=disable])').count() !== 0 && cell.find('div.dt-column-order').count() === 0) { - Dom.c('div') - .classAdd('dt-column-order') - .appendTo(cell); + Dom.c('div').classAdd('dt-column-order').appendTo(cell); } // We need to wrap the elements in the header in another // element to use flexbox layout for those elements var headerFooter = isHeader ? 'header' : 'footer'; - if (cell.find('div.dt-column-' + headerFooter).count() === - 0) { + if (cell.find('div.dt-column-' + headerFooter).count() === 0) { Dom.c('div') .classAdd('dt-column-' + headerFooter) .append(Array.from(cell.get(0).childNodes)) @@ -6792,6 +6868,12 @@ function callbackFire(ctx, callbackArr, eventName, args, bubbles = false) { }); } if (eventName !== null) { + // Non-DOM events + if (bubbles) { + Dom.trigger(eventName + '.dt', args, { + dt: ctx.api + }); + } let table = Dom.s(ctx.table); let result = table.trigger(eventName + '.dt', bubbles, args, { dt: ctx.api @@ -7417,24 +7499,29 @@ function __mlHelper(localeString) { }; }; } -// Based on locale, determine standard number formatting -// Fallback for legacy browsers is US English -var __thousands = ','; -var __decimal = '.'; -if (window.Intl !== undefined) { - try { - var num = new Intl.NumberFormat().formatToParts(100000.1); - for (var i = 0; i < num.length; i++) { - if (num[i].type === 'group') { - __thousands = num[i].value; - } - else if (num[i].type === 'decimal') { - __decimal = num[i].value; +var __thousands; +var __decimal; +function detectIntl() { + let win = DataTable.use('win'); + // Based on locale, determine standard number formatting + // Fallback for legacy browsers is US English + __thousands = ','; + __decimal = '.'; + if (win.Intl !== undefined) { + try { + var num = new Intl.NumberFormat().formatToParts(100000.1); + for (var i = 0; i < num.length; i++) { + if (num[i].type === 'group') { + __thousands = num[i].value; + } + else if (num[i].type === 'decimal') { + __decimal = num[i].value; + } } } - } - catch (e) { - // noop + catch (e) { + // noop + } } } /** @@ -7475,6 +7562,9 @@ var helpers = { datetime: __mlHelper('toLocaleString'), time: __mlHelper('toLocaleTimeString'), number: function (thousands, decimal, precision, prefix, postfix) { + if (!__thousands && !__decimal) { + detectIntl(); + } // Auto locale detection if (thousands === null || thousands === undefined) { thousands = __thousands; @@ -7556,7 +7646,8 @@ const defaults$2 = { title: null, type: null, visible: true, - width: null + width: null, + widthCalc: 'all' }; /** @@ -7657,6 +7748,10 @@ class Settings { * Width of the column */ this.width = null; + /** + * Which cells to use when calculating the column width + */ + this.widthCalc = 'all'; /** * Width of the column when it was first "encountered" */ @@ -7824,7 +7919,7 @@ function browserDetect(ctx) { .css({ position: 'fixed', top: '0', - left: -1 * window.pageXOffset + 'px', // allow for scrolling + left: -1 * external('win').pageXOffset + 'px', // allow for scrolling height: '1px', width: '1px', overflow: 'hidden' @@ -7868,9 +7963,7 @@ function addColumn(settings) { data: defaults$2.data ? defaults$2.data : columnIdx, idx: columnIdx, searchFixed: {}, - colEl: Dom - .c('col') - .attr('data-dt-column', columnIdx) + colEl: Dom.c('col').attr('data-dt-column', columnIdx) }); settings.columns.push(column); // Legacy support for `searchCols` property. If set, and there is a value @@ -7878,9 +7971,7 @@ function addColumn(settings) { // specific `search` option is applied in `columnOptions`, but we always // want the search object for the column to exist. let searchCols = settings.searchCols; - settings.searches[columnIdx] = create$1(searchCols[columnIdx] - ? hungarianToCamel(searchCols[columnIdx]) - : {}); + settings.searches[columnIdx] = create$1(searchCols[columnIdx] ? hungarianToCamel(searchCols[columnIdx]) : {}); settings.searches[columnIdx].columns = [columnIdx]; } /** @@ -8370,12 +8461,18 @@ function columnsFromHeader(cell) { */ function columnCells(header, row = null, column = null) { var out = []; + var included = []; for (var i = 0; i < header.length; i++) { if (row === null || row === i) { for (var j = 0; j < header[i].length; j++) { var cell = header[i][j].cell; - if ((column === null || column === j) && !out.includes(cell)) { - out.push(cell); + if ((column === null || column === j) && + !included.includes(cell)) { + included.push(cell); + out.push({ + cell, + row: header[i].row + }); } } } @@ -8390,31 +8487,35 @@ function columnCells(header, row = null, column = null) { * @returns Array of selected elements */ function columnOrderingCells(settings, notSelector) { - var cells = []; - var titleRow = settings.titleRow; + let combined = []; + let titleRow = settings.titleRow; if (titleRow === true) { // Top row (legacy `orderCellsTop`) - cells = columnCells(settings.header, 0); + combined = columnCells(settings.header, 0); } else if (titleRow === false) { // Bottom row (legacy `orderCellsTop`) - cells = columnCells(settings.header, settings.header.length - 1); + combined = columnCells(settings.header, settings.header.length - 1); } else if (titleRow !== null) { // Specific row - cells = columnCells(settings.header, titleRow); + combined = columnCells(settings.header, titleRow); } else { // All - cells = columnCells(settings.header); + combined = columnCells(settings.header); } + let cells = combined.map(c => c.cell); + let rows = combined.map(c => c.row); return Dom.s(cells) .filter('th' + notSelector + ', td' + notSelector) .filter(el => { - return (Dom.s(el) - .parent() - .filter(notSelector) - .length !== 0); + let idx = cells.indexOf(el); + if (idx >= 0) { + return Dom.s(rows[idx]).filter(notSelector).length !== 0; + } + // Shouldn't be able to get here! + return true; }); } @@ -8613,7 +8714,7 @@ const ext = { /** * DataTables build type (expanded by the download builder) */ - builder: 'bs5/dt-3.0.4', + builder: 'bs5/dt-3.1.3', /** * Buttons. For use with the Buttons extension for DataTables. This is * defined here so other extensions can define buttons regardless of load @@ -8759,7 +8860,7 @@ const ext = { * Software version * @type string */ - version: '3.0.4' + version: '3.1.3' }; // // Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts @@ -9171,9 +9272,6 @@ const properties = {}; const classes = { Api }; -// TODO debug -window.classes = classes; -window.properties = properties; /** * Create a new API "class" (function), used for nested levels of the API - e.g. * `ApiRows` and `ApiColumn`. @@ -9958,7 +10056,7 @@ function selectColumns(settings, selector, opts) { } // Selector if (match && match[1]) { - let columnElements = columnCells(settings.header, null, col.idx); + let columnElements = columnCells(settings.header, null, col.idx).map(c => c.cell); return Dom.s(columnElements) .filter(match[1]) .count() > 0 @@ -9995,7 +10093,7 @@ function selectColumns(settings, selector, opts) { return [s._DT_CellIndex.column]; } // Selector on the TH elements for the columns - var result = Dom.s(columnCells(settings.header)) + var result = Dom.s(columnCells(settings.header).map(c => c.cell)) .filter(s) .mapTo(el => { return columnsFromHeader(el); @@ -10205,11 +10303,14 @@ registerPlural('columns().widths()', 'column().width()', function () { // Injects a fake row into the table for just a moment so the widths can // be read, regardless of colspan in the header and rows being present // in the body - var columns = this.columns(':visible'); - var row = Dom.c('tr').html('' + Array(columns.count()).join('') + ''); + let columns = this.columns(':visible'); + let row = Dom.c('tr'); + for (let i = 0; i < columns.count(); i++) { + Dom.c('td').appendTo(row); + } Dom.s(this.table().body()).append(row); - var widths = []; - var indexes = columns.indexes(); + let widths = []; + let indexes = columns.indexes(); row.children().each((el, idx) => { widths[indexes[idx]] = Dom.s(el).width('outer'); }); @@ -10389,7 +10490,7 @@ register('processing()', function (show) { }); // Add the state event handler in time for the initial draw to save state -Dom.s(document).on('preInit.dt', function (e, context) { +Dom.on('preInit.dt', function (e, context) { var api = new Api(context); api.on('stateSaveParams.DT', function (ev, settings, d) { // This could be more compact with the API, but it is a lot faster as a @@ -10412,7 +10513,7 @@ Dom.s(document).on('preInit.dt', function (e, context) { }); }); // But initial details can wait until the end -Dom.s(document).on('plugin-init.dt', function (e, context) { +Dom.on('plugin-init.dt', function (e, context) { var api = context.api; // And the initial load state detailsStateLoad(api, api.state.loaded()); @@ -11225,7 +11326,7 @@ const _licenseInfo = { expires: null, valid: null }; -const _wm = Dom.c('div'); +let _wm; const _publicKey = 'BE1A9w9D9U/4s4/TogY+1sW/dLJ8IquzK1PmV70J93ZTIvXMZ0eV2NAb52ntpgwVFySSB2fOI7geLNO737rQAyo='; /** * Convert a base64 string to a binary array @@ -11254,7 +11355,7 @@ function check(releaseDate, software) { noticeDisplay(); } else if (_licenseInfo.valid === false) { - noticePrep('License key invalid'); + noticePrep('Invalid license key'); noticeDisplay(); } else if (_licenseInfo.type === 'trial') { @@ -11264,7 +11365,7 @@ function check(releaseDate, software) { : -1; if (remaining < 0) { // Trial expires - consoleMsg('Your trial has now expired - https://datatables.net/plus', 'warn'); + consoleMsg('Your trial has now expired. Please visit https://datatables.net/plus to purchase a license', 'warn'); noticePrep('Trial expired'); noticeDisplay(); return false; @@ -11339,7 +11440,15 @@ const key = function (key) { * @returns */ function noticePrep(text) { + // Already prep-ed. Rather than possibly showing multiple messages, just + // let the first one show. + if (_ready) { + return; + } if (!_ready) { + if (!_wm) { + _wm = Dom.c('div'); + } let shadow = _wm[0].attachShadow({ mode: 'closed' }); let notice = Dom.c('div').css({ position: 'fixed', @@ -11351,37 +11460,59 @@ function noticePrep(text) { padding: '0.5em 1em', 'font-family': 'sans-serif', 'font-size': '12px', + 'line-height': '1.4em', + 'text-align': 'center', 'border-radius': '4px', 'z-index': '10000', - 'box-shadow': '0 2px 5px rgba(0,0,0,0.2)' + 'box-shadow': '1px 3px 5px rgba(0, 0, 0, 0.333)' }); - Dom.c('a') - .attr('href', 'https://datatables.net/tn/25') - .attr('target', '_blank') - .css({ - color: 'inherit', - 'text-decoration': 'none' - }) - .appendTo(notice); - if (!text) { - text = 'License key required'; - } shadow.appendChild(notice[0]); _notice = notice; _ready = true; } + _notice.empty(); if (text) { + // Specific notice + _notice + .append(Dom.c('span').text('DataTables Plus')) + .append(Dom.c('br')) + .append(Dom.c('span').text(text + ' - ')) + .append(Dom.c('a') + .attr('href', 'https://datatables.net/tn/25') + .attr('target', '_blank') + .css({ + color: 'inherit' + }) + .html('learn more »')); + } + else { _notice - .find('a') - .html('DataTables Plus: ' + text + ' - learn more »'); + .append(Dom.c('span').text('DataTables Plus - Evaluation Mode')) + .append(Dom.c('br')) + .append(Dom.c('a') + .attr('href', 'https://datatables.net/plus/trial') + .attr('target', '_blank') + .css({ + color: 'inherit' + }) + .text('Start a Free Trial')) + .append(Dom.c('span').text(' - ')) + .append(Dom.c('a') + .attr('href', 'https://datatables.net/plus') + .attr('target', '_blank') + .css({ + color: 'inherit' + }) + .text('Purchase a License')); } } /** * Display the license notice */ function noticeDisplay() { - if (!_processingKey && document.body && !document.body.contains(_wm[0])) { - document.body.appendChild(_wm[0]); + let doc = external('doc'); + if (!_processingKey && doc.body && !doc.body.contains(_wm[0])) { + doc.body.appendChild(_wm[0]); } } /** @@ -11455,7 +11586,7 @@ function plus (DataTable) { value: function (releaseDate, software = '') { // Unsecure sites are only useful for development, so allow there // and on the site. - let host = window.location.hostname; + let host = external('win').location.hostname; let isDev = host === '192.168.234.234' || host.endsWith('.datatables.net') || host === 'datatables.net'; @@ -11479,7 +11610,8 @@ function plus (DataTable) { } function getSubtle() { // Backwards compat for old browsers - let cryptoObj = window.crypto || window.msCrypto; + let win = external('win'); + let cryptoObj = win.crypto || win.msCrypto; let subtle = cryptoObj.subtle || cryptoObj.webkitSubtle; return subtle; } @@ -11565,7 +11697,7 @@ function _divProp(el, prop, val) { } } register$2('div', function (settings, opts) { - var n = document.createElement('div'); + var n = Dom.c('div').get(0); if (opts) { _divProp(n, 'className', opts.className); _divProp(n, 'id', opts.id); @@ -11723,7 +11855,7 @@ function _pagingDraw(settings, host, opts) { buttonEls.push(btn.display); } let wrapped = renderer(settings, 'pagingContainer')(settings, buttonEls); - let activeEl = host.find(document.activeElement).attr('data-dt-idx'); + let activeEl = host.find(external('doc').activeElement).attr('data-dt-idx'); host.empty().append(wrapped); if (activeEl) { host.find('[data-dt-idx="' + activeEl + '"]').trigger('focus'); @@ -12330,7 +12462,10 @@ const defaults = { stateDuration: 7200, stateLoadCallback: function (settings) { try { - const state = (settings.stateDuration === -1 ? sessionStorage : localStorage).getItem('DataTables_' + settings.unique + '_' + location.pathname); + const state = (settings.stateDuration === -1 ? sessionStorage : localStorage).getItem('DataTables_' + + settings.unique + + '_' + + external('win').location.pathname); return state ? JSON.parse(state) : {}; } catch (e) { @@ -12344,7 +12479,10 @@ const defaults = { try { (settings.stateDuration === -1 ? sessionStorage - : localStorage).setItem('DataTables_' + settings.unique + '_' + location.pathname, JSON.stringify(data)); + : localStorage).setItem('DataTables_' + + settings.unique + + '_' + + external('win').location.pathname, JSON.stringify(data)); } catch (e) { // noop @@ -12400,6 +12538,8 @@ const DataTable = function (selector, options) { if (init.on && init.on.options) { listener(table, 'options', init.on.options); } + // Don't have the settings object here, so can't use `callbackFire` + Dom.trigger('options.dt', [init]); table.trigger('options.dt', true, [init]); // Backwards compatibility parameter mapping compatOpts(defaults, true); @@ -12794,7 +12934,9 @@ DataTable.feature = { }; // Register the libraries util.external(DataTable); -if (window.jQuery) { +// If jQuery is present on the global, let DataTables know about it. This is +// required as it registers the plugin functions on the .fn object. +if (typeof window !== 'undefined' && window.jQuery) { util.external(window.jQuery); } From 42aa3ee1c77758086abe8cb16df7d9f222fe6885 Mon Sep 17 00:00:00 2001 From: Cassian433 Date: Tue, 6 Oct 2026 22:40:49 +0530 Subject: [PATCH 21/30] Close WebSocket connections that stop responding (#7807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both notification hubs send a Ping every 15 seconds but ignore the Pong, so the loop only ends on a Close frame, EOF or a socket error. When a client disappears without the TCP connection being closed, for example behind a reverse proxy or CDN that keeps the upstream connection open, none of these ever happens. The connection, its file descriptor and its map entry are then kept forever, until the process runs out of file descriptors and stops accepting connections and opening the database. Keep track of when something was last received from the client, Pongs included, and close the connection when nothing was received for 30 seconds. This is the default ClientTimeoutInterval of ASP.NET Core SignalR, which the official server uses. Fixes #7805 Co-authored-by: Cassian433 <262026629+Cassian433@users.noreply.github.com> Co-authored-by: Daniel García --- src/api/notifications.rs | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 8bfcd518..32204916 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -1,7 +1,7 @@ use std::{ net::IpAddr, sync::{Arc, LazyLock}, - time::Duration, + time::{Duration, Instant}, }; use chrono::{NaiveDateTime, Utc}; @@ -41,6 +41,14 @@ pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled()); pub fn routes() -> Vec { @@ -156,12 +164,16 @@ fn websockets_hub<'r>( rocket_ws::Stream! { ws => { let mut ws = ws; let _guard = guard; - let mut interval = tokio::time::interval(Duration::from_secs(15)); + let mut interval = tokio::time::interval(WS_PING_INTERVAL); + let mut last_received = Instant::now(); loop { tokio::select! { res = ws.next() => { match res { Some(Ok(message)) => { + // Any message, including a Pong, means the client is still there + last_received = Instant::now(); + match message { // Respond to any pings Message::Ping(ping) => yield Message::Pong(ping), @@ -195,7 +207,13 @@ fn websockets_hub<'r>( } } - _ = interval.tick() => yield Message::Ping(create_ping()) + _ = interval.tick() => { + // The client stopped responding without closing the connection, drop it + if last_received.elapsed() > WS_CLIENT_TIMEOUT { + break; + } + yield Message::Ping(create_ping()); + } } } }} @@ -229,12 +247,16 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R rocket_ws::Stream! { ws => { let mut ws = ws; let _guard = guard; - let mut interval = tokio::time::interval(Duration::from_secs(15)); + let mut interval = tokio::time::interval(WS_PING_INTERVAL); + let mut last_received = Instant::now(); loop { tokio::select! { res = ws.next() => { match res { Some(Ok(message)) => { + // Any message, including a Pong, means the client is still there + last_received = Instant::now(); + match message { // Respond to any pings Message::Ping(ping) => yield Message::Pong(ping), @@ -268,7 +290,13 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R } } - _ = interval.tick() => yield Message::Ping(create_ping()) + _ = interval.tick() => { + // The client stopped responding without closing the connection, drop it + if last_received.elapsed() > WS_CLIENT_TIMEOUT { + break; + } + yield Message::Ping(create_ping()); + } } } }} From 415df400f49d35a2999fa0292c8d244664f58b26 Mon Sep 17 00:00:00 2001 From: Iain Date: Tue, 6 Oct 2026 18:21:46 +0100 Subject: [PATCH 22/30] Validate the Duo and SSO purge schedules on startup (#7819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Daniel García --- src/config.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/config.rs b/src/config.rs index 7d35461c..bf6ea724 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1266,6 +1266,14 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression") } + if !cfg.duo_context_purge_schedule.is_empty() && cfg.duo_context_purge_schedule.parse::().is_err() { + err!("`DUO_CONTEXT_PURGE_SCHEDULE` is not a valid cron expression") + } + + if !cfg.purge_incomplete_sso_auth.is_empty() && cfg.purge_incomplete_sso_auth.parse::().is_err() { + err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression") + } + if !cfg.disable_admin_token { match cfg.admin_token.as_ref() { Some(t) if t.starts_with("$argon2") => { From de6d2066b32c913b27c63abc84b23542b2e5517e Mon Sep 17 00:00:00 2001 From: ump45nose <52391318+ump45nose@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:25:51 +0800 Subject: [PATCH 23/30] docs: fix "runnning" typo in playwright README (#7787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: yuwk <1729065730@qq.com> Co-authored-by: Daniel García --- playwright/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/README.md b/playwright/README.md index 000725d7..4b35e040 100644 --- a/playwright/README.md +++ b/playwright/README.md @@ -62,7 +62,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl ### Keep services running -If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests): +If you want you can keep the DB and Keycloak running (states are not impacted by the tests): ```bash PW_KEEP_SERVICE_RUNNING=true npx playwright test From 8b56926077aa3976403451d17b897ae00ce3957c Mon Sep 17 00:00:00 2001 From: Wu Shuwen Date: Wed, 7 Oct 2026 01:26:09 +0800 Subject: [PATCH 24/30] docs: fix Playwright test env link (#7739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Daniel García --- playwright/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/README.md b/playwright/README.md index 4b35e040..cd18bebd 100644 --- a/playwright/README.md +++ b/playwright/README.md @@ -2,7 +2,7 @@ This allows running integration tests using [Playwright](https://playwright.dev/). \ -It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance. +It uses its own [test.env](./test.env) with different ports to not collide with a running dev instance. ## Install From c687cacb6b96f4d4ca24f22c5ce12417373f1f5d Mon Sep 17 00:00:00 2001 From: Jogius Date: Tue, 6 Oct 2026 19:29:58 +0200 Subject: [PATCH 25/30] Fix group import to update groups on data change (#7671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Daniel García --- src/api/core/public.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/core/public.rs b/src/api/core/public.rs index fbb1f855..1ee80515 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -137,9 +137,13 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn if CONFIG.org_groups_enabled() { for group_data in &data.groups { - let group_uuid = if let Some(group) = + let group_uuid = if let Some(mut group) = Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await { + if group.name != group_data.name { + group.name = group_data.name.clone(); + group.save(&conn).await?; + } group.uuid } else { let mut group = From 8647af8f89a7d639efd79c339dabbc9215cf8dde Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:28:51 +0200 Subject: [PATCH 26/30] [Web 2026.9.0] Support new Emergency Access password payload (#7746) * Support new emergency access password payload * Apply PR 7747 review changes to emergency access * Add Playwright test for emergency access takeover --- playwright/tests/emergency.spec.ts | 131 ++++++++++++++++++ src/api/core/accounts.rs | 41 ++++-- src/api/core/emergency_access.rs | 34 ++++- src/db/models/user.rs | 4 + .../send_emergency_access_invite.html.hbs | 2 +- 5 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 playwright/tests/emergency.spec.ts diff --git a/playwright/tests/emergency.spec.ts b/playwright/tests/emergency.spec.ts new file mode 100644 index 00000000..4c836986 --- /dev/null +++ b/playwright/tests/emergency.spec.ts @@ -0,0 +1,131 @@ +import { test, expect, type Page, type TestInfo, Test } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from "../global-utils"; +import { createAccount, logUser } from './setups/user'; +import { activateTOTP } from './setups/2fa'; + +let users = utils.loadEnv(); +let mailserver; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + mailserver = new MailDev({ + port: process.env.MAILDEV_SMTP_PORT, + web: { port: process.env.MAILDEV_HTTP_PORT }, + }) + + await mailserver.listen(); + + await utils.startVault(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.PW_SMTP_FROM, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); + if( mailserver ){ + await mailserver.close(); + } +}); + +async function emergencyAccess(test: Test, page: Page, user: { name: string }) { + await test.step('Navigate', async () => { + await page.getByRole('button', { name: user.name }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByRole('link', { name: 'Emergency access' }).click(); + await expect(page.locator('#main-content').getByText('Emergency access', { exact: true })).toBeVisible(); + }); +} + +test('Emergency access', async ({ browser, page }) => { + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + + const mailBuffer = mailserver.buffer(users.user1.email); + const mailBuffer2 = mailserver.buffer(users.user2.email); + + await createAccount(test, page, users.user1); + await createAccount(test, page2, users.user2); + + await test.step('Add test2', async () => { + await emergencyAccess(test, page, users.user1); + await page.getByRole('button', { name: 'Add emergency contact' }).click(); + await page.getByRole('textbox', { name: 'Email * (required)' }).fill(users.user2.email); + await page.getByRole('radio', { name: 'Takeover Can reset your' }).check(); + await page.getByRole('button', { name: 'Save' }).click(); + await utils.checkNotification(page, 'User(s) invited'); + }); + + await test.step('Accept', async () => { + const email = await mailBuffer2.expect((m) => m.subject === "Emergency access for " + users.user1.name); + const pageE = await context2.newPage(); + await pageE.setContent(email.html); + const link = await pageE.getByTestId("emergency").getAttribute("href"); + await pageE.close(); + + await page2.goto(link); + await utils.checkNotification(page2, 'Invitation accepted'); + }); + + await test.step('Confirm', async () => { + await emergencyAccess(test, page, users.user1); + await expect(page.locator('#main-content').getByText('Needs confirmation')).toBeVisible(); + await page.getByRole('button', { name: 'Options' }).click(); + await page.getByRole('menuitem', { name: 'Confirm' }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + await utils.checkNotification(page, users.user2.name + ' confirmed'); + await mailBuffer2.expect((m) => m.subject === "Emergency access contact for " + users.user1.name + " confirmed"); + }); + + await test.step('Request', async () => { + await emergencyAccess(test, page2, users.user2); + await page2.getByRole('button', { name: 'Options' }).click(); + await page2.getByRole('menuitem', { name: 'Request Access' }).click(); + await page2.getByRole('button', { name: 'Request Access' }).click(); + await utils.checkNotification(page2, 'Emergency access requested'); + await mailBuffer.expect((m) => m.subject === "Emergency access request by " + users.user2.name + " initiated"); + }); + + await test.step('Approved', async () => { + await emergencyAccess(test, page, users.user1); + await page.getByRole('button', { name: 'Options' }).click(); + await page.getByRole('menuitem', { name: 'Approve' }).click(); + await page.getByRole('button', { name: 'Approve' }).click(); + await utils.checkNotification(page, 'Emergency access approved'); + await mailBuffer2.expect((m) => m.subject === "Emergency access request for " + users.user1.name + " approved"); + }); + await activateTOTP(test, page, users.user1); + + let newPassword = "TotoNewPassword"; + await test.step('Access', async () => { + await emergencyAccess(test, page2, users.user2); + await page2.getByRole('button', { name: 'Options' }).click(); + await page2.getByRole('menuitem', { name: 'Takeover' }).click(); + await page2.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword); + await page2.getByRole('textbox', { name: 'Confirm new master password' }).fill(newPassword); + await page2.getByRole('button', { name: 'Save' }).click(); + await utils.checkNotification(page2, 'Password reset for ' + users.user1.name); + }); + + await test.step('Changed no 2fa', async () => { + users.user1.password = newPassword; + await logUser(test, page, users.user1); + }); + + await test.step('Reject', async () => { + await emergencyAccess(test, page, users.user1); + await page.getByRole('button', { name: 'Options' }).click(); + await page.getByRole('menuitem', { name: 'Reject' }).click(); + await utils.checkNotification(page, 'Emergency access rejected'); + await mailBuffer2.expect((m) => m.subject === "Emergency access request to " + users.user1.name + " rejected"); + }); + + await test.step('Remove', async () => { + await page.getByRole('button', { name: 'Options' }).click(); + await page.getByRole('menuitem', { name: 'Remove' }).click(); + await page.getByRole('button', { name: 'Yes' }).click(); + await utils.checkNotification(page, 'Removed user ' + users.user2.name); + await expect(page.getByText('You have not added any emergency contacts')).toBeVisible(); + }); +}); diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index b3499a93..6f5c436b 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -93,6 +93,15 @@ pub struct KDFData { kdf_parallelism: Option, } +impl KDFData { + pub(super) fn matches_user(&self, user: &User) -> bool { + self.kdf == user.client_kdf_type + && self.kdf_iterations == user.client_kdf_iter + && self.kdf_memory == user.client_kdf_memory + && self.kdf_parallelism == user.client_kdf_parallelism + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RegisterData { @@ -717,21 +726,35 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct AuthenticationData { +pub(super) struct AuthenticationData { #[serde(alias = "Salt")] salt: String, #[serde(alias = "Kdf")] - kdf: KDFData, + pub(super) kdf: KDFData, #[serde(alias = "MasterPasswordAuthenticationHash")] - master_password_authentication_hash: String, + pub(super) master_password_authentication_hash: String, +} + +impl AuthenticationData { + pub(super) fn check(&self, user: &User, unlock: &UnlockData) -> EmptyResult { + if self.kdf != unlock.kdf { + err!("KDF settings must be equal for authentication and unlock") + } + + if self.salt != user.master_password_salt() || self.salt != unlock.salt { + err!("Invalid master password salt") + } + + Ok(()) + } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct UnlockData { +pub(super) struct UnlockData { salt: String, kdf: KDFData, - master_key_wrapped_user_key: String, + pub(super) master_key_wrapped_user_key: String, } #[derive(Deserialize)] @@ -750,13 +773,7 @@ async fn post_kdf(data: Json, headers: Headers, conn: DbConn, nt: err!("Invalid password") } - if data.authentication_data.kdf != data.unlock_data.kdf { - err!("KDF settings must be equal for authentication and unlock") - } - - if headers.user.email != data.authentication_data.salt || headers.user.email != data.unlock_data.salt { - err!("Invalid master password salt") - } + data.authentication_data.check(&headers.user, &data.unlock_data)?; let mut user = headers.user; diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 6afc683c..5813ee69 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -6,7 +6,10 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, - core::{CipherSyncData, CipherSyncType}, + core::{ + CipherSyncData, CipherSyncType, + accounts::{AuthenticationData, UnlockData}, + }, }, auth::{Headers, decode_emergency_access_invite}, db::{ @@ -615,6 +618,7 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, "kdfMemory": grantor_user.client_kdf_memory, "kdfParallelism": grantor_user.client_kdf_parallelism, "keyEncrypted": &emergency_access.key_encrypted, + "salt": grantor_user.master_password_salt(), "object": "emergencyAccessTakeover", }); @@ -624,8 +628,13 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct EmergencyAccessPasswordData { - new_master_password_hash: String, - key: String, + // Legacy payload + new_master_password_hash: Option, + key: Option, + + // Current payload + authentication_data: Option, + unlock_data: Option, } #[post("/emergency-access//password", data = "")] @@ -638,8 +647,6 @@ async fn password_emergency_access( check_emergency_access_enabled()?; let data: EmergencyAccessPasswordData = data.into_inner(); - let new_master_password_hash = &data.new_master_password_hash; - //let key = &data.Key; let requesting_user = headers.user; let Some(emergency_access) = @@ -656,8 +663,23 @@ async fn password_emergency_access( err!("Grantor user not found.") }; + let (new_master_password_hash, new_key) = + if let (Some(authentication_data), Some(unlock_data)) = (data.authentication_data, data.unlock_data) { + authentication_data.check(&grantor_user, &unlock_data)?; + + if !authentication_data.kdf.matches_user(&grantor_user) { + err!("KDF settings do not match the grantor account") + } + + (authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key) + } else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) { + (new_master_password_hash, new_key) + } else { + err!("Invalid request!") + }; + // change grantor_user password - grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?; + grantor_user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?; grantor_user.save(&conn).await?; // Disable TwoFactor providers since they will otherwise block logins diff --git a/src/db/models/user.rs b/src/db/models/user.rs index ead01373..6610c945 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -170,6 +170,10 @@ impl User { ) } + pub fn master_password_salt(&self) -> String { + self.email.trim().to_lowercase() + } + pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool { if let Some(ref totp_recover) = self.totp_recover { crypto::ct_eq(recovery_code, totp_recover.to_lowercase()) diff --git a/src/static/templates/email/send_emergency_access_invite.html.hbs b/src/static/templates/email/send_emergency_access_invite.html.hbs index 5318378c..b5ab3a07 100644 --- a/src/static/templates/email/send_emergency_access_invite.html.hbs +++ b/src/static/templates/email/send_emergency_access_invite.html.hbs @@ -9,7 +9,7 @@ Emergency access for {{{grantor_name}}} - Become emergency contact From b8089e31c2f35958021a32414411aecaddf3b24b Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:45:12 +0200 Subject: [PATCH 27/30] [Web 2026.9.0] Support new account recovery password payload (#7747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Support new account recovery password payload * Address account recovery review feedback * Check the account recovery password before sending the recovery email --------- Co-authored-by: Daniel García --- src/api/core/organizations.rs | 41 ++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 6464f774..e0124ef1 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -26,6 +26,8 @@ use crate::{ util::{NumberOrString, convert_json_key_lcase_first}, }; +use super::accounts::{AuthenticationData, UnlockData}; + pub fn routes() -> Vec { routes![ get_organization, @@ -2751,9 +2753,14 @@ struct OrganizationUserResetPasswordEnrollmentRequest { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrganizationUserRecoverAccountRequest { + // Legacy payload new_master_password_hash: Option, key: Option, + // Current payload + authentication_data: Option, + unlock_data: Option, + #[serde(default)] reset_master_password: bool, #[serde(default)] @@ -2838,6 +2845,29 @@ async fn recover_account( false }; + // Check the new password before the email below, so that a rejected request doesn't tell the user + // their password was reset + let new_password = if req.reset_master_password { + let (new_master_password_hash, new_key) = if let (Some(authentication_data), Some(unlock_data)) = + (req.authentication_data, req.unlock_data) + { + authentication_data.check(&user, &unlock_data)?; + + if !authentication_data.kdf.matches_user(&user) { + err!("KDF settings do not match the user account") + } + + (authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key) + } else if let (Some(new_master_password_hash), Some(new_key)) = (req.new_master_password_hash, req.key) { + (new_master_password_hash, new_key) + } else { + err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code); + }; + Some((new_master_password_hash, new_key)) + } else { + None + }; + // Sending email first ensure working email configuration and the resulting user notification. // Also this might add some protection against security flaws and misuse if let Err(e) = mail::send_admin_account_recovery( @@ -2853,14 +2883,8 @@ async fn recover_account( err!(format!("Error sending user reset password email: {e:#?}")); } - if req.reset_master_password { - if let Some(key) = req.key - && let Some(hash) = req.new_master_password_hash - { - user.set_password(hash.as_str(), Some(key), true, None, &conn).await?; - } else { - err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code); - } + if let Some((new_master_password_hash, new_key)) = new_password { + user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?; } if req.reset_two_factor { @@ -2919,6 +2943,7 @@ async fn get_reset_password_details( "kdfIterations": user.client_kdf_iter, "kdfMemory": user.client_kdf_memory, "kdfParallelism": user.client_kdf_parallelism, + "masterPasswordSalt": user.master_password_salt(), "resetPasswordKey": member.reset_password_key, "encryptedPrivateKey": org.private_key, }))) From acbf49018f616e52e9310789cf861f6e3923c8be Mon Sep 17 00:00:00 2001 From: Timshel Date: Tue, 6 Oct 2026 21:45:55 +0000 Subject: [PATCH 28/30] [web-v2026.7.1] 2FA userVerificationToken for Authenticator, Email and Duo (#7563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 2FA using userVerificationToken * Fix 2FA Yubikeys * TwoFactor.find_by_user_and_type take enum parameter not i32 * log_user_event take enum parameter not i32 * Fix 2fa webauthn * Prevent 2FA claims reuse * 2fa webauthn return keys when deleting * 2fa reset twofactor_remember * Reject 2FA verification tokens issued for another provider The Email and Duo claims only have optional fields, so a userVerificationToken minted for another provider deserialized into them and was accepted. Each provider's claims now reject unknown fields, with a test that every token only parses as its own provider. Also fix the email 2FA login in the Playwright user setup, which used an undefined `mailBuffer`. --------- Co-authored-by: Timshel Co-authored-by: Daniel García --- playwright/tests/login.spec.ts | 37 ++- playwright/tests/setups/2fa.ts | 19 +- playwright/tests/setups/user.ts | 18 +- src/api/core/accounts.rs | 12 +- src/api/core/events.rs | 4 +- src/api/core/organizations.rs | 2 +- src/api/core/two_factor/authenticator.rs | 68 ++--- src/api/core/two_factor/duo.rs | 125 ++++----- src/api/core/two_factor/email.rs | 102 +++---- src/api/core/two_factor/mod.rs | 59 +--- src/api/core/two_factor/protected_actions.rs | 5 +- src/api/core/two_factor/webauthn.rs | 183 +++++++----- src/api/core/two_factor/yubikey.rs | 98 ++++--- src/api/identity.rs | 11 +- src/api/mod.rs | 2 +- src/auth.rs | 3 + src/auth/two_factor.rs | 281 +++++++++++++++++++ src/db/models/two_factor.rs | 4 +- 18 files changed, 667 insertions(+), 366 deletions(-) create mode 100644 src/auth/two_factor.rs diff --git a/playwright/tests/login.spec.ts b/playwright/tests/login.spec.ts index 194976ea..bc229c43 100644 --- a/playwright/tests/login.spec.ts +++ b/playwright/tests/login.spec.ts @@ -3,7 +3,7 @@ import * as OTPAuth from "otpauth"; import * as utils from "../global-utils"; import { createAccount, logUser } from './setups/user'; -import { activateTOTP, disableTOTP } from './setups/2fa'; +import { activateTOTP, disableTOTP, recoveryCodes } from './setups/2fa'; let users = utils.loadEnv(); let totp; @@ -31,21 +31,42 @@ test('Authenticator 2fa', async ({ page }) => { await utils.logout(test, page, users.user1); - await test.step('login', async () => { - let timestamp = Date.now(); // Needed to use the next token - timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; + await logUser(test, page, users.user1, { totp }); + + await disableTOTP(test, page, users.user1); +}); + +test('Recovery codes', async ({ context, page }) => { + await logUser(test, page, users.user1); + + await activateTOTP(test, page, users.user1); + let recovery = await recoveryCodes(test, page, users.user1); + + await utils.logout(test, page, users.user1); + await test.step('login', async () => { await page.getByLabel(/Email address/).fill(users.user1.email); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password); await page.getByRole('button', { name: 'Log in', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - await page.getByLabel(/Verification code/).fill(totp.generate({timestamp})); - await page.getByRole('button', { name: 'Continue' }).click(); await expect(page).toHaveTitle(/Vaultwarden Web/); - }); - await disableTOTP(test, page, users.user1); + const newPagePromise = context.waitForEvent('page'); + await page.getByRole('button', { name: 'Use your recovery code' }).click(); + const newPage = await newPagePromise; + + const tabs = context.pages(); + await tabs[1].bringToFront(); + await expect(tabs[1].getByRole('heading', { name: 'Recover account two-step login' })).toBeVisible(); + await tabs[1].getByRole('textbox', { name: 'Email address * (required)' }).fill(users.user1.email); + await tabs[1].getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password); + await tabs[1].getByRole('textbox', { name: 'Recovery code * (required)' }).fill(recovery); + await tabs[1].getByRole('button', { name: 'Submit' }).click(); + + await expect(tabs[1]).toHaveTitle(/Two-step login/); + }); }); + diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts index d430d053..0b1711a1 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -4,6 +4,24 @@ import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; +export async function recoveryCodes(test: Test, page: Page, user: { name: string, password: string }): string { + return await test.step('Recovery code', async () => { + await page.getByRole('button', { name: user.name }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByRole('link', { name: 'Security' }).click(); + await page.getByRole('link', { name: 'Two-step login' }).click(); + + await page.getByRole('button', { name: 'View recovery code' }).click(); + await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); + await page.getByRole('button', { name: 'Continue' }).click(); + + const recovery = await page.getByRole('code').innerText(); + await page.getByLabel('Close').click(); + + return recovery; + }) +} + export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP { return await test.step('Activate TOTP 2FA', async () => { await page.getByRole('button', { name: user.name }).click(); @@ -21,7 +39,6 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string, await page.getByLabel(/Verification code/).fill(totp.generate()); await page.getByRole('button', { name: 'Turn on' }).click(); await page.getByRole('heading', { name: 'Turned on', exact: true }); - await page.getByLabel('Close').click(); return totp; }) diff --git a/playwright/tests/setups/user.ts b/playwright/tests/setups/user.ts index 3d3990e9..11b39763 100644 --- a/playwright/tests/setups/user.ts +++ b/playwright/tests/setups/user.ts @@ -2,6 +2,7 @@ import { expect, type Browser, Page } from '@playwright/test'; import { type MailBuffer } from 'maildev'; +import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; import { retrieveEmailCode } from './2fa'; @@ -43,6 +44,7 @@ export async function logUser( mailBuffer ?: MailBuffer, mail2fa?: boolean, notNewDevice?: boolean, + totp?: OTPAuth.TOTP, } = {} ) { await test.step(`Log user ${user.email}`, async () => { @@ -55,11 +57,23 @@ export async function logUser( await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); await page.getByRole('button', { name: 'Log in', exact: true }).click(); - if( options.mail2fa ){ + if( options.mail2fa || options.totp ){ + let code; + await test.step('2FA check', async () => { await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - let code = await retrieveEmailCode(test, page, options.mailBuffer); + + if( options.totp ) { + const totp = options.totp; + let timestamp = Date.now(); // Needed to use the next token + timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; + code = totp.generate({timestamp}); + } else if( options.mail2fa ){ + code = await retrieveEmailCode(test, page, options.mailBuffer); + } + await page.getByLabel(/Verification code/).fill(code); + await page.getByRole('button', { name: 'Continue' }).click(); }); } diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 6f5c436b..dd2e5801 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -493,8 +493,7 @@ async fn post_set_password(data: Json, headers: Headers, conn: Membership::accept_user_invitations(&user.uuid, &conn).await?; } - log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; + log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; user.save(&conn).await?; @@ -640,8 +639,7 @@ async fn post_password(data: Json, headers: Headers, conn: DbCon err!("Invalid password") } - log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; + log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; let (new_master_password_hash, new_key) = if let (Some(unlock_data), Some(authentication_data)) = (data.unlock_data, data.authentication_data) { @@ -1696,7 +1694,7 @@ async fn post_auth_request( nt.send_auth_request(&user.uuid, &auth_request.uuid, &device, &conn).await; log_user_event( - EventType::UserRequestedDeviceApproval as i32, + EventType::UserRequestedDeviceApproval, &user.uuid, client_headers.device_type, &client_headers.ip.ip, @@ -1790,7 +1788,7 @@ async fn put_auth_request( nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &headers.device, &conn).await; log_user_event( - EventType::OrganizationUserApprovedAuthRequest as i32, + EventType::OrganizationUserApprovedAuthRequest, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -1801,7 +1799,7 @@ async fn put_auth_request( // If denied, there's no reason to keep the request auth_request.delete(&conn).await?; log_user_event( - EventType::OrganizationUserRejectedAuthRequest as i32, + EventType::OrganizationUserRejectedAuthRequest, &headers.user.uuid, headers.device.atype, &headers.ip.ip, diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 80906e40..15ec11a0 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -242,11 +242,11 @@ async fn post_events_collect(data: Json>, headers: Headers, Ok(()) } -pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { +pub async fn log_user_event(event_type: EventType, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { if !CONFIG.org_events_enabled() { return; } - log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await; + log_user_event_impl(event_type as i32, user_id, device_type, None, ip, conn).await; } async fn log_user_event_impl( diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index e0124ef1..6fccc850 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -2840,7 +2840,7 @@ async fn recover_account( let fallback_2fa_email = if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() { - TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none() + TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none() } else { false }; diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index 99b5c67c..55984443 100644 --- a/src/api/core/two_factor/authenticator.rs +++ b/src/api/core/two_factor/authenticator.rs @@ -3,11 +3,11 @@ use rocket::{Route, serde::json::Json}; use crate::{ api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code}, - auth::{ClientIp, Headers}, + auth::{ClientIp, Headers, two_factor}, crypto, db::{ DbConn, - models::{Device, EventType, TwoFactor, TwoFactorType, UserId}, + models::{EventType, TwoFactor, TwoFactorType, UserId}, }, util::NumberOrString, }; @@ -20,27 +20,23 @@ pub fn routes() -> Vec { #[post("/two-factor/get-authenticator", data = "")] async fn generate_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, false, &conn).await?; - let type_ = TwoFactorType::Authenticator as i32; - let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await; + let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await; let (enabled, key) = match twofactor { Some(tf) => (true, tf.data), _ => (false, crypto::encode_random_bytes::<20>(&BASE32)), }; - // Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all. - // It should help prevent TOTP disclosure if someone keeps their vault unlocked. - // Since it doesn't seem to be used, and also does not cause any issues, lets leave it out of the response. - // See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Controllers/TwoFactorController.cs#L94 Ok(Json(json!({ - "enabled": enabled, - "key": key, - "object": "twoFactorAuthenticator" + "authenticator": json!({ + "enabled": enabled, + "key": key, + }), + "userVerificationToken": two_factor::authenticator_token(user.uuid, key, enabled), }))) } @@ -49,8 +45,7 @@ async fn generate_authenticator(data: Json, headers: Headers, struct EnableAuthenticatorData { key: String, token: NumberOrString, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } #[post("/two-factor/authenticator", data = "")] @@ -61,12 +56,7 @@ async fn activate_authenticator(data: Json, headers: He let mut user = headers.user; - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, - } - .validate(&user, true, &conn) - .await?; + two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &key, false)?; // Validate key as base32 and 20 bytes length let decoded_key: Vec = if let Ok(decoded) = BASE32.decode(key.as_bytes()) { @@ -84,12 +74,13 @@ async fn activate_authenticator(data: Json, headers: He generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ - "enabled": true, - "key": key, - "object": "twoFactorAuthenticator" + "authenticator": json!({ + "enabled": true, + "key": key, + }), }))) } @@ -125,8 +116,7 @@ pub async fn validate_totp_code( err!("Invalid TOTP secret") }; - let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await - { + let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator, conn).await { Some(tf) => tf, _ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()), }; @@ -184,37 +174,25 @@ pub async fn validate_totp_code( #[serde(rename_all = "camelCase")] struct DisableAuthenticatorData { key: String, - master_password_hash: String, - r#type: NumberOrString, + user_verification_token: String, } #[delete("/two-factor/authenticator", data = "")] -async fn disable_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn disable_authenticator(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let user = headers.user; - let type_ = data.r#type.into_i32()?; - if !user.check_valid_password(&data.master_password_hash) { - err!("Invalid password"); - } + two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &data.key, true)?; - if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await { if twofactor.data == data.key { twofactor.delete(&conn).await?; - Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?; - log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; } else { err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email)); } } - if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { - super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; - } + super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?; - Ok(Json(json!({ - "enabled": false, - "keys": type_, - "object": "twoFactorProvider" - }))) + Ok(()) } diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index 860979ff..23116b2c 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -2,12 +2,12 @@ use chrono::Utc; use rocket::{Route, serde::json::Json}; use crate::{ - CONFIG, api::{ - ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, - core::two_factor::generate_recover_code, + ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, + core::log_user_event, + core::two_factor::{VerificationTokenData, generate_recover_code}, }, - auth::Headers, + auth::{Headers, two_factor, two_factor::DuoData}, crypto, db::{ DbConn, @@ -18,55 +18,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![get_duo, activate_duo, activate_duo_put,] -} - -#[derive(Serialize, Deserialize)] -struct DuoData { - host: String, // Duo API hostname - ik: String, // client id - sk: String, // client secret -} - -impl DuoData { - fn global() -> Option { - match (CONFIG._enable_duo(), CONFIG.duo_host()) { - (true, Some(host)) => Some(Self { - host, - ik: CONFIG.duo_ikey().unwrap(), - sk: CONFIG.duo_skey().unwrap(), - }), - _ => None, - } - } - fn msg(s: &str) -> Self { - Self { - host: s.into(), - ik: s.into(), - sk: s.into(), - } - } - fn secret() -> Self { - Self::msg("") - } - fn obscure(self) -> Self { - let mut host = self.host; - let mut ik = self.ik; - let mut sk = self.sk; - - let digits = 4; - let replaced = "************"; - - host.replace_range(digits.., replaced); - ik.replace_range(digits.., replaced); - sk.replace_range(digits.., replaced); - - Self { - host, - ik, - sk, - } - } + routes![get_duo, activate_duo, activate_duo_put, disable_duo,] } enum DuoStatus { @@ -95,22 +47,19 @@ async fn get_duo(data: Json, headers: Headers, conn: DbConn) data.validate(&user, false, &conn).await?; - let data = get_user_duo_data(&user.uuid, &conn).await; - - let (enabled, data) = match data { + let (enabled, duo) = match get_user_duo_data(&user.uuid, &conn).await { DuoStatus::Global(_) => (true, Some(DuoData::secret())), DuoStatus::User(data) => (true, Some(data.obscure())), DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))), DuoStatus::Disabled(false) => (false, None), }; - let json = if let Some(data) = data { + let duo_json = if let Some(data) = duo.as_ref() { json!({ "enabled": enabled, "host": data.host, "clientSecret": data.sk, "clientId": data.ik, - "object": "twoFactorDuo" }) } else { json!({ @@ -118,11 +67,13 @@ async fn get_duo(data: Json, headers: Headers, conn: DbConn) "host": null, "clientSecret": null, "clientId": null, - "object": "twoFactorDuo" }) }; - Ok(Json(json)) + Ok(Json(rocket::serde::json::json!({ + "duo": duo_json, + "userVerificationToken": two_factor::duo_token(user.uuid, duo, enabled), + }))) } #[derive(Deserialize)] @@ -131,8 +82,7 @@ struct EnableDuoData { host: String, client_secret: String, client_id: String, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } impl From for DuoData { @@ -159,12 +109,7 @@ async fn activate_duo(data: Json, headers: Headers, conn: DbConn) let data: EnableDuoData = data.into_inner(); let mut user = headers.user; - PasswordOrOtpData { - master_password_hash: data.master_password_hash.clone(), - otp: data.otp.clone(), - } - .validate(&user, true, &conn) - .await?; + two_factor::validate_duo(&data.user_verification_token, &user.uuid, None, false)?; let (data, data_str) = if check_duo_fields_custom(&data) { let data_req: DuoData = data.into(); @@ -181,14 +126,15 @@ async fn activate_duo(data: Json, headers: Headers, conn: DbConn) generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ - "enabled": true, - "host": data.host, - "clientSecret": data.sk, - "clientId": data.ik, - "object": "twoFactorDuo" + "duo": json!({ + "enabled": true, + "host": data.host, + "clientSecret": data.sk, + "clientId": data.ik, + }), }))) } @@ -197,6 +143,29 @@ async fn activate_duo_put(data: Json, headers: Headers, conn: DbC activate_duo(data, headers, conn).await } +#[delete("/two-factor/duo", data = "")] +async fn disable_duo(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let user = headers.user; + + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Duo, &conn).await { + // Apply the same transformation than in `get_duo` to check we are disabling the correct one + let duo = match to_user_duo_data(&twofactor) { + DuoStatus::Global(_) => Some(DuoData::secret()), + DuoStatus::User(data) => Some(data.obscure()), + DuoStatus::Disabled(_) => None, + }; + + two_factor::validate_duo(&data.user_verification_token, &user.uuid, duo.as_ref(), true)?; + + twofactor.delete(&conn).await?; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + } + + super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?; + + Ok(()) +} + async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult { use reqwest::{Method, header}; use std::str::FromStr; @@ -222,13 +191,15 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) } async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus { - let type_ = TwoFactorType::Duo as i32; - // If the user doesn't have an entry, disabled - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Duo, conn).await else { return DuoStatus::Disabled(DuoData::global().is_some()); }; + to_user_duo_data(&twofactor) +} + +fn to_user_duo_data(twofactor: &TwoFactor) -> DuoStatus { // If the user has the required values, we use those if let Ok(data) = serde_json::from_str(&twofactor.data) { return DuoStatus::User(data); diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 836d1c47..2b0ee785 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -5,9 +5,12 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, PasswordOrOtpData, - core::{log_user_event, two_factor::generate_recover_code}, + core::{ + log_user_event, + two_factor::{VerificationTokenData, generate_recover_code}, + }, }, - auth::{ClientHeaders, Headers}, + auth::{ClientHeaders, Headers, two_factor}, crypto, db::{ DbConn, @@ -18,7 +21,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![get_email, send_email_login, send_email, email,] + routes![get_email, send_email_login, send_email, email, disable_email] } #[derive(Deserialize)] @@ -116,8 +119,8 @@ async fn send_email_login(data: Json, client_headers: Client /// Generate the token, save the data for later verification and send email to user pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult { - let type_ = TwoFactorType::Email as i32; - let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?; + let mut twofactor = + TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?; let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); @@ -140,18 +143,19 @@ async fn get_email(data: Json, headers: Headers, conn: DbConn data.validate(&user, false, &conn).await?; let (enabled, mfa_email) = - match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await { - Some(x) => { - let twofactor_data = EmailTokenData::from_json(&x.data)?; - (true, json!(twofactor_data.email)) - } - _ => (false, serde_json::value::Value::Null), + if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await { + let twofactor_data = EmailTokenData::from_json(&x.data)?; + (true, Some(twofactor_data.email)) + } else { + (false, None) }; - Ok(Json(json!({ - "email": mfa_email, - "enabled": enabled, - "object": "twoFactorEmail" + Ok(Json(rocket::serde::json::json!({ + "email": rocket::serde::json::json!({ + "enabled": enabled, + "email": mfa_email, + }), + "userVerificationToken": two_factor::email_token(user.uuid, mfa_email, enabled), }))) } @@ -160,30 +164,22 @@ async fn get_email(data: Json, headers: Headers, conn: DbConn struct SendEmailData { /// Email where 2FA codes will be sent to, can be different than user email account. email: String, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } /// Send a verification email to the specified email address to check whether it exists/belongs to user. #[post("/two-factor/send-email", data = "")] -async fn send_email(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +async fn send_email(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: SendEmailData = data.into_inner(); let user = headers.user; - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, - } - .validate(&user, false, &conn) - .await?; + two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email.clone(), false)?; if !CONFIG._enable_email_2fa() { err!("Email 2FA is disabled") } - let type_ = TwoFactorType::Email as i32; - - if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await { tf.delete(&conn).await?; } @@ -191,12 +187,13 @@ async fn send_email(data: Json, headers: Headers, conn: DbConn) - let twofactor_data = EmailTokenData::new(data.email, Some(generated_token)); // Uses EmailVerificationChallenge as type to show that it's not verified yet. - let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json()); + let twofactor = + TwoFactor::new(user.uuid.clone(), TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json()); twofactor.save(&conn).await?; mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?; - Ok(()) + Ok(Json(json!({}))) } #[derive(Deserialize, Serialize)] @@ -204,8 +201,7 @@ async fn send_email(data: Json, headers: Headers, conn: DbConn) - struct EmailData { email: String, token: String, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } /// Verify email belongs to user and can be used for 2FA email codes. @@ -214,17 +210,11 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes let data: EmailData = data.into_inner(); let mut user = headers.user; - // This is the last step in the verification process, delete the otp directly afterwards - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, - } - .validate(&user, true, &conn) - .await?; + two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email, false)?; - let type_ = TwoFactorType::EmailVerificationChallenge as i32; - let mut twofactor = - TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await.map_res("Two factor not found")?; + let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge, &conn) + .await + .map_res("Two factor not found")?; let mut email_data = EmailTokenData::from_json(&twofactor.data)?; @@ -243,13 +233,26 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; - Ok(Json(json!({ - "email": email_data.email, - "enabled": "true", - "object": "twoFactorEmail" - }))) + Ok(Json(json!({}))) +} + +#[delete("/two-factor/email", data = "")] +async fn disable_email(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let user = headers.user; + + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await { + let twofactor_data = EmailTokenData::from_json(&twofactor.data)?; + two_factor::validate_email(&data.user_verification_token, &user.uuid, twofactor_data.email, true)?; + + twofactor.delete(&conn).await?; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + } + + super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?; + + Ok(()) } /// Validate the email code when used as TwoFactor token mechanism @@ -261,9 +264,8 @@ pub async fn validate_email_code_str( conn: &DbConn, ) -> EmptyResult { let mut email_data = EmailTokenData::from_json(data)?; - let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn) - .await - .map_res("Two factor not found")?; + let mut twofactor = + TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?; let Some(issued_token) = &email_data.last_token else { err!( format!("No token available! IP: {ip}"), diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 14bdafd5..61cb05df 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -7,10 +7,7 @@ use serde_json::Value; use crate::{ CONFIG, - api::{ - EmptyResult, JsonResult, PasswordOrOtpData, - core::{log_event, log_user_event}, - }, + api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_event}, auth::Headers, crypto, db::{ @@ -21,7 +18,6 @@ use crate::{ }, }, mail, - util::NumberOrString, }; pub mod authenticator; @@ -69,7 +65,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data } pub fn routes() -> Vec { - let mut routes = routes![get_twofactor, get_recover, disable_twofactor, get_device_verification_settings]; + let mut routes = routes![get_twofactor, get_recover, get_device_verification_settings]; routes.append(&mut authenticator::routes()); routes.append(&mut duo::routes()); @@ -81,6 +77,12 @@ pub fn routes() -> Vec { routes } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct VerificationTokenData { + user_verification_token: String, +} + #[get("/two-factor")] async fn get_twofactor(headers: Headers, conn: DbConn) -> Json { let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await; @@ -120,45 +122,14 @@ async fn generate_recover_code(user: &mut User, conn: &DbConn) { } } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct DisableTwoFactorData { - master_password_hash: Option, - otp: Option, - r#type: NumberOrString, -} - -#[put("/two-factor/disable", data = "")] -async fn disable_twofactor(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data: DisableTwoFactorData = data.into_inner(); - let user = headers.user; - - // Delete directly after a valid token has been provided - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, +/// Call after a 2FA provider, or one of its keys, was removed. No remembered device may keep skipping +/// the providers that are left, and once every provider is gone the 2FA policy applies. +pub async fn check_2fa_state(user: &User, device_type: i32, ip: &std::net::IpAddr, conn: &DbConn) -> EmptyResult { + Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?; + if TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() { + enforce_2fa_policy(user, &user.uuid, device_type, ip, conn).await?; } - .validate(&user, true, &conn) - .await?; - - let type_ = data.r#type.into_i32()?; - - if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { - twofactor.delete(&conn).await?; - Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?; - log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; - } - - if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { - enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; - } - - Ok(Json(json!({ - "enabled": false, - "type": type_, - "object": "twoFactorProvider" - }))) + Ok(()) } pub async fn enforce_2fa_policy( diff --git a/src/api/core/two_factor/protected_actions.rs b/src/api/core/two_factor/protected_actions.rs index c0c1b5e8..1cafede0 100644 --- a/src/api/core/two_factor/protected_actions.rs +++ b/src/api/core/two_factor/protected_actions.rs @@ -72,8 +72,7 @@ async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult { let user = headers.user; // Only one Protected Action per user is allowed to take place, delete the previous one - if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &conn).await - { + if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions, &conn).await { let pa_data = ProtectedActionData::from_json(&pa.data)?; let elapsed = pa_data.time_since_sent().num_seconds(); let delay = 30; @@ -125,7 +124,7 @@ pub async fn validate_protected_action_otp( delete_if_valid: bool, conn: &DbConn, ) -> EmptyResult { - let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn) + let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions, conn) .await .map_res("Protected action token not found, try sending the code again or restart the process")?; let mut pa_data = ProtectedActionData::from_json(&pa.data)?; diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index a3a9b21b..7eab5546 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -1,4 +1,4 @@ -use std::{str::FromStr, sync::LazyLock, time::Duration}; +use std::{collections::HashSet, str::FromStr, sync::LazyLock, time::Duration}; use rocket::{Route, serde::json::Json}; use serde_json::Value; @@ -18,16 +18,18 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, PasswordOrOtpData, - core::{log_user_event, two_factor::generate_recover_code}, + core::{ + log_user_event, + two_factor::{VerificationTokenData, generate_recover_code}, + }, }, - auth::Headers, + auth::{Headers, two_factor}, crypto::ct_eq, db::{ DbConn, - models::{Device, EventType, TwoFactor, TwoFactorType, UserId}, + models::{EventType, TwoFactor, TwoFactorType, UserId}, }, error::Error, - util::NumberOrString, }; static WEBAUTHN: LazyLock = LazyLock::new(|| { @@ -45,7 +47,14 @@ static WEBAUTHN: LazyLock = LazyLock::new(|| { }); pub fn routes() -> Vec { - routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,] + routes![ + get_webauthn, + generate_webauthn_challenge, + activate_webauthn, + activate_webauthn_put, + delete_webauthn, + delete_webauthns + ] } // Some old u2f structs still needed for migrating from u2f to WebAuthn @@ -119,34 +128,36 @@ async fn get_webauthn(data: Json, headers: Headers, conn: DbC data.validate(&user, false, &conn).await?; let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?; + let keys: Vec = registrations.iter().map(|r| r.id).collect(); let registrations_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ - "enabled": enabled, - "keys": registrations_json, - "object": "twoFactorWebAuthn" + "webAuthn": json!({ + "enabled": enabled, + "keys": registrations_json, + }), + "userVerificationToken": two_factor::webauthn_token(user.uuid, keys, enabled), }))) } #[post("/two-factor/get-webauthn-challenge", data = "")] -async fn generate_webauthn_challenge(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data: PasswordOrOtpData = data.into_inner(); +async fn generate_webauthn_challenge(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let user = headers.user; - data.validate(&user, false, &conn).await?; - - let registrations = get_webauthn_registrations(&user.uuid, &conn) - .await? - .1 + let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?; + let keys: Vec = registrations.iter().map(|r| r.id).collect(); + let creds = registrations .into_iter() .map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering .collect(); + two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, enabled)?; + let (mut challenge, state) = WEBAUTHN.start_passkey_registration( Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail &user.email, user.display_name(), - Some(registrations), + Some(creds), )?; let mut state = serde_json::to_value(&state)?; @@ -166,17 +177,19 @@ async fn generate_webauthn_challenge(data: Json, headers: Hea let mut challenge_value = serde_json::to_value(challenge.public_key)?; challenge_value["status"] = "ok".into(); challenge_value["errorMessage"] = "".into(); - Ok(Json(challenge_value)) + + Ok(Json(json!({ + "options": challenge_value + }))) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EnableWebauthnData { - id: NumberOrString, // 1..5 + id: i32, name: String, device_response: RegisterPublicKeyCredentialCopy, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } #[derive(Debug, Deserialize)] @@ -257,16 +270,14 @@ async fn activate_webauthn(data: Json, headers: Headers, con let data: EnableWebauthnData = data.into_inner(); let mut user = headers.user; - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, - } - .validate(&user, true, &conn) - .await?; + let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1; + let keys: Vec = registrations.iter().map(|r| r.id).collect(); + two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, !keys.is_empty())?; // Retrieve and delete the saved challenge state - let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; - let state = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + let state = if let Some(tf) = + TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnRegisterChallenge, &conn).await + { let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; tf.delete(&conn).await?; state @@ -277,10 +288,9 @@ async fn activate_webauthn(data: Json, headers: Headers, con // Verify the credentials with the saved state let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; - let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1; // TODO: Check for repeated ID's registrations.push(WebauthnRegistration { - id: data.id.into_i32()?, + id: data.id, name: data.name, migrated: false, @@ -293,13 +303,13 @@ async fn activate_webauthn(data: Json, headers: Headers, con .await?; generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; - let keys_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ - "enabled": true, - "keys": keys_json, - "object": "twoFactorU2f" + "webAuthn": json!({ + "enabled": true, + "keys": registrations.iter().map(WebauthnRegistration::to_json).collect::>(), + }), }))) } @@ -310,58 +320,87 @@ async fn activate_webauthn_put(data: Json, headers: Headers, #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct DeleteU2FData { - id: NumberOrString, - master_password_hash: String, +struct DeleteWebauthnData { + id: i32, + user_verification_token: String, } #[delete("/two-factor/webauthn", data = "")] -async fn delete_webauthn(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let id = data.id.into_i32()?; - if !headers.user.check_valid_password(&data.master_password_hash) { - err!("Invalid password"); - } +async fn delete_webauthn(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + inner_delete_webauthns(&data.user_verification_token, |key| key.id != data.id, headers, &conn).await +} + +#[delete("/two-factor/webauthn/all", data = "")] +async fn delete_webauthns(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + inner_delete_webauthns(&data.user_verification_token, |_| false, headers, &conn).await +} + +async fn inner_delete_webauthns( + token: &str, + retain: impl Fn(&WebauthnRegistration) -> bool, + headers: Headers, + conn: &DbConn, +) -> JsonResult { + let user = headers.user; - let Some(mut tf) = - TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await - else { + let Some(mut tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn, conn).await else { err!("Webauthn data not found!") }; - let mut data: Vec = serde_json::from_str(&tf.data)?; + let mut keys: Vec = serde_json::from_str(&tf.data)?; + let keys_id: Vec = keys.iter().map(|r| r.id).collect(); + + two_factor::validate_webauthn(token, &user.uuid, &keys_id, true)?; - let Some(item_pos) = data.iter().position(|r| r.id == id) else { + let mut removed: HashSet> = HashSet::new(); + let mut migrated = false; + + keys.retain(|key| { + let retained = retain(key); + if !retained { + removed.insert(key.credential.cred_id().to_vec()); + migrated = migrated || key.migrated; + } + retained + }); + + if removed.is_empty() { err!("Webauthn entry not found") - }; + } - let removed_item = data.remove(item_pos); - tf.data = serde_json::to_string(&data)?; - tf.save(&conn).await?; - drop(tf); - Device::clear_twofactor_remember_by_user(&headers.user.uuid, &conn).await?; + if keys.is_empty() { + tf.delete(conn).await?; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, conn).await; + } else { + tf.data = serde_json::to_string(&keys)?; + tf.save(conn).await?; + drop(tf); + } // If entry is migrated from u2f, delete the u2f entry as well - if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await - { - let mut data: Vec = if let Ok(d) = serde_json::from_str(&u2f.data) { - d - } else { + if migrated && let Some(mut u2f) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::U2f, conn).await { + let Ok(mut data) = serde_json::from_str::>(&u2f.data) else { err!("Error parsing U2F data") }; - data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); - let new_data_str = serde_json::to_string(&data)?; + data.retain(|old| !removed.contains(&old.reg.key_handle)); - u2f.data = new_data_str; - u2f.save(&conn).await?; + if data.is_empty() { + u2f.delete(conn).await?; + } else { + let new_data_str = serde_json::to_string(&data)?; + u2f.data = new_data_str; + u2f.save(conn).await?; + } } - let keys_json: Vec = data.iter().map(WebauthnRegistration::to_json).collect(); + super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, conn).await?; Ok(Json(json!({ - "enabled": true, - "keys": keys_json, - "object": "twoFactorU2f" + "webAuthn": json!({ + "enabled": !keys.is_empty(), + "keys": keys.iter().map(WebauthnRegistration::to_json).collect::>(), + }), }))) } @@ -369,8 +408,7 @@ pub async fn get_webauthn_registrations( user_id: &UserId, conn: &DbConn, ) -> Result<(bool, Vec), Error> { - let type_ = TwoFactorType::Webauthn as i32; - match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { + match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Webauthn, conn).await { Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)), None => Ok((false, Vec::new())), // If no data, return empty list } @@ -417,8 +455,9 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes } pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult { - let type_ = TwoFactorType::WebauthnLoginChallenge as i32; - let mut state = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await { + let mut state = if let Some(tf) = + TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginChallenge, conn).await + { let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?; tf.delete(conn).await?; state diff --git a/src/api/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs index 54f0adb5..17c4e9a0 100644 --- a/src/api/core/two_factor/yubikey.rs +++ b/src/api/core/two_factor/yubikey.rs @@ -10,9 +10,12 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, PasswordOrOtpData, - core::{log_user_event, two_factor::generate_recover_code}, + core::{ + log_user_event, + two_factor::{VerificationTokenData, generate_recover_code}, + }, }, - auth::Headers, + auth::{Headers, two_factor}, db::{ DbConn, models::{EventType, TwoFactor, TwoFactorType}, @@ -22,7 +25,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![generate_yubikey, activate_yubikey, activate_yubikey_put,] + routes![generate_yubikey, activate_yubikey, activate_yubikey_put, delete_yubikeys,] } struct HttpClientTransport { @@ -60,8 +63,7 @@ struct EnableYubikeyData { key4: Option, key5: Option, nfc: bool, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } #[derive(Deserialize, Serialize, Debug)] @@ -125,48 +127,29 @@ async fn generate_yubikey(data: Json, headers: Headers, conn: data.validate(&user, false, &conn).await?; let user_id = &user.uuid; - let yubikey_type = TwoFactorType::YubiKey as i32; - let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await; - - if let Some(r) = r { - let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; - - let mut result = jsonify_yubikeys(yubikey_metadata.keys); - - result["enabled"] = Value::Bool(true); - result["nfc"] = Value::Bool(yubikey_metadata.nfc); - result["object"] = Value::String("twoFactorU2f".to_owned()); - - Ok(Json(result)) - } else { - Ok(Json(json!({ - "enabled": false, - "object": "twoFactorU2f", - }))) - } + let (enabled, keys, yubikey_json) = + if let Some(r) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::YubiKey, &conn).await { + let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; + let enabled = !yubikey_metadata.keys.is_empty(); + let mut result = jsonify_yubikeys(yubikey_metadata.keys.clone()); + result["enabled"] = Value::Bool(enabled); + result["nfc"] = Value::Bool(yubikey_metadata.nfc); + (enabled, yubikey_metadata.keys, result) + } else { + (false, Vec::new(), json!({"enabled": false})) + }; + Ok(Json(json!({ + "yubiKey": yubikey_json, + "userVerificationToken": two_factor::yubikey_token(user.uuid, keys, enabled), + }))) } #[post("/two-factor/yubikey", data = "")] async fn activate_yubikey(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: EnableYubikeyData = data.into_inner(); - let mut user = headers.user; - - PasswordOrOtpData { - master_password_hash: data.master_password_hash.clone(), - otp: data.otp.clone(), - } - .validate(&user, true, &conn) - .await?; - - // Check if we already have some data - let mut yubikey_data = - match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await { - Some(data) => data, - None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()), - }; - let yubikeys = parse_yubikeys(&data); + let mut user = headers.user; if yubikeys.is_empty() { // Return an error to prevent saving empty keys which would cause users not being able to login anymore. @@ -174,6 +157,17 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: err!("A key is required."); } + // Check if we already have some data + let mut yubikey_data = + if let Some(yd) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await { + let ym: YubikeyMetadata = serde_json::from_str(&yd.data)?; + two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &ym.keys, !ym.keys.is_empty())?; + yd + } else { + two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &Vec::new(), false)?; + TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()) + }; + // Ensure they are valid OTPs for yubikey in &yubikeys { if yubikey.is_empty() || yubikey.len() == 12 { @@ -195,15 +189,12 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; let mut result = jsonify_yubikeys(yubikey_metadata.keys); - result["enabled"] = Value::Bool(true); result["nfc"] = Value::Bool(yubikey_metadata.nfc); - result["object"] = Value::String("twoFactorU2f".to_owned()); - - Ok(Json(result)) + Ok(Json(json!({"yubiKey": result}))) } #[put("/two-factor/yubikey", data = "")] @@ -211,6 +202,23 @@ async fn activate_yubikey_put(data: Json, headers: Headers, c activate_yubikey(data, headers, conn).await } +#[delete("/two-factor/yubikey", data = "")] +async fn delete_yubikeys(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let user = headers.user; + + if let Some(r) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await { + let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; + two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &yubikey_metadata.keys, true)?; + + r.delete(&conn).await?; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + } + + super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?; + + Ok(()) +} + pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult { if response.len() != 44 { err!("Invalid Yubikey OTP length"); diff --git a/src/api/identity.rs b/src/api/identity.rs index ac05386a..b61258cf 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -118,7 +118,7 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo match &login_result { Ok(_) => { log_user_event( - EventType::UserLoggedIn as i32, + EventType::UserLoggedIn, &user_id, client_header.device_type, &client_header.ip.ip, @@ -128,8 +128,7 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo } Err(e) => { if let Some(ev) = e.get_event() { - log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn) - .await; + log_user_event(ev.event, &user_id, client_header.device_type, &client_header.ip.ip, &conn).await; } } } @@ -904,7 +903,7 @@ async fn twofactor_auth( enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?; - log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await; + log_user_event(EventType::UserRecovered2fa, &user.uuid, device.atype, &ip.ip, conn).await; if CONFIG.mail_enabled() && let Err(e) = @@ -974,7 +973,7 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, } Some(tf_type @ TwoFactorType::YubiKey) => { - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else { err!("No YubiKey devices registered") }; @@ -986,7 +985,7 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, } Some(tf_type @ TwoFactorType::Email) => { - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else { err!("No twofactor email registered") }; diff --git a/src/api/mod.rs b/src/api/mod.rs index 1b3b5a7b..8baa221d 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -46,7 +46,7 @@ pub type JsonResult = ApiResult>; pub type EmptyResult = ApiResult<()>; // Common structs representing JSON data received -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct PasswordOrOtpData { #[serde(alias = "MasterPasswordHash")] diff --git a/src/auth.rs b/src/auth.rs index cad9b298..57720382 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,3 +1,6 @@ +#[path = "auth/two_factor.rs"] +pub mod two_factor; + #[path = "auth/send.rs"] pub mod send; pub type SendTokens = send::SendTokens; diff --git a/src/auth/two_factor.rs b/src/auth/two_factor.rs new file mode 100644 index 00000000..533c0cf4 --- /dev/null +++ b/src/auth/two_factor.rs @@ -0,0 +1,281 @@ +use chrono::{TimeDelta, Utc}; +use serde::{de::DeserializeOwned, ser::Serialize}; +use std::sync::LazyLock; + +use crate::{ + CONFIG, + api::{ApiResult, EmptyResult}, + auth::{decode_jwt, encode_jwt}, + db::models::UserId, +}; + +static JWT_2FA_AUTH_ISSUER: LazyLock = LazyLock::new(|| format!("{}|api.2fa", CONFIG.domain_origin())); + +#[derive(Serialize, Deserialize)] +pub struct TwoFactorClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: UserId, + + pub enabled: bool, + + pub claims: T, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuthenticatorClaims { + #[serde(rename = "authenticator_key")] + pub key: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DuoClaims { + #[serde(rename = "duo_data")] + pub data: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WebauthnClaims { + #[serde(rename = "webauthn_keys")] + pub keys: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct YubikeyClaims { + #[serde(rename = "yubi_keys")] + pub keys: Vec, +} + +#[derive(Serialize, Deserialize, PartialEq)] +pub struct DuoData { + pub host: String, // Duo API hostname + pub ik: String, // client id + pub sk: String, // client secret +} + +impl DuoData { + pub fn global() -> Option { + match (CONFIG._enable_duo(), CONFIG.duo_host()) { + (true, Some(host)) => Some(Self { + host, + ik: CONFIG.duo_ikey().unwrap(), + sk: CONFIG.duo_skey().unwrap(), + }), + _ => None, + } + } + pub fn msg(s: &str) -> Self { + Self { + host: s.into(), + ik: s.into(), + sk: s.into(), + } + } + pub fn secret() -> Self { + Self::msg("") + } + pub fn obscure(self) -> Self { + let mut host = self.host; + let mut ik = self.ik; + let mut sk = self.sk; + + let digits = 4; + let replaced = "************"; + + host.replace_range(digits.., replaced); + ik.replace_range(digits.., replaced); + sk.replace_range(digits.., replaced); + + Self { + host, + ik, + sk, + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EmailClaims { + pub email: Option, +} + +fn token(user_id: UserId, enabled: bool, claims: T) -> String { + let time_now = Utc::now(); + let claims = TwoFactorClaims { + nbf: time_now.timestamp(), + exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(), + iss: JWT_2FA_AUTH_ISSUER.to_string(), + sub: user_id, + enabled, + claims, + }; + encode_jwt(&claims) +} + +fn validate(token: &str, user_id: &UserId, enabled: bool) -> ApiResult { + match decode_jwt::>(token, JWT_2FA_AUTH_ISSUER.to_string()) { + Ok(claims) => { + if claims.sub != *user_id { + err!("Invalid verification token: Invalid user"); + } + if claims.enabled != enabled { + err!("Invalid verification token: Invalid state"); + } + Ok(claims.claims) + } + Err(err) => err!(format!("Failed to decode verification token: {err}")), + } +} + +pub fn authenticator_token(user_id: UserId, key: String, enabled: bool) -> String { + token( + user_id, + enabled, + AuthenticatorClaims { + key, + }, + ) +} + +pub fn validate_authenticator(token: &str, user_id: &UserId, key: &str, enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if claims.key != key { + err!("Invalid verification token: Invalid key"); + } + Ok(()) +} + +pub fn duo_token(user_id: UserId, data: Option, enabled: bool) -> String { + token( + user_id, + enabled, + DuoClaims { + data, + }, + ) +} + +// When disabling we check that it's the correct data +pub fn validate_duo(token: &str, user_id: &UserId, data: Option<&DuoData>, enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if enabled && claims.data.as_ref() != data { + err!("Invalid verification token: Invalid duo data"); + } + Ok(()) +} + +pub fn email_token(user_id: UserId, email: Option, enabled: bool) -> String { + token( + user_id, + enabled, + EmailClaims { + email, + }, + ) +} + +// When disabling we check that it's the correct `email` +pub fn validate_email(token: &str, user_id: &UserId, email: String, enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if enabled && claims.email != Some(email) { + err!("Invalid verification token: Invalid email"); + } + Ok(()) +} + +pub fn webauthn_token(user_id: UserId, keys: Vec, enabled: bool) -> String { + token( + user_id, + enabled, + WebauthnClaims { + keys, + }, + ) +} + +pub fn validate_webauthn(token: &str, user_id: &UserId, keys: &[i32], enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if keys != claims.keys { + err!("Invalid verification token: Invalid keys"); + } + Ok(()) +} + +pub fn yubikey_token(user_id: UserId, keys: Vec, enabled: bool) -> String { + token( + user_id, + enabled, + YubikeyClaims { + keys, + }, + ) +} + +pub fn validate_yubikey(token: &str, user_id: &UserId, keys: &Vec, enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if *keys != claims.keys { + err!("Invalid verification token: Invalid keys"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, from_value, to_value}; + + fn token(claims: T) -> Value { + to_value(TwoFactorClaims { + nbf: 0, + exp: 0, + iss: String::new(), + sub: UserId::from(String::from("4ff0f0a4-0aa4-4c1d-9d43-1f2bd4d5e8b1")), + enabled: false, + claims, + }) + .unwrap() + } + + #[test] + fn claims_only_parse_as_their_own_provider() { + let tokens = [ + token(AuthenticatorClaims { + key: String::from("JBSWY3DPEHPK3PXP"), + }), + token(DuoClaims { + data: None, + }), + token(WebauthnClaims { + keys: vec![1], + }), + token(YubikeyClaims { + keys: Vec::new(), + }), + token(EmailClaims { + email: None, + }), + ]; + for (issued, token) in tokens.iter().enumerate() { + let parsed = [ + from_value::>(token.clone()).is_ok(), + from_value::>(token.clone()).is_ok(), + from_value::>(token.clone()).is_ok(), + from_value::>(token.clone()).is_ok(), + from_value::>(token.clone()).is_ok(), + ]; + for (checked, ok) in parsed.into_iter().enumerate() { + assert_eq!(ok, issued == checked, "token {issued} parsed as {checked}"); + } + } + } +} diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 5f57635e..051ea4df 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -137,11 +137,11 @@ impl TwoFactor { .await } - pub async fn find_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Option { + pub async fn find_by_user_and_type(user_uuid: &UserId, atype: TwoFactorType, conn: &DbConn) -> Option { conn.run(move |conn| { twofactor::table .filter(twofactor::user_uuid.eq(user_uuid)) - .filter(twofactor::atype.eq(atype)) + .filter(twofactor::atype.eq(atype as i32)) .first::(conn) .ok() }) From a2efadc650676e98cc1a6152141b6f930f496109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 7 Oct 2026 00:16:01 +0200 Subject: [PATCH 29/30] Fix email 2FA for SSO logins on iOS and Android (#7827) * Allow email-only /api/two-factor/send-email-login for mobile clients Mobile clients (iOS) may call /api/two-factor/send-email-login with only the user's email and without a MasterPasswordHash or an AuthRequest. Permit email-only requests so the server will send the email 2FA token in that flow.\n\nModified: src/api/core/two_factor/email.rs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(email-2fa): use device-identifier fallback when no password hash submitted iOS clients call /api/two-factor/send-email-login with an email and DeviceIdentifier but without masterPasswordHash or authRequestId after receiving a 2FA-required response from the token endpoint. The previous empty else-block allowed any caller to trigger a 2FA email for any account knowing only the email address. Replace the empty block with a device-identifier-based fallback: - Look up the most-recently-active device via find_by_device_for_email2fa. - Verify the device's associated user email matches the submitted email. - Log (debug) when the fallback path is exercised for operator visibility. - Reject with the original error when no device identifier is provided or when the device maps to a different account. Fixes #7568 * Check the pending 2FA login for this user and device Look up the pending 2FA login for the user and device instead of the latest one on the device, answer a failed check with the same error and IP/username log detail as a wrong password, and escape the device id in the logs. --------- Co-authored-by: Arunabha-Mukhopadhyay Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/api/core/two_factor/email.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 2b0ee785..531eea58 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -14,7 +14,10 @@ use crate::{ crypto, db::{ DbConn, - models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, + models::{ + AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, + UserId, + }, }, error::{Error, MapResult}, mail, @@ -94,6 +97,20 @@ async fn send_email_login(data: Json, client_headers: Client { err!("AuthRequest doesn't exist", "Invalid device, IP or code") } + } else if let Some(device_identifier) = &data.device_identifier { + // iOS/Android SSO logins send the email and device id but no password hash, + // so accept a device that has a pending 2FA login for this user + if TwoFactorIncomplete::find_by_user_and_device(&user.uuid, device_identifier, &conn).await.is_none() { + err!( + "Username or password is incorrect. Try again", + format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug()) + ) + } + debug!( + "Email 2FA fallback: pending login. Username: {}. Device: {}.", + user.email, + device_identifier.to_string().escape_debug() + ); } else { err!("No password hash has been submitted.") } @@ -107,7 +124,7 @@ async fn send_email_login(data: Json, client_headers: Client let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else { err!( "Username or password is incorrect. Try again", - format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip) + format!("IP: {}. Device: {}.", client_headers.ip.ip, device_identifier.to_string().escape_debug()) ) }; From 1f802f8e6ae9b788e1e9cc5dffb7e83780417332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 7 Oct 2026 00:56:01 +0200 Subject: [PATCH 30/30] Update web-vault to v2026.8.0 (#7828) --- docker/DockerSettings.yaml | 4 ++-- docker/Dockerfile.alpine | 12 ++++++------ docker/Dockerfile.debian | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docker/DockerSettings.yaml b/docker/DockerSettings.yaml index 6ae6e9a8..dbe0ac92 100644 --- a/docker/DockerSettings.yaml +++ b/docker/DockerSettings.yaml @@ -1,6 +1,6 @@ --- -vault_version: "v2026.7.0" -vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c" +vault_version: "v2026.8.0" +vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a" # Cross Compile Docker Helper Scripts v1.9.0 # We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts # https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine index 6e6a79a3..be267da5 100644 --- a/docker/Dockerfile.alpine +++ b/docker/Dockerfile.alpine @@ -19,15 +19,15 @@ # - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # click the tag name to view the digest of the image it currently points to. # - From the command line: -# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0 -# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c] +# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0 +# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0 +# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c -# [docker.io/vaultwarden/web-vault:v2026.7.0] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a +# [docker.io/vaultwarden/web-vault:v2026.8.0] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault ########################## ALPINE BUILD IMAGES ########################## ## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 44d068ef..ac82c24b 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -19,15 +19,15 @@ # - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # click the tag name to view the digest of the image it currently points to. # - From the command line: -# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0 -# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c] +# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0 +# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0 +# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c -# [docker.io/vaultwarden/web-vault:v2026.7.0] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a +# [docker.io/vaultwarden/web-vault:v2026.8.0] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault ########################## Cross Compile Docker Helper Scripts ########################## ## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts