diff --git a/.env.template b/.env.template index 7484053f..d22145b8 100644 --- a/.env.template +++ b/.env.template @@ -78,7 +78,7 @@ # DB_CONNECTION_RETRIES=15 ## Database timeout -## Timeout in seconds when acquiring database connection +## Timeout when acquiring database connection # DATABASE_TIMEOUT=30 ## Database idle timeout @@ -173,14 +173,14 @@ ## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start. # EVENT_CLEANUP_SCHEDULE="0 10 0 * * *" ## Number of days to retain events stored in the database. -## If unset (the default) or 0, events are kept indefinitely and the scheduled job is disabled! +## If unset (the default), events are kept indefinitely and the scheduled job is disabled! # EVENTS_DAYS_RETAIN= ## ## 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="30 * * * * *" ## -## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled. +## 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. ## Defaults to every minute. Set blank to disable this job. # DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *" # @@ -233,7 +233,7 @@ # USER_SEND_LIMIT= ## Number of days to wait before auto-deleting a trashed item. -## If unset (the default) or 0, trashed items are not auto-deleted. +## If unset (the default), trashed items are not auto-deleted. ## This setting applies globally, so make sure to inform all users of any changes to this setting. # TRASH_AUTO_DELETE_DAYS= @@ -338,8 +338,6 @@ ## "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 @@ -386,34 +384,28 @@ # HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true ## Client Settings -## Server-wide Fill Assist rules feed base directory URL, not a JSON file. -## Defaults to https://fillassist.bitwarden.com. -## Must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace. -# FILL_ASSIST_RULES_URL=https://fillassist.bitwarden.com - ## Enable experimental feature flags for clients. ## This is a comma-separated list of flags, e.g. "flag1,flag2,flag3". ## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled! ## ## The following flags are available: -## - "fill-assist-targeting-rules": Enable Fill Assist targeting rules in the browser extension. (Browser >= 2026.6.0) ## - "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) -## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0) -## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1) -## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled. -## - "pm-30529-webauthn-related-origins": Allow passkeys stored in the vault to be used on related origins listed in a site's /.well-known/webauthn file (Browser >= 2026.2.0) -## - "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) -## - "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) -## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0) -## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers. -## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet. -## - "pm-27278-v2-password-registration": Register new accounts with v2 encryption. (Web >= 2026.6.4, Desktop/Browser/CLI >= 2026.7.0, Android >= 2026.4.1, iOS >= 2026.5.1) -## - "enable-account-encryption-v2-jit-password-registration": Give SSO users who set their master password on their first login v2 encryption. (Web >= 2026.1.1, Desktop/Browser/CLI >= 2026.1.0, Android >= 2026.4.1, iOS >= 2026.6.0) -## These two flags can't be undone: a v2 account stays v2, and it can only log in from clients that are 2026.10.1 or newer (2026.10.2 for the desktop app), so the versions above are only enough to create one. +## - "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": +## - "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) # EXPERIMENTAL_CLIENT_FEATURE_FLAGS= ## Require new device emails. When a user logs in an email is required to be sent. @@ -474,7 +466,7 @@ ## Allowed connect-src (Know the risks!) ## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src -## Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature +## Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature ## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value. ## Multiple values must be separated with a whitespace. And only HTTPS values are allowed. ## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld" @@ -500,15 +492,6 @@ ## KNOW WHAT YOU ARE DOING! # ORG_GROUPS_ENABLED=false -## EXPERIMENTAL FEATURE: Custom roles -## Enables the Custom member role with granular permissions for organizations. -## This is still being developed: only the permissions this version enforces can be granted. -## This is enforced by the server and is unrelated to EXPERIMENTAL_CLIENT_FEATURE_FLAGS. -## Disabling it again keeps the stored Custom roles and permissions, but they grant nothing until it is re-enabled. -## Disabled by default because this is an experimental feature! -## KNOW WHAT YOU ARE DOING! -# EXPERIMENTAL_CUSTOM_ROLES=false - ## Increase secure note size limit (Know the risks!) ## Sets the secure note size limit to 100_000 instead of the default 10_000. ## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! @@ -601,6 +584,11 @@ # 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 @@ -626,9 +614,6 @@ ## Note that the checkbox would still be present, but ignored. # DISABLE_2FA_REMEMBER=false ## -## Number of days before a remembered 2FA login expires (min: 1, max: 90). -# TWO_FACTOR_REMEMBER_DAYS=30 -## ## Authenticator Settings ## Disable authenticator time drifted codes to be valid. ## TOTP codes of the previous and next 30 seconds will be invalid @@ -637,7 +622,7 @@ ## we allow by default the TOTP code which was valid one step back and one in the future. ## This can however allow attackers to be a bit more lucky with their attempts because there are 3 valid codes. ## You can disable this, so that only the current TOTP Code is allowed. -## Keep in mind that when a server drifts out of time, valid codes could be marked as invalid. +## Keep in mind that when a sever drifts out of time, valid codes could be marked as invalid. ## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid. # AUTHENTICATOR_DISABLE_TIME_DRIFT=false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 673137cf..f52bd8e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -113,7 +113,7 @@ jobs: # Enable Rust Caching - name: Rust Caching - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: # Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes. # Like changing the build host from Ubuntu 20.04 to 22.04 for example. diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml index c4b83aa6..a429d10f 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@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 # 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 6f80509d..311891b7 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@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 with: platforms: "arm64,arm" # Start Docker Buildx - name: Setup Docker Buildx - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 # https://github.com/moby/buildkit/issues/3969 # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills with: @@ -106,7 +106,7 @@ jobs: # Login to Docker Hub - name: Login to Docker Hub - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -121,7 +121,7 @@ jobs: # Login to GitHub Container Registry - name: Login to GitHub Container Registry - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -137,7 +137,7 @@ jobs: # Login to Quay.io - name: Login to Quay.io - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} @@ -185,7 +185,7 @@ jobs: - name: Bake ${{ matrix.base_image }} containers id: bake_vw - uses: docker/bake-action@59cb1203cd42bcefd40a1b6d73b589e48c86040e # v7.5.0 + uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0 env: BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}" SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}" @@ -222,7 +222,7 @@ jobs: touch "${RUNNER_TEMP}/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }} path: ${{ runner.temp }}/digests/* @@ -242,7 +242,7 @@ jobs: subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }} - name: Upload binaries as artifacts - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }} path: vaultwarden-${{ env.NORMALIZED_ARCH }} @@ -264,7 +264,7 @@ jobs: steps: - name: Download digests - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: ${{ runner.temp }}/digests pattern: digests-*-${{ matrix.base_image }} @@ -272,7 +272,7 @@ jobs: # Login to Docker Hub - name: Login to Docker Hub - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -287,7 +287,7 @@ jobs: # Login to GitHub Container Registry - name: Login to GitHub Container Registry - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -303,7 +303,7 @@ jobs: # Login to Quay.io - name: Login to Quay.io - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 7ffbe632..7f41885d 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@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index f7d1f92c..00fcbab4 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@7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1 + uses: crate-ci/typos@d43b6c087ac471e2ea7b8af622ff15f05c0c365b # v1.50.1 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 7372759b..31153b07 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0 + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 with: # intentionally not scanning the entire repository, # since it contains integration tests. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b6352bbb..e8319414 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: 7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1 + rev: d43b6c087ac471e2ea7b8af622ff15f05c0c365b # v1.50.1 hooks: - id: typos always_run: true diff --git a/.typos.toml b/.typos.toml index dabcadb4..87c0c4a6 100644 --- a/.typos.toml +++ b/.typos.toml @@ -14,8 +14,9 @@ extend-ignore-re = [ # In SMTP it's called HELO, so ignore it "(?i)helo_name", "Server name sent during.+HELO", - # COSE Is short for CBOR Object Signing and Encryption - "(?i)cose", + # COSE Is short for CBOR Object Signing and Encryption, ignore these specific items + "COSEKey", + "COSEAlgorithm", # Ignore this specific string as it's valid "Ensure they are valid OTPs", # This word is misspelled upstream diff --git a/Cargo.lock b/Cargo.lock index 587ac6b6..55a7233b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,15 +32,15 @@ dependencies = [ [[package]] name = "alloc-no-stdlib" -version = "3.0.0" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.3.0" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -68,9 +68,9 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arc-swap" -version = "1.9.3" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -112,7 +112,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure 0.13.2", + "synstructure", ] [[package]] @@ -151,9 +151,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.51" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970" +checksum = "24a8ec73eb862508b7041723c89386365894b4e7d9f6998bf1b8529e5b0ee254" dependencies = [ "compression-codecs", "compression-core", @@ -318,14 +318,24 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.5", ] [[package]] name = "asyncband" -version = "0.7.4" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2050892d4691cf110586070786712b504985a4d37ed64a9cd8a402718e1706" +checksum = "94a214ba60d6231afd0e805e3c27c45a1626d9debaa5a5061c45a1ea1b2f1ed0" +dependencies = [ + "hashbrown 0.17.1", + "slab", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" [[package]] name = "atomic" @@ -350,9 +360,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.13.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "939e256cfe0ed68e96d32f194339d88210d5c10685cff0787400158f852c6819" +checksum = "b8d7b388a9fc3a6db15a5ec778c38b354eff1364882c94d08e0252f7a47dcaa4" dependencies = [ "aws-credential-types", "aws-runtime", @@ -381,9 +391,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.3.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "576e8da35751dc85409ec1cd15999f692c277a1fbe9336780fe6f15e49dd637d" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -393,9 +403,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.10.2" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a0cc2144962cc5e358467b16e3d82981b1d424a7c3d34602be0036103814a1a" +checksum = "ef47857a1d4488b528f4a5d5715fa7c3300820897824152234d3fa22b1426657" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -409,7 +419,7 @@ dependencies = [ "bytes-utils", "fastrand", "http 1.5.0", - "http-body", + "http-body 1.1.0", "percent-encoding", "pin-project-lite", "tracing", @@ -418,9 +428,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.119.0" +version = "1.109.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c414f25ec4aa9dc18f5cc8f55e18848bb509fcda4ce7c77bee6072e95806bc9" +checksum = "c3cfe74df5d9ad2fedd691973ad3521ebf4f27a3c68c792556686aedb5519bab" dependencies = [ "arc-swap", "aws-credential-types", @@ -436,6 +446,7 @@ dependencies = [ "aws-types", "bytes", "fastrand", + "http 0.2.12", "http 1.5.0", "regex-lite", "tracing", @@ -443,9 +454,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.121.0" +version = "1.111.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc638bb4355e6514334dcc3228cd4622f5ee54d704bd6a9010a5a91d7856d9d9" +checksum = "81b0ec31ed6191bd11350aae4b2004198f2db21350cb0a20c57e0a92e55dd161" dependencies = [ "arc-swap", "aws-credential-types", @@ -461,6 +472,7 @@ dependencies = [ "aws-types", "bytes", "fastrand", + "http 0.2.12", "http 1.5.0", "regex-lite", "tracing", @@ -468,9 +480,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.124.0" +version = "1.114.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f8a0dd8fbde04ba58ad722113c7addb11bf94cf549b1ea0eb9ea427fb87198a" +checksum = "ef45745026107ec30c4ef86bd8ae4b002e7e5f6a86e4225240bdf6b06a0b944a" dependencies = [ "arc-swap", "aws-credential-types", @@ -487,6 +499,7 @@ dependencies = [ "aws-smithy-xml", "aws-types", "fastrand", + "http 0.2.12", "http 1.5.0", "regex-lite", "tracing", @@ -494,9 +507,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.6.4" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f385da9b7757ed4c8214797ad663ff534fb622006143530aa80e21f8e06ff85c" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -506,18 +519,19 @@ dependencies = [ "form_urlencoded", "hex", "hmac 0.13.0", + "http 0.2.12", "http 1.5.0", "percent-encoding", - "sha2 0.11.1", + "sha2 0.11.0", "time", "tracing", ] [[package]] name = "aws-smithy-async" -version = "1.3.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" dependencies = [ "futures-util", "pin-project-lite", @@ -526,9 +540,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.64.3" +version = "0.64.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19af609dd5edf8b056cd0bb001c706c8b3b1458055328b334ec13d7f46f6aaa8" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -537,18 +551,19 @@ dependencies = [ "futures-core", "futures-util", "http 1.5.0", - "http-body", + "http-body 1.1.0", "http-body-util", "percent-encoding", "pin-project-lite", + "pin-utils", "tracing", ] [[package]] name = "aws-smithy-json" -version = "0.65.1" +version = "0.63.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45f9fc77ffe92c92c341165a01a565bd30c39ecf5aa8c0719b41719009604f9" +checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -566,9 +581,9 @@ dependencies = [ [[package]] name = "aws-smithy-query" -version = "0.64.0" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165f6265342a313ad6f7a1ac17a0cdba30c522039d3f41d8d6871ce8a2b34ecd" +checksum = "512346c7212ab7436df2d77a16d976a468ae44a418835511d2a69269810aaf62" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -579,9 +594,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.16.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cb80fb19c084da5fca9d797845735e184445e5b958599440d67529bf6ef1b64" +checksum = "b82e438d30e02a825d363bd639a9efaed68a8089d86101054b0081e7e0d3e606" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -591,24 +606,28 @@ dependencies = [ "aws-smithy-types", "bytes", "fastrand", + "http 0.2.12", "http 1.5.0", - "http-body", + "http-body 0.4.6", + "http-body 1.1.0", "http-body-util", "pin-project-lite", + "pin-utils", "tokio", "tracing", ] [[package]] name = "aws-smithy-runtime-api" -version = "1.19.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c4e85cffe9944b10d576b075432bd8f98dbc081e8681cb37d5b2d87dc27bc84" +checksum = "9c054752dd9e4dc73d0b75748c99ac2d0feafbf2f25c7b0516f03a3534161223" 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", @@ -618,9 +637,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api-macros" -version = "1.2.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce237f890a2283c9b328955c3b0918d2d5bf66a1cb6ceccd36a0047246fbc6e6" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" dependencies = [ "proc-macro2", "quote", @@ -629,9 +648,9 @@ dependencies = [ [[package]] name = "aws-smithy-schema" -version = "1.1.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9543fc722ed4865ab29fb66bd7ed0bef7b029d265ba8c587144511e30006d431" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -640,19 +659,22 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.9.1" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf183d70774ea2cfcc101ca74a8884e4c76be8b9df65cd303f6a1175c88e8f80" +checksum = "8f94d16e797ec62cd999fc9d5942b48fa7050c3093ddadff48e4d7528d16fcb9" dependencies = [ "base64-simd", "bytes", "bytes-utils", + "http 0.2.12", "http 1.5.0", - "http-body", + "http-body 0.4.6", + "http-body 1.1.0", "http-body-util", "itoa", "num-integer", "pin-project-lite", + "pin-utils", "ryu", "serde", "time", @@ -660,9 +682,9 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.64.1" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590fdc1a70546b2f7f2061c42eaf173fb6f26d935330d069edc04dbd8f01d924" +checksum = "ce84f71c72fee2cbbadde6e7d082f5fb466e3a84733855295fa7aafd1b31b7d8" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -672,9 +694,9 @@ dependencies = [ [[package]] name = "aws-types" -version = "1.7.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba0d970d3947bb064d2ae1d823efa186c1dbde88f88afa8f6549946e5d8deee" +checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -691,12 +713,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base16ct" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" - [[package]] name = "base64" version = "0.21.7" @@ -744,9 +760,9 @@ dependencies = [ [[package]] name = "bigdecimal" -version = "0.4.11" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" dependencies = [ "autocfg", "libm", @@ -769,9 +785,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.2" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "blake2" @@ -815,9 +831,9 @@ dependencies = [ [[package]] name = "brotli" -version = "9.1.1" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -826,9 +842,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "6.2.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -879,17 +895,17 @@ dependencies = [ [[package]] name = "cached" -version = "4.1.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f886e989b5b6e7991606f2550e45a99e130024465ba8d67f9f9b256eb7044bf" +checksum = "c5a6cf8262820194a1488ece477f5fb5ba9256ef25229d525470e71d6e9a5835" dependencies = [ "ahash", "async-lock", "cached_proc_macro", "cached_proc_macro_types", "hashbrown 0.17.1", - "parking_lot 0.12.5", - "thiserror 2.0.21", + "parking_lot", + "thiserror 2.0.20", "web-time", ] @@ -914,9 +930,9 @@ checksum = "f5813789573ae815c8b4be58c4428e0e7ae05f0227678ba9de332ded585b9159" [[package]] name = "cc" -version = "1.10.0" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -926,9 +942,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.5" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -985,9 +1001,9 @@ checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" [[package]] name = "combine" -version = "4.6.9" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -995,9 +1011,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.46" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe" +checksum = "100590da849306918656ffbb22576bbdfc1382b50ad10d1d50ec177d3b205fb2" dependencies = [ "brotli", "compression-core", @@ -1009,9 +1025,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.34" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" [[package]] name = "concurrent-queue" @@ -1124,12 +1140,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -1160,9 +1170,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.3" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -1227,22 +1237,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "crypto-bigint" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" -dependencies = [ - "cpubits", - "ctutils", - "getrandom 0.4.3", - "hybrid-array", - "num-traits", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.6" @@ -1259,19 +1253,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.3", "hybrid-array", - "rand_core 0.10.1", ] [[package]] name = "ctutils" -version = "0.4.7" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", - "subtle", ] [[package]] @@ -1284,23 +1275,7 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto 0.2.9", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto 0.3.0", + "fiat-crypto", "rustc_version", "subtle", "zeroize", @@ -1385,7 +1360,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.9", + "syn 3.0.5", ] [[package]] @@ -1418,7 +1393,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core 0.24.1", "quote", - "syn 3.0.9", + "syn 3.0.5", ] [[package]] @@ -1430,9 +1405,9 @@ dependencies = [ "cfg-if", "crossbeam-utils", "hashbrown 0.14.5", - "lock_api 0.4.14", + "lock_api", "once_cell", - "parking_lot_core 0.9.12", + "parking_lot_core", ] [[package]] @@ -1449,9 +1424,9 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] name = "defmt" -version = "1.2.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -1459,23 +1434,23 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.2.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 3.0.9", + "syn 2.0.119", ] [[package]] name = "defmt-parser" -version = "1.1.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.21", + "thiserror 2.0.20", ] [[package]] @@ -1485,18 +1460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", - "pem-rfc7468 0.7.0", - "zeroize", -] - -[[package]] -name = "der" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" -dependencies = [ - "const-oid 0.10.2", - "pem-rfc7468 1.0.0", + "pem-rfc7468", "zeroize", ] @@ -1516,9 +1480,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.9" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "serde_core", ] @@ -1579,9 +1543,9 @@ dependencies = [ [[package]] name = "devise" -version = "0.5.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "143afb21c617f66c792ef2d75f9fb4c9dd774ff206bcc24df08a1bda294a37bc" +checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" dependencies = [ "devise_codegen", "devise_core", @@ -1589,9 +1553,9 @@ dependencies = [ [[package]] name = "devise_codegen" -version = "0.5.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b3187e4283046e01d16d724ed05326427aa9c1c3a376548ef31820e6eb9717" +checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" dependencies = [ "devise_core", "quote", @@ -1599,25 +1563,25 @@ dependencies = [ [[package]] name = "devise_core" -version = "0.5.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "970e4b2fcd605ffa79715510663a9fe76b87f5483d59857399129cf047f73d27" +checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" dependencies = [ - "bitflags 2.13.2", + "bitflags 2.13.1", "proc-macro2", - "proc-macro2-diagnostics 0.11.0", + "proc-macro2-diagnostics", "quote", - "syn 3.0.9", + "syn 2.0.119", ] [[package]] name = "diesel" -version = "2.3.14" +version = "2.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe9f7eaef33febd60290c5a9f3b0571d03c4c04a24739cd64808ce30e65a5d3" +checksum = "e3b934ddbdcb2abb9f9fc9c30bd47bcc5618b615eea1d334cda5fdf8ff9b072a" dependencies = [ "bigdecimal", - "bitflags 2.13.2", + "bitflags 2.13.1", "byteorder", "chrono", "diesel_derives", @@ -1712,7 +1676,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.5", ] [[package]] @@ -1771,27 +1735,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der 0.7.10", + "der", "digest 0.10.7", - "elliptic-curve 0.13.8", - "rfc6979 0.4.0", - "signature 2.2.0", - "spki 0.7.3", -] - -[[package]] -name = "ecdsa" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" -dependencies = [ - "der 0.8.3", - "digest 0.11.3", - "elliptic-curve 0.14.3", - "rfc6979 0.6.0", - "signature 3.0.0", - "spki 0.8.1", - "zeroize", + "elliptic-curve", + "rfc6979", + "signature", + "spki", ] [[package]] @@ -1800,18 +1749,8 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8 0.10.2", - "signature 2.2.0", -] - -[[package]] -name = "ed25519" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" -dependencies = [ - "pkcs8 0.11.0", - "signature 3.0.0", + "pkcs8", + "signature", ] [[package]] @@ -1820,34 +1759,19 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek 4.1.3", - "ed25519 2.2.3", + "curve25519-dalek", + "ed25519", "serde", "sha2 0.10.9", "subtle", "zeroize", ] -[[package]] -name = "ed25519-dalek" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" -dependencies = [ - "curve25519-dalek 5.0.0", - "ed25519 3.0.0", - "serde", - "sha2 0.11.1", - "signature 3.0.0", - "subtle", - "zeroize", -] - [[package]] name = "either" -version = "1.19.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elliptic-curve" @@ -1855,38 +1779,17 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct 0.2.0", - "crypto-bigint 0.5.5", + "base16ct", + "crypto-bigint", "digest 0.10.7", - "ff 0.13.1", + "ff", "generic-array", - "group 0.13.0", + "group", "hkdf", - "pem-rfc7468 0.7.0", - "pkcs8 0.10.2", + "pem-rfc7468", + "pkcs8", "rand_core 0.6.4", - "sec1 0.7.3", - "subtle", - "zeroize", -] - -[[package]] -name = "elliptic-curve" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" -dependencies = [ - "base16ct 1.0.0", - "crypto-bigint 0.7.10", - "crypto-common 0.2.2", - "digest 0.11.3", - "ff 0.14.0", - "group 0.14.0", - "hybrid-array", - "pem-rfc7468 1.0.0", - "pkcs8 0.11.0", - "rand_core 0.10.1", - "sec1 0.8.1", + "sec1", "subtle", "zeroize", ] @@ -1912,12 +1815,13 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.44" +version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" dependencies = [ "cfg-if", "core_detect", + "multiversion", "multiversion_no_op", "rustversion", "scopeguard", @@ -1926,9 +1830,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" @@ -1994,36 +1898,20 @@ dependencies = [ "subtle", ] -[[package]] -name = "ff" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" -dependencies = [ - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - [[package]] name = "figment" version = "0.10.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ - "atomic", - "pear 0.2.9", + "atomic 0.6.1", + "pear", "serde", "toml 0.8.23", "uncased", @@ -2032,9 +1920,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.15" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "flate2" @@ -2152,7 +2040,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.5", ] [[package]] @@ -2248,11 +2136,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasm-bindgen", ] [[package]] @@ -2287,7 +2173,7 @@ dependencies = [ "getrandom 0.3.4", "hashbrown 0.16.1", "nonzero_ext", - "parking_lot 0.12.5", + "parking_lot", "portable-atomic", "quanta", "rand 0.9.5", @@ -2315,27 +2201,16 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff 0.13.1", + "ff", "rand_core 0.6.4", "subtle", ] -[[package]] -name = "group" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" -dependencies = [ - "ff 0.14.0", - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "h2" -version = "0.4.20" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -2374,7 +2249,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.21", + "thiserror 2.0.20", "walkdir", ] @@ -2436,9 +2311,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hickory-net" -version = "0.26.3" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" +checksum = "084e7bd6a377435d568f652153e571b50970d7ccc1d1eeec0519f834632287e1" dependencies = [ "async-trait", "cfg-if", @@ -2450,8 +2325,8 @@ dependencies = [ "idna", "ipnet", "jni", - "rand 0.10.3", - "thiserror 2.0.21", + "rand 0.10.2", + "thiserror 2.0.20", "tinyvec", "tokio", "tracing", @@ -2460,9 +2335,9 @@ dependencies = [ [[package]] name = "hickory-proto" -version = "0.26.3" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" +checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d" dependencies = [ "data-encoding", "idna", @@ -2470,9 +2345,9 @@ dependencies = [ "jni", "once_cell", "prefix-trie", - "rand 0.10.3", + "rand 0.10.2", "ring", - "thiserror 2.0.21", + "thiserror 2.0.20", "tinyvec", "tracing", "url", @@ -2480,9 +2355,9 @@ dependencies = [ [[package]] name = "hickory-resolver" -version = "0.26.3" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981" +checksum = "0e4f9f4603319422d482738f3f6fe5aac03157fdbfed1cd85a3ff45adb09072f" dependencies = [ "cfg-if", "futures-util", @@ -2494,12 +2369,12 @@ dependencies = [ "moka", "ndk-context", "once_cell", - "parking_lot 0.12.5", - "rand 0.10.3", + "parking_lot", + "rand 0.10.2", "resolv-conf", "smallvec", "system-configuration", - "thiserror 2.0.21", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -2544,9 +2419,12 @@ dependencies = [ [[package]] name = "html5gum" -version = "0.9.0" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae3133814a10216af5242a254420a74cb0d2f7715646924f5a8e7720cd188b" +checksum = "428502d3ec1742c35e015871aef742bc4722a9acfcb616b8ff79b519922e1c36" +dependencies = [ + "jetscii", +] [[package]] name = "http" @@ -2569,6 +2447,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.1.0" @@ -2588,7 +2477,7 @@ dependencies = [ "bytes", "futures-core", "http 1.5.0", - "http-body", + "http-body 1.1.0", "pin-project-lite", ] @@ -2610,16 +2499,37 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ - "subtle", "typenum", - "zeroize", ] [[package]] name = "hyper" -version = "1.12.0" +version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -2627,9 +2537,8 @@ dependencies = [ "futures-core", "h2", "http 1.5.0", - "http-body", + "http-body 1.1.0", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2639,38 +2548,37 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.10" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.5.0", - "hyper", + "hyper 1.11.1", "hyper-util", - "rustls", + "rustls 0.23.44", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.5", "tower-service", ] [[package]] name = "hyper-util" -version = "0.1.21" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.23.1", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http 1.5.0", - "http-body", - "httparse", - "hyper", + "http-body 1.1.0", + "hyper 1.11.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -2704,9 +2612,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2718,9 +2626,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -2731,9 +2639,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2745,15 +2653,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ "displaydoc", "icu_collections", @@ -2766,15 +2674,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.4.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -2847,7 +2755,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2", + "socket2 0.6.5", "widestring", "windows-registry", "windows-result", @@ -2876,9 +2784,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.14.0" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] @@ -2890,10 +2798,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "jiff" -version = "0.2.38" +name = "jetscii" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", "jiff-core", @@ -2910,19 +2824,18 @@ dependencies = [ [[package]] name = "jiff-core" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" dependencies = [ "defmt", - "log", ] [[package]] name = "jiff-static" -version = "0.2.38" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ "jiff-core", "proc-macro2", @@ -2932,9 +2845,9 @@ dependencies = [ [[package]] name = "jiff-tzdb" -version = "0.1.9" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" [[package]] name = "jiff-tzdb-platform" @@ -2957,7 +2870,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.21", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -3017,9 +2930,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.107" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -3028,24 +2941,24 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "11.1.0" +version = "11.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" dependencies = [ "base64 0.22.1", - "ed25519-dalek 2.2.0", + "ed25519-dalek", "getrandom 0.2.17", "hmac 0.12.1", "js-sys", - "p256 0.13.2", - "p384 0.13.1", + "p256", + "p384", "pem", "rand 0.8.8", "rsa", "serde", "serde_json", "sha2 0.10.9", - "signature 2.2.0", + "signature", "simple_asn1", "zeroize", ] @@ -3070,9 +2983,9 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ "spin 0.9.9", ] @@ -3098,21 +3011,21 @@ dependencies = [ "nom 8.0.0", "percent-encoding", "quoted_printable", - "rustls", + "rustls 0.23.44", "rustls-native-certs", "serde", - "socket2", + "socket2 0.6.5", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.5", "tracing", "url", ] [[package]] name = "libc" -version = "0.2.191" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -3154,9 +3067,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" @@ -3167,20 +3080,11 @@ dependencies = [ "scopeguard", ] -[[package]] -name = "lock_api" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65d91b22da179613dcf4d6ec8349563f52a363f6849e03b00dd0a185ec4a59af" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" -version = "0.4.35" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "value-bag", ] @@ -3205,7 +3109,7 @@ name = "macros" version = "0.1.0" dependencies = [ "quote", - "syn 3.0.9", + "syn 3.0.5", ] [[package]] @@ -3227,6 +3131,16 @@ 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" @@ -3245,14 +3159,13 @@ dependencies = [ [[package]] name = "migrations_macros" -version = "2.3.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9423d6affd681fb4d25d35885d0d0262c52f8ade16e96f87c064c442e5aa5d47" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" dependencies = [ "migrations_internals", "proc-macro2", "quote", - "syn 3.0.9", ] [[package]] @@ -3288,9 +3201,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.4" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -3310,7 +3223,7 @@ dependencies = [ "equivalent", "event-listener 5.4.2", "futures-util", - "parking_lot 0.12.5", + "parking_lot", "portable-atomic", "smallvec", "tagptr", @@ -3336,6 +3249,27 @@ 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.5", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -3365,7 +3299,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.2", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -3445,7 +3379,7 @@ checksum = "e4e98dc3b890f6c23a0f9d3d491a2823d0dea0fa656302a13dd225fa924112a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.5", ] [[package]] @@ -3504,9 +3438,9 @@ dependencies = [ [[package]] name = "num_threads" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" dependencies = [ "libc", ] @@ -3551,9 +3485,9 @@ dependencies = [ [[package]] name = "opendal" -version = "0.59.5" +version = "0.59.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46081824c74bac9f68f0bc4408077f77b0e315c006514719c97af1c2227ac2d2" +checksum = "f950151f9587a51a7bed70a15fa0cff464eae96e41ae7499f97067bdafdf43eb" dependencies = [ "opendal-core", "opendal-service-fs", @@ -3562,9 +3496,9 @@ dependencies = [ [[package]] name = "opendal-core" -version = "0.59.5" +version = "0.59.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d5a635dfea36a7a0c3bbb2ca34c97af17f98dd69b0cdf31a1d436a346e0a12" +checksum = "a43405d217dfdfb543f58847336d3af672897dd1939bb7dcf314b63cf364f1c9" dependencies = [ "anyhow", "asyncband", @@ -3588,23 +3522,23 @@ dependencies = [ [[package]] name = "opendal-http-transport-reqwest" -version = "0.59.5" +version = "0.59.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc025e163c43993e30cf1829a30b791934e5b0f7cd4d7e03acc902a9efb7e8f5" +checksum = "401999057db611e592f883fcf2cbd6754ff37af587deaadd07b8c1398b2b6b06" dependencies = [ "bytes", "futures", "http 1.5.0", - "http-body", + "http-body 1.1.0", "opendal-core", "reqwest", ] [[package]] name = "opendal-service-fs" -version = "0.59.5" +version = "0.59.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70768b8f5af6996a494e784a1dc2a76b5cf890c422aff31ad094319bde98feb7" +checksum = "fb9caf04d6d38713299dd4abac984b95ab16ee20e1ff09160f495c5a64644083" dependencies = [ "bytes", "log", @@ -3616,9 +3550,9 @@ dependencies = [ [[package]] name = "opendal-service-s3" -version = "0.59.5" +version = "0.59.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0932321e27c712a30ef72db56b12f31e41e9f0d2d3841d63a91bc8075cd8f0ef" +checksum = "388b1d39b62535c62803754ebef89808859558697366dbedd0299345887ba461" dependencies = [ "base64 0.23.1", "bytes", @@ -3637,22 +3571,21 @@ dependencies = [ [[package]] name = "openidconnect" -version = "4.1.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0d098a59353679624d406825940139e2b2d554a52b64c9fb1b1bc42a11fe5c" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "dyn-clone", - "ed25519-dalek 3.0.0", - "getrandom 0.4.3", + "ed25519-dalek", "hmac 0.12.1", "http 1.5.0", "itertools", "log", "oauth2", - "p256 0.14.0", - "p384 0.14.0", + "p256", + "p384", "rand 0.8.8", "rsa", "serde", @@ -3673,7 +3606,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.2", + "bitflags 2.13.1", "cfg-if", "foreign-types", "libc", @@ -3694,9 +3627,9 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" @@ -3751,51 +3684,24 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", - "primeorder 0.13.6", + "ecdsa", + "elliptic-curve", + "primeorder", "sha2 0.10.9", ] -[[package]] -name = "p256" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" -dependencies = [ - "ecdsa 0.17.0", - "elliptic-curve 0.14.3", - "primefield", - "primeorder 0.14.0", - "sha2 0.11.1", -] - [[package]] name = "p384" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", - "primeorder 0.13.6", + "ecdsa", + "elliptic-curve", + "primeorder", "sha2 0.10.9", ] -[[package]] -name = "p384" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" -dependencies = [ - "ecdsa 0.17.0", - "elliptic-curve 0.14.3", - "fiat-crypto 0.3.0", - "primefield", - "primeorder 0.14.0", - "sha2 0.11.1", -] - [[package]] name = "parking" version = "2.2.1" @@ -3808,18 +3714,8 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ - "lock_api 0.4.14", - "parking_lot_core 0.9.12", -] - -[[package]] -name = "parking_lot" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9cf937ca4e1d0b7578784559063422fc39ae7c5a00f02471f2614d5486fd226" -dependencies = [ - "lock_api 0.5.0", - "parking_lot_core 0.10.0", + "lock_api", + "parking_lot_core", ] [[package]] @@ -3830,19 +3726,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "parking_lot_core" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cfff060068cc18aa7815010fcaf80c68c7fb9e85fc7f5c0f9bf142c21d19826" -dependencies = [ - "libc", - "redox_syscall 0.9.5", + "redox_syscall", "smallvec", "windows-link", ] @@ -3876,18 +3760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" dependencies = [ "inlinable_string", - "pear_codegen 0.2.9", - "yansi", -] - -[[package]] -name = "pear" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bdc582f5ece4663ba62cb9cc81b9f86aa23884ee92cddb1d5e75a94991119cb" -dependencies = [ - "inlinable_string", - "pear_codegen 0.3.0", + "pear_codegen", "yansi", ] @@ -3898,23 +3771,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" dependencies = [ "proc-macro2", - "proc-macro2-diagnostics 0.10.1", + "proc-macro2-diagnostics", "quote", "syn 2.0.119", ] -[[package]] -name = "pear_codegen" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b4262794e70dac544a759fd50088c6331e7a2ba4fc204b4b6e29c9b5257f9c4" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics 0.11.0", - "quote", - "syn 3.0.9", -] - [[package]] name = "pem" version = "3.0.6" @@ -3934,15 +3795,6 @@ dependencies = [ "base64ct", ] -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3951,20 +3803,19 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.9.5" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8db75153506001cb36ed924f08cadc862eb3f8c4d0d534139f29074769d7660" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" dependencies = [ "memchr", - "pest_stack_guard", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.9.5" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20aa17e11fb35664b67d5214a0afac57a360d231b6ec53d6eba0700b27d933b4" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" dependencies = [ "pest", "pest_generator", @@ -3972,36 +3823,26 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.5" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626ec5afccf44ccbfeb2b232316510bb55b4bf34d891afcb0fb07d2b17d7fe9d" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 3.0.9", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.9.5" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3f45b6caa65c1b08186832745159c2529994146340a7bf44168095469720f5d" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" dependencies = [ "pest", ] -[[package]] -name = "pest_stack_guard" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97e6cd2217057fa86b6eaa512917e58089d875b957a31e35e7ab729977e0a5f2" -dependencies = [ - "psm", - "stacker", -] - [[package]] name = "phc" version = "0.6.1" @@ -4087,9 +3928,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" @@ -4108,9 +3949,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der 0.7.10", - "pkcs8 0.10.2", - "spki 0.7.3", + "der", + "pkcs8", + "spki", ] [[package]] @@ -4119,18 +3960,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.10", - "spki 0.7.3", -] - -[[package]] -name = "pkcs8" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" -dependencies = [ - "der 0.8.3", - "spki 0.8.1", + "der", + "spki", ] [[package]] @@ -4179,9 +4010,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" @@ -4194,9 +4025,9 @@ dependencies = [ [[package]] name = "pq-sys" -version = "0.7.8" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297a95d6f2378891f949d9556723e15140e50d29eac07458dabcee8688995b1d" +checksum = "574ddd6a267294433f140b02a726b0640c43cf7c6f717084684aaa3b285aba61" dependencies = [ "libc", "pkg-config", @@ -4214,40 +4045,13 @@ dependencies = [ "num-traits", ] -[[package]] -name = "primefield" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" -dependencies = [ - "crypto-bigint 0.7.10", - "crypto-common 0.2.2", - "ff 0.14.0", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - [[package]] name = "primeorder" version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve 0.13.8", -] - -[[package]] -name = "primeorder" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" -dependencies = [ - "elliptic-curve 0.14.3", - "once_cell", - "primefield", - "serdect", - "wnaf", + "elliptic-curve", ] [[package]] @@ -4256,7 +4060,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.17+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -4281,33 +4085,12 @@ dependencies = [ "yansi", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df921e1ef974ebc8dd9effdb0c037a96928ae8cbf871438e02d6fd67204b595e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", - "version_check", -] - [[package]] name = "psl-types" version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" -[[package]] -name = "psm" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" -dependencies = [ - "cc", -] - [[package]] name = "publicsuffix" version = "2.3.0" @@ -4341,9 +4124,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.42.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", "serde", @@ -4383,7 +4166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" dependencies = [ "log", - "parking_lot 0.12.5", + "parking_lot", "scheduled-thread-pool", ] @@ -4410,9 +4193,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.3" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -4469,7 +4252,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.2", + "bitflags 2.13.1", ] [[package]] @@ -4478,16 +4261,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "redox_syscall" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" -dependencies = [ - "bitflags 2.13.2", + "bitflags 2.13.1", ] [[package]] @@ -4507,15 +4281,9 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.5", ] -[[package]] -name = "ref-swap" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09c30c54dffee5b40af088d5d50aa3455c91a0127164b51f0215efc4cb28fb3c" - [[package]] name = "regex" version = "1.13.1" @@ -4564,9 +4332,9 @@ dependencies = [ [[package]] name = "reqsign-aws-core" -version = "3.2.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38c902d8ec745bd9eadd1abbcbe227f4c1fc2926b2ec8929f89234af3114500" +checksum = "bac4749b7dfa7bfaccd01eb03e9dc795ed37e3f20d6f0f38e2c67ee85ad6bc86" dependencies = [ "bytes", "form_urlencoded", @@ -4585,9 +4353,9 @@ dependencies = [ [[package]] name = "reqsign-aws-v4" -version = "3.3.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "906bfd028f96bfe73fdb892ce26f3b3f99b4f30feb9bd024be58bd6657f68b2b" +checksum = "ff250f0fd0b913fbd565e405acc553da0f13bde30bfb5403178c9d0313cdc15f" dependencies = [ "bytes", "http 1.5.0", @@ -4600,30 +4368,31 @@ dependencies = [ [[package]] name = "reqsign-core" -version = "3.3.2" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fed85c436b6b920e4b36310f699a77771a91e38ae483df48447bd7d162671c" +checksum = "ff052daffb0599681c50f85c59e7236438976efe991ab864edd9f3b235501a0f" 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.1", + "sha2 0.11.0", "windows-sys 0.61.2", ] [[package]] name = "reqsign-file-read-tokio" -version = "3.0.7" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "586a8e198057773f9616901fd723a4bc186812b9765bc0f20368661d6169d906" +checksum = "b3235df90a6bca681aa47dd86f2393d122a6d77042aa8a7c81e218cd45c5bfc0" dependencies = [ "anyhow", "reqsign-core", @@ -4645,9 +4414,9 @@ dependencies = [ "futures-util", "h2", "http 1.5.0", - "http-body", + "http-body 1.1.0", "http-body-util", - "hyper", + "hyper 1.11.1", "hyper-rustls", "hyper-util", "js-sys", @@ -4655,7 +4424,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustls", + "rustls 0.23.44", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -4663,7 +4432,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.5", "tokio-util", "tower", "tower-http", @@ -4691,16 +4460,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "rfc6979" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" -dependencies = [ - "crypto-bigint 0.7.10", - "hmac 0.13.0", -] - [[package]] name = "ring" version = "0.17.14" @@ -4735,28 +4494,27 @@ dependencies = [ [[package]] name = "rocket" -version = "0.5.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17431360eb641a861453b57107ee1b425a799f49ec1b0fe9b8a45fe437447ce" +checksum = "a516907296a31df7dc04310e7043b61d71954d703b603cc6867a026d7e72d73f" dependencies = [ "async-stream", "async-trait", + "atomic 0.5.3", "binascii", "bytes", "either", "figment", "futures", - "http-body-util", - "hyper-util", "indexmap 2.14.2", "log", "memchr", "multer", "num_cpus", - "parking_lot 0.12.5", + "parking_lot", "pin-project-lite", + "rand 0.8.8", "ref-cast", - "ref-swap", "rocket_codegen", "rocket_http", "serde", @@ -4774,50 +4532,48 @@ dependencies = [ [[package]] name = "rocket_codegen" -version = "0.5.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352bd0b773ca6f5bea6cd18efc2fd7f04fcd34c75c242dad6ef1bbd729cc619d" +checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" dependencies = [ "devise", "glob", "indexmap 2.14.2", "proc-macro2", - "proc-macro2-diagnostics 0.11.0", "quote", "rocket_http", - "syn 3.0.9", + "syn 2.0.119", "unicode-xid", "version_check", ] [[package]] name = "rocket_http" -version = "0.5.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "847886ef963dff77126f110fd2b4401f5149440ab32973bef16c4a2cc83e525f" +checksum = "e274915a20ee3065f611c044bd63c40757396b6dbc057d6046aec27f14f882b9" dependencies = [ "cookie", "either", "futures", "http 0.2.12", - "hyper", + "hyper 0.14.32", "indexmap 2.14.2", "log", "memchr", - "pear 0.3.0", + "pear", "percent-encoding", "pin-project-lite", "ref-cast", - "rustls", - "rustls-pki-types", - "rustls-webpki", + "rustls 0.21.12", + "rustls-pemfile", "serde", "smallvec", "stable-pattern", "state", "time", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", "uncased", ] @@ -4854,21 +4610,22 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", - "signature 2.2.0", - "spki 0.7.3", + "signature", + "spki", "subtle", "zeroize", ] [[package]] name = "rsqlite-vfs" -version = "0.2.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ - "thiserror 2.0.21", + "hashbrown 0.16.1", + "thiserror 2.0.20", ] [[package]] @@ -4911,11 +4668,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.6" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.2", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -4924,24 +4681,36 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.46" +version = "0.21.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.15", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.8.5" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -4949,6 +4718,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -4960,19 +4738,19 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", "once_cell", - "rustls", + "rustls 0.23.44", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki", + "rustls-webpki 0.103.15", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -4981,15 +4759,25 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.2.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.16" +version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -5019,20 +4807,20 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.30" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "scheduled-thread-pool" -version = "0.2.8" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f304b290c05b371b2aa1e8718242ff0394530f9334ecedcb9ae51330f37fc1ba" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" dependencies = [ - "parking_lot 0.13.0", + "parking_lot", ] [[package]] @@ -5049,9 +4837,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.3" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -5071,30 +4859,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sec1" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct 0.2.0", - "der 0.7.10", + "base16ct", + "der", "generic-array", - "pkcs8 0.10.2", - "subtle", - "zeroize", -] - -[[package]] -name = "sec1" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" -dependencies = [ - "base16ct 1.0.0", - "ctutils", - "der 0.8.3", - "hybrid-array", + "pkcs8", "subtle", "zeroize", ] @@ -5105,7 +4889,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.2", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5175,14 +4959,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.5", ] [[package]] name = "serde_json" -version = "1.0.154" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -5222,9 +5006,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.2" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -5243,9 +5027,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.24.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" dependencies = [ "base64 0.23.1", "bs58", @@ -5255,7 +5039,7 @@ dependencies = [ "indexmap 2.14.2", "jiff", "schemars 0.9.0", - "schemars 1.2.3", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -5264,24 +5048,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.24.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" dependencies = [ "darling 0.24.1", "proc-macro2", "quote", - "syn 3.0.9", -] - -[[package]] -name = "serdect" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" -dependencies = [ - "base16ct 1.0.0", - "serde", + "syn 3.0.5", ] [[package]] @@ -5319,9 +5093,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -5373,16 +5147,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "signature" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "digest 0.11.3", - "rand_core 0.10.1", -] - [[package]] name = "simd-adler32" version = "0.3.10" @@ -5413,15 +5177,15 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.21", + "thiserror 2.0.20", "time", ] [[package]] name = "siphasher" -version = "1.0.4" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -5431,9 +5195,19 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] [[package]] name = "socket2" @@ -5459,11 +5233,11 @@ checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spinning_top" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf02766a1eb909bc7653557c66b384e44edc3ad979e6257c6173c65ea51bc7d" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" dependencies = [ - "lock_api 0.4.14", + "lock_api", ] [[package]] @@ -5473,27 +5247,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", -] - -[[package]] -name = "spki" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" -dependencies = [ - "base64ct", - "der 0.8.3", + "der", ] [[package]] name = "sqlite-wasm-rs" -version = "0.6.2" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36bd8e29bf08189a005b2df1df99823aa0861cabe9044e5f31ed54ca292ee618" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ "cc", + "js-sys", "rsqlite-vfs", + "wasm-bindgen", ] [[package]] @@ -5511,19 +5277,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "stacker" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.61.2", -] - [[package]] name = "state" version = "0.6.0" @@ -5571,9 +5324,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.9" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -5600,17 +5353,6 @@ 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.9", -] - [[package]] name = "syslog" version = "7.0.0" @@ -5629,7 +5371,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.2", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5674,11 +5416,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.21" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.21", + "thiserror-impl 2.0.20", ] [[package]] @@ -5694,13 +5436,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.21" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.5", ] [[package]] @@ -5755,9 +5497,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.5" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -5765,23 +5507,32 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.13.3" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.54.1" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", "mio", - "parking_lot 0.12.5", + "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -5794,16 +5545,26 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.5", ] [[package]] name = "tokio-rustls" -version = "0.26.6" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "rustls", + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls 0.23.44", "tokio", ] @@ -5832,9 +5593,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.20" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -5864,7 +5625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "serde_core", - "serde_spanned 1.1.2", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.15", @@ -5890,9 +5651,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.2+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -5913,21 +5674,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.17+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.2", - "toml_datetime 1.1.2+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.5+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -5972,12 +5733,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.13.2", + "bitflags 2.13.1", "bytes", "futures-core", "futures-util", "http 1.5.0", - "http-body", + "http-body 1.1.0", "http-body-util", "pin-project-lite", "tokio", @@ -6120,9 +5881,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.27" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" @@ -6169,24 +5930,15 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "utf8_iter" -version = "1.1.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" -dependencies = [ - "utf_types", -] - -[[package]] -name = "utf_types" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.29.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -6257,7 +6009,7 @@ dependencies = [ "pastey 0.2.3", "percent-encoding", "pico-args", - "rand 0.10.3", + "rand 0.10.2", "regex", "reqsign-aws-v4", "reqsign-core", @@ -6267,7 +6019,7 @@ dependencies = [ "rocket", "rocket_ws", "rpassword", - "rustls", + "rustls 0.23.44", "semver", "serde", "serde_json", @@ -6318,9 +6070,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ "try-lock", ] @@ -6342,11 +6094,12 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.130" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", + "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", @@ -6354,20 +6107,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.80" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", - "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.130" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6375,22 +6127,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.130" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.130" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -6410,9 +6162,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.107" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -6498,9 +6250,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.11" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -6793,18 +6545,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wnaf" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" -dependencies = [ - "ff 0.14.0", - "group 0.14.0", - "hybrid-array", - "primefield", -] - [[package]] name = "writeable" version = "0.6.4" @@ -6830,9 +6570,9 @@ dependencies = [ [[package]] name = "xattr" -version = "1.7.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", "rustix", @@ -6861,9 +6601,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -6872,14 +6612,14 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.4" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", - "synstructure 0.14.0", + "syn 2.0.119", + "synstructure", ] [[package]] @@ -6897,18 +6637,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.63" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.63" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", @@ -6926,21 +6666,21 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", - "synstructure 0.14.0", + "syn 2.0.119", + "synstructure", ] [[package]] name = "zeroize" -version = "1.9.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] @@ -6969,9 +6709,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.9" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -6980,20 +6720,20 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.7" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.5", ] [[package]] name = "zlib-rs" -version = "0.6.8" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" @@ -7003,27 +6743,27 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" -version = "0.14.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" +checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "8.2.0" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" +checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.1.2+zstd.1.5.7" +version = "2.1.0+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 1717ec63..d3a3d5e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace.package] edition = "2024" -rust-version = "1.97.1" +rust-version = "1.96.1" license = "AGPL-3.0-only" repository = "https://github.com/dani-garcia/vaultwarden" publish = false @@ -66,7 +66,7 @@ syslog = "7.0.0" macros = { path = "./macros" } # Logging -log = "0.4.35" +log = "0.4.34" fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] } # We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs tracing = { version = "0.1.44", features = ["log"] } @@ -77,10 +77,10 @@ dotenvy = { version = "0.15.7", default-features = false } # Numerical libraries num-traits = "0.2.19" num-derive = "0.5.1" -bigdecimal = "0.4.11" +bigdecimal = "0.4.10" # Web framework -rocket = { version = "0.5.2", default-features = false, features = ["json", "tls"] } +rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] } rocket_ws = { version = "0.1.1" } # WebSockets libraries @@ -91,7 +91,7 @@ dashmap = "6.2.1" # Async futures futures = "0.3.34" -tokio = { version = "1.54.1", features = [ +tokio = { version = "1.53.1", features = [ "fs", "io-util", "net", @@ -100,14 +100,14 @@ tokio = { version = "1.54.1", features = [ "signal", "time", ] } -tokio-util = { version = "0.7.20", features = ["compat"] } +tokio-util = { version = "0.7.19", features = ["compat"] } # A generic serialization/deserialization framework serde = { version = "1.0.229", features = ["derive"] } -serde_json = "1.0.154" +serde_json = "1.0.151" # A safe, extensible ORM and Query builder -diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric", "64-column-tables"] } +diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] } diesel_migrations = "2.3.2" derive_more = { version = "2.1.1", features = [ @@ -123,13 +123,13 @@ diesel-derive-newtype = "2.1.3" libsqlite3-sys = { version = "0.38.2", optional = true } # Crypto-related libraries -rand = "0.10.3" +rand = "0.10.2" ring = "0.17.14" -rustls = { version = "0.23.46", features = ["ring", "std"], default-features = false } +rustls = { version = "0.23.44", features = ["ring", "std"], default-features = false } subtle = "2.6.1" # UUID generation -uuid = { version = "1.29.0", features = ["v4"] } +uuid = { version = "1.26.0", 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.1.0", default-features = false, features = ["rust_crypto", "use_pem"] } +jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust_crypto", "use_pem"] } # TOTP library totp-lite = "2.0.1" @@ -201,10 +201,10 @@ reqwest = { version = "0.13.5", default-features = false, features = [ "socks", "system-proxy", ] } -hickory-resolver = "0.26.3" +hickory-resolver = "0.26.2" # Favicon extraction libraries -html5gum = "0.9.0" +html5gum = "0.8.4" regex = { version = "1.13.1", default-features = false, features = [ "perf", "std", @@ -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.1.0", features = ["async"] } +cached = { version = "4.0.0", features = ["async"] } # Used for custom short lived cookie jar during favicon extraction cookie = "0.18.2" @@ -235,7 +235,7 @@ governor = "0.10.4" ipnet = "2.12.2" # OIDC for SSO -openidconnect = { version = "4.1.0", default-features = false } +openidconnect = { version = "4.0.1", default-features = false } moka = { version = "0.12.16", features = ["future"] } # Check client versions for specific features. @@ -257,21 +257,21 @@ rpassword = "7.5.4" grass_compiler = { version = "0.13.4", default-features = false } # File are accessed through Apache OpenDAL -opendal = { version = "0.59.5", default-features = false, features = ["services-fs"] } -opendal-http-transport-reqwest = { version = "0.59.5", default-features = false, features = ["rustls-no-provider"], optional = true } +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 } # For retrieving AWS credentials, including temporary SSO credentials -aws-config = { version = "1.13.0", optional = true, default-features = false, features = [ +aws-config = { version = "1.12.0", optional = true, default-features = false, features = [ "behavior-version-latest", "credentials-process", "rt-tokio", "sso", ] } -aws-credential-types = { version = "1.3.1", optional = true } -aws-smithy-runtime-api = { version = "1.19.2", optional = true } +aws-credential-types = { version = "1.3.0", optional = true } +aws-smithy-runtime-api = { version = "1.16.0", optional = true } http = { version = "1.5.0", optional = true } -reqsign-aws-v4 = { version = "3.3.1", optional = true } -reqsign-core = { version = "3.3.2", optional = true } +reqsign-aws-v4 = { version = "3.3.0", optional = true } +reqsign-core = { version = "3.3.1", optional = true } # Strip debuginfo from the release builds # The debug symbols are to provide better panic traces diff --git a/docker/DockerSettings.yaml b/docker/DockerSettings.yaml index 4ef24c9d..6ae6e9a8 100644 --- a/docker/DockerSettings.yaml +++ b/docker/DockerSettings.yaml @@ -1,10 +1,10 @@ --- -vault_version: "v2026.10.1" -vault_image_digest: "sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52" -# Cross Compile Docker Helper Scripts v1.10.0 +vault_version: "v2026.7.0" +vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c" +# 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 -xx_image_digest: "sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6" +xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707" # The `rust_version` variable is extracted from `rust-toolchain.toml` # rust_version: x.yy.z # Rust version to be used debian_version: trixie # Debian release name to be used diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine index 95e8784d..a91deb07 100644 --- a/docker/Dockerfile.alpine +++ b/docker/Dockerfile.alpine @@ -19,23 +19,23 @@ # - 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.10.1 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1 -# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52] +# $ 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] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 -# [docker.io/vaultwarden/web-vault:v2026.10.1] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c +# [docker.io/vaultwarden/web-vault:v2026.7.0] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault ########################## 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.99.1 AS build_amd64 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.1 AS build_arm64 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.1 AS build_armv7 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.1 AS build_armv6 +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 ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 621cfafe..b8490609 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -19,24 +19,24 @@ # - 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.10.1 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1 -# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52] +# $ 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] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 -# [docker.io/vaultwarden/web-vault:v2026.10.1] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c +# [docker.io/vaultwarden/web-vault:v2026.7.0] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault ########################## Cross Compile Docker Helper Scripts ########################## ## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts ## And these bash scripts do not have any significant difference if at all -FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6 AS xx +FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 -FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.1-slim-trixie AS build +FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.98.1-slim-trixie AS build # hadolint ignore=DL3067 COPY --from=xx / / ARG TARGETARCH @@ -101,17 +101,9 @@ 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="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ + XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ + export XX_RUSTFLAGS; \ 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 @@ -131,17 +123,9 @@ 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="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ + XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ + export XX_RUSTFLAGS; \ 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 2dc175b0..633d6955 100644 --- a/docker/Dockerfile.j2 +++ b/docker/Dockerfile.j2 @@ -32,17 +32,9 @@ # 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="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ + XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \ + export XX_RUSTFLAGS; \ 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 diff --git a/docker/README.md b/docker/README.md index 3bbc31e1..d64d5789 100644 --- a/docker/README.md +++ b/docker/README.md @@ -15,7 +15,6 @@ Some unsupported platforms for Debian based images. These are not built and test - 386 (linux/386) - ppc64le (linux/ppc64le) - s390x (linux/s390x) -- riscv64 (linux/riscv64) To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.
This ensures the container build process can run binaries from other architectures.
diff --git a/docker/docker-bake.hcl b/docker/docker-bake.hcl index 30902bba..c27c39a6 100644 --- a/docker/docker-bake.hcl +++ b/docker/docker-bake.hcl @@ -148,12 +148,6 @@ target "debian-s390x" { platforms = ["linux/s390x"] tags = generate_tags("", "-s390x") } - -target "debian-riscv64" { - inherits = ["debian"] - platforms = ["linux/riscv64"] - tags = generate_tags("", "-riscv64") -} // ==== End of unsupported Debian architecture targets === // A Group to build all platforms individually for local testing diff --git a/macros/Cargo.toml b/macros/Cargo.toml index ee9fd206..34cb913d 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -14,7 +14,7 @@ proc-macro = true [dependencies] quote = "1.0.47" -syn = "3.0.9" +syn = "3.0.5" [lints] workspace = true 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 deleted file mode 100644 index e69de29b..00000000 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 deleted file mode 100644 index d25396cf..00000000 --- a/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index e69de29b..00000000 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 deleted file mode 100644 index d25396cf..00000000 --- a/migrations/mysql/2026-09-02-120000_add_key_id/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/down.sql b/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/down.sql deleted file mode 100644 index 27c33623..00000000 --- a/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE org_policies DROP COLUMN revision_date; diff --git a/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/up.sql b/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/up.sql deleted file mode 100644 index 97a84117..00000000 --- a/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/up.sql +++ /dev/null @@ -1,7 +0,0 @@ --- DATETIME (not TIMESTAMP) to match this repo's convention for revision_date --- columns elsewhere, and to avoid MySQL's implicit session-timezone --- conversion and 2038 range limit on TIMESTAMP. -ALTER TABLE org_policies -ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00'; - -UPDATE org_policies SET revision_date = UTC_TIMESTAMP(); diff --git a/migrations/mysql/2026-10-08-120000_cipher_data_longtext/down.sql b/migrations/mysql/2026-10-08-120000_cipher_data_longtext/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/mysql/2026-10-08-120000_cipher_data_longtext/up.sql b/migrations/mysql/2026-10-08-120000_cipher_data_longtext/up.sql deleted file mode 100644 index 09abbaee..00000000 --- a/migrations/mysql/2026-10-08-120000_cipher_data_longtext/up.sql +++ /dev/null @@ -1,3 +0,0 @@ --- A blob-encrypted cipher keeps all of its content in `data`, up to 500,000 characters, so the --- 64 KiB of TEXT is too small. Like upstream, which uses LONGTEXT. -ALTER TABLE ciphers MODIFY data LONGTEXT NOT NULL; diff --git a/migrations/mysql/2026-10-09-120000_user_crypto_v2/down.sql b/migrations/mysql/2026-10-09-120000_user_crypto_v2/down.sql deleted file mode 100644 index 49700440..00000000 --- a/migrations/mysql/2026-10-09-120000_user_crypto_v2/down.sql +++ /dev/null @@ -1,5 +0,0 @@ -DROP TABLE IF EXISTS user_signature_key_pairs; - -ALTER TABLE users DROP COLUMN signed_public_key; -ALTER TABLE users DROP COLUMN security_state; -ALTER TABLE users DROP COLUMN security_version; diff --git a/migrations/mysql/2026-10-09-120000_user_crypto_v2/up.sql b/migrations/mysql/2026-10-09-120000_user_crypto_v2/up.sql deleted file mode 100644 index 73edfa6a..00000000 --- a/migrations/mysql/2026-10-09-120000_user_crypto_v2/up.sql +++ /dev/null @@ -1,14 +0,0 @@ -ALTER TABLE users ADD COLUMN signed_public_key TEXT; -ALTER TABLE users ADD COLUMN security_state TEXT; -ALTER TABLE users ADD COLUMN security_version INTEGER; - -CREATE TABLE user_signature_key_pairs ( - uuid CHAR(36) NOT NULL PRIMARY KEY, - user_uuid CHAR(36) NOT NULL UNIQUE, - signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44 - signing_key TEXT NOT NULL, - verifying_key TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE -); diff --git a/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql b/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql deleted file mode 100644 index 85ebcbfb..00000000 --- a/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql +++ /dev/null @@ -1,22 +0,0 @@ --- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the --- versions before this one don't know. So while there are any, this fails on the duplicate key below, before anything --- is dropped: change their role first, or restore the backup taken before the upgrade. --- A failed attempt leaves the temporary table on this connection. Clear it before retrying. -DROP TEMPORARY TABLE IF EXISTS custom_role_downgrade; -CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY); -INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade'); -INSERT INTO custom_role_downgrade - SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade' - FROM users_organizations WHERE atype = 4 LIMIT 1; -DROP TEMPORARY TABLE custom_role_downgrade; - -ALTER TABLE users_organizations - DROP COLUMN manage_users, - DROP COLUMN manage_groups, - DROP COLUMN manage_policies, - DROP COLUMN create_new_collections, - DROP COLUMN edit_any_collection, - DROP COLUMN delete_any_collection, - DROP COLUMN access_event_logs, - DROP COLUMN access_import_export, - DROP COLUMN access_reports; diff --git a/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql b/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql deleted file mode 100644 index d28b0d64..00000000 --- a/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql +++ /dev/null @@ -1,13 +0,0 @@ --- The granular permissions of the Custom member role. They only take effect on a Custom membership --- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any. --- A single statement, so an interrupted upgrade cannot leave only some of the columns behind. -ALTER TABLE users_organizations - ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/mysql/2026-11-02-120000_reset_user_key_id/down.sql b/migrations/mysql/2026-11-02-120000_reset_user_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/mysql/2026-11-02-120000_reset_user_key_id/up.sql b/migrations/mysql/2026-11-02-120000_reset_user_key_id/up.sql deleted file mode 100644 index eeffa854..00000000 --- a/migrations/mysql/2026-11-02-120000_reset_user_key_id/up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Key rotations before this kept the id of the old user key, so every new --- cipher was refused from then on. Stale ids can't be told apart, so clear --- all of them; clients record their current key's id again when it's empty. -UPDATE users SET key_id = NULL; diff --git a/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/down.sql b/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/down.sql deleted file mode 100644 index 89e12c15..00000000 --- a/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users DROP COLUMN v2_upgrade_token; -ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/up.sql b/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/up.sql deleted file mode 100644 index 45c58491..00000000 --- a/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; -ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token 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 deleted file mode 100644 index e69de29b..00000000 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 deleted file mode 100644 index d25396cf..00000000 --- a/migrations/postgresql/2026-09-02-120000_add_key_id/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/down.sql b/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/down.sql deleted file mode 100644 index 27c33623..00000000 --- a/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE org_policies DROP COLUMN revision_date; diff --git a/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/up.sql b/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/up.sql deleted file mode 100644 index 44229de2..00000000 --- a/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/up.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Backfill via `now() AT TIME ZONE 'utc'` rather than a DEFAULT of now(): --- assigning timestamptz now() into a naive TIMESTAMP column casts through --- the server's TimeZone GUC, so a DEFAULT now() would store local wall-clock --- instead of UTC on non-UTC servers, unlike every other naive-UTC timestamp --- column in this schema. -ALTER TABLE org_policies -ADD COLUMN revision_date TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00'; - -UPDATE org_policies SET revision_date = (now() AT TIME ZONE 'utc'); diff --git a/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/down.sql b/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/up.sql b/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/up.sql deleted file mode 100644 index aab2e86a..00000000 --- a/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/up.sql +++ /dev/null @@ -1,3 +0,0 @@ --- TEXT has no size limit here, only MySQL needed the change. --- PostgreSQL rejects a migration that is only comments ("Received an empty query"), hence the no-op. -SELECT 1; diff --git a/migrations/postgresql/2026-10-09-120000_user_crypto_v2/down.sql b/migrations/postgresql/2026-10-09-120000_user_crypto_v2/down.sql deleted file mode 100644 index 49700440..00000000 --- a/migrations/postgresql/2026-10-09-120000_user_crypto_v2/down.sql +++ /dev/null @@ -1,5 +0,0 @@ -DROP TABLE IF EXISTS user_signature_key_pairs; - -ALTER TABLE users DROP COLUMN signed_public_key; -ALTER TABLE users DROP COLUMN security_state; -ALTER TABLE users DROP COLUMN security_version; diff --git a/migrations/postgresql/2026-10-09-120000_user_crypto_v2/up.sql b/migrations/postgresql/2026-10-09-120000_user_crypto_v2/up.sql deleted file mode 100644 index d3186ef0..00000000 --- a/migrations/postgresql/2026-10-09-120000_user_crypto_v2/up.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE users ADD COLUMN signed_public_key TEXT; -ALTER TABLE users ADD COLUMN security_state TEXT; -ALTER TABLE users ADD COLUMN security_version INTEGER; - -CREATE TABLE user_signature_key_pairs ( - uuid CHAR(36) NOT NULL PRIMARY KEY, - user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE, - signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44 - signing_key TEXT NOT NULL, - verifying_key TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL -); diff --git a/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql b/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql deleted file mode 100644 index 0cc5dd03..00000000 --- a/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the --- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their --- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does. -CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY); -INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade'); -INSERT INTO custom_role_downgrade - SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade' - FROM users_organizations WHERE atype = 4 LIMIT 1; -DROP TABLE custom_role_downgrade; - -ALTER TABLE users_organizations - DROP COLUMN manage_users, - DROP COLUMN manage_groups, - DROP COLUMN manage_policies, - DROP COLUMN create_new_collections, - DROP COLUMN edit_any_collection, - DROP COLUMN delete_any_collection, - DROP COLUMN access_event_logs, - DROP COLUMN access_import_export, - DROP COLUMN access_reports; diff --git a/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql b/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql deleted file mode 100644 index d28b0d64..00000000 --- a/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql +++ /dev/null @@ -1,13 +0,0 @@ --- The granular permissions of the Custom member role. They only take effect on a Custom membership --- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any. --- A single statement, so an interrupted upgrade cannot leave only some of the columns behind. -ALTER TABLE users_organizations - ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/postgresql/2026-11-02-120000_reset_user_key_id/down.sql b/migrations/postgresql/2026-11-02-120000_reset_user_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/postgresql/2026-11-02-120000_reset_user_key_id/up.sql b/migrations/postgresql/2026-11-02-120000_reset_user_key_id/up.sql deleted file mode 100644 index eeffa854..00000000 --- a/migrations/postgresql/2026-11-02-120000_reset_user_key_id/up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Key rotations before this kept the id of the old user key, so every new --- cipher was refused from then on. Stale ids can't be told apart, so clear --- all of them; clients record their current key's id again when it's empty. -UPDATE users SET key_id = NULL; diff --git a/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/down.sql b/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/down.sql deleted file mode 100644 index 89e12c15..00000000 --- a/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users DROP COLUMN v2_upgrade_token; -ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/up.sql b/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/up.sql deleted file mode 100644 index 45c58491..00000000 --- a/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; -ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token 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 deleted file mode 100644 index e69de29b..00000000 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 deleted file mode 100644 index d25396cf..00000000 --- a/migrations/sqlite/2026-09-02-120000_add_key_id/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/down.sql b/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/up.sql b/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/up.sql deleted file mode 100644 index 86595711..00000000 --- a/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/up.sql +++ /dev/null @@ -1,6 +0,0 @@ --- SQLite forbids non-constant defaults in ALTER TABLE ... ADD COLUMN, so add --- the column with a constant placeholder and backfill separately. -ALTER TABLE org_policies -ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00'; - -UPDATE org_policies SET revision_date = CURRENT_TIMESTAMP; diff --git a/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/down.sql b/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/up.sql b/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/up.sql deleted file mode 100644 index f18eb816..00000000 --- a/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/up.sql +++ /dev/null @@ -1 +0,0 @@ --- TEXT has no size limit here, only MySQL needed the change diff --git a/migrations/sqlite/2026-10-09-120000_user_crypto_v2/down.sql b/migrations/sqlite/2026-10-09-120000_user_crypto_v2/down.sql deleted file mode 100644 index 49700440..00000000 --- a/migrations/sqlite/2026-10-09-120000_user_crypto_v2/down.sql +++ /dev/null @@ -1,5 +0,0 @@ -DROP TABLE IF EXISTS user_signature_key_pairs; - -ALTER TABLE users DROP COLUMN signed_public_key; -ALTER TABLE users DROP COLUMN security_state; -ALTER TABLE users DROP COLUMN security_version; diff --git a/migrations/sqlite/2026-10-09-120000_user_crypto_v2/up.sql b/migrations/sqlite/2026-10-09-120000_user_crypto_v2/up.sql deleted file mode 100644 index c15e5b5e..00000000 --- a/migrations/sqlite/2026-10-09-120000_user_crypto_v2/up.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE users ADD COLUMN signed_public_key TEXT; -ALTER TABLE users ADD COLUMN security_state TEXT; -ALTER TABLE users ADD COLUMN security_version INTEGER; - -CREATE TABLE user_signature_key_pairs ( - uuid TEXT NOT NULL PRIMARY KEY, - user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE, - signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44 - signing_key TEXT NOT NULL, - verifying_key TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL -); diff --git a/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql b/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql deleted file mode 100644 index 11c30bec..00000000 --- a/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the --- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their --- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does. -CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY); -INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade'); -INSERT INTO custom_role_downgrade - SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade' - FROM users_organizations WHERE atype = 4 LIMIT 1; -DROP TABLE custom_role_downgrade; - -ALTER TABLE users_organizations DROP COLUMN manage_users; -ALTER TABLE users_organizations DROP COLUMN manage_groups; -ALTER TABLE users_organizations DROP COLUMN manage_policies; -ALTER TABLE users_organizations DROP COLUMN create_new_collections; -ALTER TABLE users_organizations DROP COLUMN edit_any_collection; -ALTER TABLE users_organizations DROP COLUMN delete_any_collection; -ALTER TABLE users_organizations DROP COLUMN access_event_logs; -ALTER TABLE users_organizations DROP COLUMN access_import_export; -ALTER TABLE users_organizations DROP COLUMN access_reports; diff --git a/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql b/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql deleted file mode 100644 index c708c693..00000000 --- a/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql +++ /dev/null @@ -1,11 +0,0 @@ --- The granular permissions of the Custom member role. They only take effect on a Custom membership --- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any. -ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT 0; -- FALSE -ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT 0; -- FALSE diff --git a/migrations/sqlite/2026-11-02-120000_reset_user_key_id/down.sql b/migrations/sqlite/2026-11-02-120000_reset_user_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/sqlite/2026-11-02-120000_reset_user_key_id/up.sql b/migrations/sqlite/2026-11-02-120000_reset_user_key_id/up.sql deleted file mode 100644 index eeffa854..00000000 --- a/migrations/sqlite/2026-11-02-120000_reset_user_key_id/up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Key rotations before this kept the id of the old user key, so every new --- cipher was refused from then on. Stale ids can't be told apart, so clear --- all of them; clients record their current key's id again when it's empty. -UPDATE users SET key_id = NULL; diff --git a/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/down.sql b/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/down.sql deleted file mode 100644 index 89e12c15..00000000 --- a/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users DROP COLUMN v2_upgrade_token; -ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/up.sql b/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/up.sql deleted file mode 100644 index 45c58491..00000000 --- a/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; -ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; diff --git a/playwright/README.md b/playwright/README.md index cd18bebd..000725d7 100644 --- a/playwright/README.md +++ b/playwright/README.md @@ -2,7 +2,7 @@ This allows running integration tests using [Playwright](https://playwright.dev/). \ -It uses its own [test.env](./test.env) with different ports to not collide with a running dev instance. +It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance. ## Install @@ -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 running (states are not impacted by the tests): +If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests): ```bash PW_KEEP_SERVICE_RUNNING=true npx playwright test diff --git a/playwright/tests/emergency.spec.ts b/playwright/tests/emergency.spec.ts deleted file mode 100644 index 4c836986..00000000 --- a/playwright/tests/emergency.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -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/playwright/tests/login.spec.ts b/playwright/tests/login.spec.ts index bc229c43..194976ea 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, recoveryCodes } from './setups/2fa'; +import { activateTOTP, disableTOTP } from './setups/2fa'; let users = utils.loadEnv(); let totp; @@ -31,42 +31,21 @@ test('Authenticator 2fa', async ({ page }) => { await utils.logout(test, page, users.user1); - 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 () => { + let timestamp = Date.now(); // Needed to use the next token + timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; + 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/); - - 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/); }); -}); + await disableTOTP(test, page, users.user1); +}); diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts index 0b1711a1..d430d053 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -4,24 +4,6 @@ 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(); @@ -39,6 +21,7 @@ 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 11b39763..3d3990e9 100644 --- a/playwright/tests/setups/user.ts +++ b/playwright/tests/setups/user.ts @@ -2,7 +2,6 @@ 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'; @@ -44,7 +43,6 @@ export async function logUser( mailBuffer ?: MailBuffer, mail2fa?: boolean, notNewDevice?: boolean, - totp?: OTPAuth.TOTP, } = {} ) { await test.step(`Log user ${user.email}`, async () => { @@ -57,23 +55,11 @@ 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 || options.totp ){ - let code; - + if( options.mail2fa ){ await test.step('2FA check', async () => { await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - - 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); - } - + let code = await retrieveEmailCode(test, page, options.mailBuffer); await page.getByLabel(/Verification code/).fill(code); - await page.getByRole('button', { name: 'Continue' }).click(); }); } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index b195f645..2be20926 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.99.1" +channel = "1.98.1" components = [ "rustfmt", "clippy" ] profile = "minimal" diff --git a/src/api/admin.rs b/src/api/admin.rs index 40df44ae..4bdf8e71 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, env, sync::LazyLock}; +use std::{env, sync::LazyLock}; use reqwest::Method; use rocket::{ @@ -17,7 +17,7 @@ use crate::{ api::{ ApiResult, EmptyResult, JsonResult, Notify, core::{log_event, two_factor}, - push_logout_to_relay, unregister_push_device, + unregister_push_device, }, auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims}, config::ConfigBuilder, @@ -26,7 +26,6 @@ use crate::{ models::{ Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId, MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, - custom_roles_enabled, }, }, error::{Error, MapResult}, @@ -202,15 +201,12 @@ fn post_admin_login( // If the token is invalid, redirect to login page if validate_token(&data.token) { // If the token received is valid, generate JWT and save it as a cookie - let claims = match generate_admin_claims() { - Ok(claims) => claims, - Err(e) => return Err(AdminResponse::Ok(render_admin_login(Some(e.message()), redirect.as_deref()))), - }; + let claims = generate_admin_claims(); let jwt = encode_jwt(&claims); let cookie = Cookie::build((COOKIE_NAME, jwt)) .path(admin_path()) - .max_age(time::Duration::minutes(i64::from(CONFIG.admin_session_lifetime()))) + .max_age(time::Duration::minutes(CONFIG.admin_session_lifetime())) .same_site(SameSite::Strict) .http_only(true) .secure(secure.https); @@ -257,7 +253,6 @@ struct AdminTemplateData { logged_in: bool, urlpath: String, sso_enabled: bool, - custom_roles_enabled: bool, } impl AdminTemplateData { @@ -268,7 +263,6 @@ impl AdminTemplateData { logged_in: true, urlpath: CONFIG.domain_path(), sso_enabled: CONFIG.sso_enabled(), - custom_roles_enabled: custom_roles_enabled(), } } @@ -393,18 +387,6 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult = Membership::find_confirmed_by_user(&u.uuid, &conn) - .await - .into_iter() - .map(|member| (member.org_uuid.to_string(), member.atype)) - .collect(); - for org in usr["organizations"].as_array_mut().into_iter().flatten() { - if let Some(atype) = org["id"].as_str().and_then(|org_id| roles.get(org_id)) { - org["type"] = json!(atype); - } - } - users_json.push(usr); } @@ -434,21 +416,13 @@ async fn get_user_json(user_id: UserId, _token: AdminToken, conn: DbConn) -> Jso } #[post("/users//delete", format = "application/json")] -async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult { +async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult { let user = get_user_or_404(&user_id, &conn).await?; - // Get the membership records and push devices before deleting the actual user + // Get the membership records before deleting the actual user let memberships = Membership::find_any_state_by_user(&user_id, &conn).await; - let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user_id, &conn).await; let res = user.delete(&conn).await; - if res.is_ok() { - nt.send_logout(&user, None, &conn).await; - if has_push_device { - push_logout_to_relay(&user, None, None); - } - } - for membership in memberships { log_event( EventType::OrganizationUserDeleted, @@ -534,7 +508,6 @@ 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 @@ -580,14 +553,10 @@ async fn update_membership_type(data: Json, token: AdminToke err!("The specified user isn't member of the organization") }; - let new_type = match MembershipType::from_str(&data.user_type.into_string()) { - // Unlike the clients, the admin panel tells the Custom and the legacy Manager role apart. Unless Custom roles - // are enabled, a Custom role is a Manager here as well, but an existing Custom member is kept as one. - Some(MembershipType::Custom) if !custom_roles_enabled() && !member_to_edit.has_type(MembershipType::Custom) => { - MembershipType::Manager - } - Some(new_type) => new_type, - None => err!("Invalid type"), + let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) { + new_type as i32 + } else { + err!("Invalid type") }; if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner { @@ -597,14 +566,7 @@ async fn update_membership_type(data: Json, token: AdminToke } } - // A Manager only becomes a Custom member if that changes nothing they may do - if new_type == MembershipType::Custom - && let Some(loss) = member_to_edit.custom_role_conversion_loss(&conn).await - { - err!(loss) - } - - member_to_edit.set_type(new_type); + member_to_edit.atype = new_type; // This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; @@ -816,8 +778,6 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A .into_keys() .collect(); - let invalid_env_vars = crate::util::INVALID_ENV_VARS.lock().unwrap().join(", "); - let diagnostics_json = json!({ "dns_resolved": dns_resolved, "current_release": VERSION, @@ -840,9 +800,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A "db_version": get_sql_server_version(&conn).await, "admin_url": format!("{}/diagnostics", admin_url()), "overrides": &CONFIG.get_overrides().join(", "), - "config_file_status": &CONFIG.get_config_file_status(), "template_overrides": check_template_overrides().join(", "), - "invalid_env_vars": invalid_env_vars, "invalid_feature_flags": invalid_feature_flags, "host_arch": env::consts::ARCH, "host_os": env::consts::OS, @@ -947,23 +905,23 @@ mod tests { fn validate_web_vault_compare() { // web_vault_compare(active, latest) // Test normal versions - 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); + 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); // Test patched/+build.n versions // Newer latest version - 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); + 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); // Equal versions - 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); + 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); // Newer active version - 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); + 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); } } diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index b2dd5845..69be1334 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1,7 +1,6 @@ use std::collections::HashSet; use chrono::Utc; -use num_traits::FromPrimitive; use rocket::{ http::Status, request::{FromRequest, Outcome, Request}, @@ -12,9 +11,9 @@ use serde_json::Value; use crate::{ CONFIG, api::{ - AnonymousNotify, ApiResult, EmptyResult, JsonResult, LogOutReason, Notify, PasswordOrOtpData, + AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::{accept_org_invite, log_user_event, two_factor::email}, - master_password_policy, push_logout_to_relay, register_push_device, unregister_push_device, + master_password_policy, register_push_device, unregister_push_device, }, auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email}, crypto, @@ -22,9 +21,8 @@ use crate::{ DbConn, DbPool, models::{ AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, - EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership, - MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, SignatureAlgorithm, - User, UserId, UserKdfType, UserSignatureKeyPair, + EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, + OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType, }, }, mail, @@ -32,8 +30,8 @@ use crate::{ }; use super::{ - ciphers::{CipherData, rotate_cipher_data, validate_rotated_cipher}, - sends::SendData, + ciphers::{CipherData, update_cipher_from_data}, + sends::{SendData, update_send_from_data}, }; pub fn routes() -> Vec { @@ -43,16 +41,11 @@ pub fn routes() -> Vec { post_profile, put_avatar, get_public_keys, - get_account_public_keys, - get_keys, post_keys, post_password, post_set_password, post_kdf, - get_key_rotation_data, post_rotatekey, - post_user_key, - post_rotate_user_keys, post_sstamp, post_email_token, post_email, @@ -64,6 +57,7 @@ pub fn routes() -> Vec { delete_account, revision_date, password_hint, + post_prelogin, verify_password, post_api_key, rotate_api_key, @@ -97,15 +91,6 @@ 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 { @@ -117,13 +102,13 @@ pub struct RegisterData { #[serde(alias = "userAsymmetricKeys")] keys: Option, - // Supersedes `keys`, and the only way a v2 account can be registered. - account_keys: Option, - 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, @@ -131,6 +116,36 @@ pub struct RegisterData { org_invite_token: Option, } +impl RegisterData { + fn hash(&self) -> String { + self.compat.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned() + } + + fn kdf(&self) -> &KDFData { + self.compat.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf) + } + + fn key(&self) -> String { + self.compat.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned() + } + + // When comparing with salt, email need to be normalized: + // - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171 + fn unprocessable(&self) -> bool { + let mut unprocessable = false; + *self.compat.fold( + |_| &false, + |rdcu| { + let email = self.email.trim().to_lowercase(); + unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf + || rdcu.master_password_authentication.salt != email + || rdcu.master_password_unlock.salt != email; + &unprocessable + }, + ) + } +} + #[derive(Debug, Deserialize)] struct RegisterDataOld { #[serde(flatten)] @@ -146,9 +161,7 @@ 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, } @@ -170,42 +183,6 @@ impl RegisterDataCompat { RegisterDataCompat::RegisterDataCur(rdcu) => fcu(rdcu), } } - - fn hash(&self) -> String { - self.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned() - } - - fn kdf(&self) -> &KDFData { - self.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf) - } - - fn key(&self) -> String { - self.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned() - } - - /// The id of the user key, which only the current format carries. - fn key_id(&self) -> Option { - match self { - RegisterDataCompat::RegisterDataOld(_) => None, - RegisterDataCompat::RegisterDataCur(rdcu) => rdcu.master_password_unlock.contained_key_id.clone(), - } - } - - // When comparing with salt, email need to be normalized: - // - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171 - fn unprocessable(&self, email: &str) -> bool { - let mut unprocessable = false; - *self.fold( - |_| &false, - |rdcu| { - let email = email.trim().to_lowercase(); - unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf - || rdcu.master_password_authentication.salt != email - || rdcu.master_password_unlock.salt != email; - &unprocessable - }, - ) - } } #[derive(Debug, Deserialize)] @@ -215,224 +192,13 @@ struct KeysData { public_key: String, } -/// Upstream's implicit `[Required]` on a non-nullable string: present and not blank. -fn required(value: Option, field: &str) -> ApiResult { - match value { - Some(value) if !value.trim().is_empty() => Ok(value), - _ => err!(format!("The {field} field is required.")), - } -} - -/// The `accountKeys` payload: a v1 key pair, or v2 with a signature key pair and security state. -/// -/// Ref: -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct AccountKeysData { - // Required like upstream, but only used without `public_key_encryption_key_pair` - user_key_encrypted_account_private_key: Option, - account_public_key: Option, - - public_key_encryption_key_pair: Option, - signature_key_pair: Option, - security_state: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PublicKeyEncryptionKeyPairData { - wrapped_private_key: String, - public_key: String, - signed_public_key: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SignatureKeyPairData { - signature_algorithm: String, - wrapped_signing_key: String, - verifying_key: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SecurityStateData { - security_state: String, - security_version: i32, -} - -struct ValidatedAccountKeys { - private_key: String, - public_key: String, - v2: Option, -} - -struct ValidatedV2AccountKeys { - signed_public_key: String, - signing_key: String, - verifying_key: String, - signature_algorithm: SignatureAlgorithm, - security_state: String, - security_version: i32, -} - -impl AccountKeysData { - /// Upstream's model checks, plus the v2 fields all or none: no client can unlock a partial v2 state. - fn validate(self) -> ApiResult { - let top_private_key = - required(self.user_key_encrypted_account_private_key, "UserKeyEncryptedAccountPrivateKey")?; - let top_public_key = required(self.account_public_key, "AccountPublicKey")?; - let (private_key, public_key, signed_public_key) = match self.public_key_encryption_key_pair { - Some(key_pair) => ( - required(Some(key_pair.wrapped_private_key), "WrappedPrivateKey")?, - required(Some(key_pair.public_key), "PublicKey")?, - key_pair.signed_public_key, - ), - // Older clients only send the top-level fields, which are always v1 - None => (top_private_key, top_public_key, None), - }; - - if let Some(signature_key_pair) = &self.signature_key_pair { - required(Some(signature_key_pair.signature_algorithm.clone()), "SignatureAlgorithm")?; - required(Some(signature_key_pair.wrapped_signing_key.clone()), "WrappedSigningKey")?; - required(Some(signature_key_pair.verifying_key.clone()), "VerifyingKey")?; - } - if let Some(security_state) = &self.security_state { - required(Some(security_state.security_state.clone()), "SecurityState")?; - if security_state.security_state.encode_utf16().count() > 10_000 { - err!("The field SecurityState must be a string with a maximum length of 10000.") - } - } - - let v2 = match (signed_public_key, self.signature_key_pair, self.security_state) { - (Some(signed_public_key), Some(signature_key_pair), Some(security_state)) => { - // Upstream fails on a null one, but takes an empty one that no client can unlock with - let signed_public_key = required(Some(signed_public_key), "SignedPublicKey")?; - let Some(signature_algorithm) = SignatureAlgorithm::parse(&signature_key_pair.signature_algorithm) - else { - err!(format!( - "Unsupported signature algorithm: {}", - signature_key_pair.signature_algorithm.escape_debug() - )) - }; - - Some(ValidatedV2AccountKeys { - signed_public_key, - signing_key: signature_key_pair.wrapped_signing_key, - verifying_key: signature_key_pair.verifying_key, - signature_algorithm, - security_state: security_state.security_state, - security_version: security_state.security_version, - }) - } - (None, None, None) => None, - _ => err!( - "Invalid account keys: the signed public key, signature key pair and security state must either all be present or all be absent" - ), - }; - - Ok(ValidatedAccountKeys { - private_key, - public_key, - v2, - }) - } -} - -impl WrappedAccountCryptographicState { - /// v2-only: the nested pair stands in for the top-level keys, so a partial state can't pass as v1. - fn validate(self) -> ApiResult { - let key_pair = self.public_key_encryption_key_pair; - AccountKeysData { - user_key_encrypted_account_private_key: Some(key_pair.wrapped_private_key.clone()), - account_public_key: Some(key_pair.public_key.clone()), - public_key_encryption_key_pair: Some(key_pair), - signature_key_pair: Some(self.signature_key_pair), - security_state: Some(self.security_state), - } - .validate() - } -} - -impl From for ValidatedAccountKeys { - fn from(keys: KeysData) -> Self { - Self { - private_key: keys.encrypted_private_key, - public_key: keys.public_key, - v2: None, - } - } -} - -impl ValidatedAccountKeys { - /// The keys of a registration: `accountKeys` only for a v2 account, like upstream. - /// - /// Ref: - fn for_registration(account_keys: Option, keys: Option) -> ApiResult { - if let Some(account_keys) = account_keys { - let account_keys = account_keys.validate()?; - if account_keys.is_v2() { - return Ok(account_keys); - } - } - let Some(keys) = keys else { - err!("PublicKey and WrappedPrivateKey not found in RequestModel") - }; - Ok(keys.into()) - } - - fn is_v2(&self) -> bool { - self.v2.is_some() - } - - /// Sets the keys on the user. Save [`Self::save_signature_key_pair`] before the user: a v2 user without its key - /// pair can't unlock, while a key pair saved for a user who isn't v2 yet is ignored until it is, and replaced on - /// the next try. A rotation of a v2 account replaces its key pair, see `rotate_account`. - fn apply(&self, user: &mut User) -> EmptyResult { - user.private_key = Some(self.private_key.clone()); - user.public_key = Some(self.public_key.clone()); - - user.signed_public_key = self.v2.as_ref().map(|v2| v2.signed_public_key.clone()); - user.security_state = self.v2.as_ref().map(|v2| v2.security_state.clone()); - user.security_version = self.v2.as_ref().map(|v2| v2.security_version); - - Ok(()) - } - - /// Saves the signature key pair, which needs the user to exist for its foreign key. - async fn save_signature_key_pair(&self, user_id: &UserId, conn: &DbConn) -> EmptyResult { - // Skip if the account is v1, since v1 accounts don't have a signature key pair. - let Some(v2) = &self.v2 else { - return Ok(()); - }; - - let mut key_pair = match UserSignatureKeyPair::find_by_user(user_id, conn).await { - Some(mut key_pair) => { - key_pair.signature_algorithm = v2.signature_algorithm as i32; - key_pair.signing_key.clone_from(&v2.signing_key); - key_pair.verifying_key.clone_from(&v2.verifying_key); - key_pair - } - None => UserSignatureKeyPair::new( - user_id.clone(), - v2.signature_algorithm, - v2.signing_key.clone(), - v2.verifying_key.clone(), - ), - }; - key_pair.save(conn).await - } -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MasterPasswordAuthentication { - #[serde(alias = "Kdf")] kdf: KDFData, - #[serde(alias = "Salt")] salt: String, - #[serde(alias = "masterPasswordAuthenticationHash", alias = "MasterPasswordAuthenticationHash")] + #[serde(alias = "masterPasswordAuthenticationHash")] hash: String, } @@ -444,19 +210,17 @@ pub struct MasterPasswordUnlock { #[serde(alias = "masterKeyWrappedUserKey")] key: String, - contained_key_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetPasswordData { #[serde(flatten)] - compat: RegisterDataCompat, + kdf: KDFData, + key: String, keys: Option, - // Supersedes `keys`, and the only way a v2 account can be initialized here. - account_keys: Option, - + master_password_hash: String, master_password_hint: Option, org_identifier: Option, } @@ -491,91 +255,93 @@ async fn is_email_2fa_required(member_id: Option, conn: &DbConn) - false } -pub async fn register(data: Json, conn: DbConn) -> JsonResult { - let data: RegisterData = data.into_inner(); +pub async fn register(data: Json, email_verification: bool, conn: DbConn) -> JsonResult { + let mut data: RegisterData = data.into_inner(); let email = data.email.to_lowercase(); - let mut name = None; + let mut email_verified = false; + let mut pending_emergency_access = None; - if data.compat.unprocessable(&data.email) { + if data.unprocessable() { err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code); } // First, validate the provided verification tokens - 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"); + 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; + } + // 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)?; + + 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") + } + + pending_emergency_access = Some((accept_emergency_access_id, claims)); + email_verified = true; + } + // 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") + } + + email_verified = true; } - // During this call we don't get the name, so extract it from the claims - name = claims.name; - claims.verified + _ => { + err!("Registration is missing required parameters") + } } - // 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)?; - - 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") - } - - 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)?; - - 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") - } - - 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) = name + if let Some(ref name) = data.name && name.len() > 50 { err!("The field Name must be a string with a maximum length of 50."); } - // Check against the password hint setting and the keys here so if they fail, - // the user can retry without losing their invitation below. + // Check against the password hint setting here so if it fails, the user + // can retry without losing their invitation below. let password_hint = clean_password_hint(data.master_password_hint.as_ref()); enforce_password_hint_setting(password_hint.as_ref())?; - let account_keys = ValidatedAccountKeys::for_registration(data.account_keys, data.keys)?; - let existing_user = User::find_by_mail(&email, &conn).await; - let is_new_user = existing_user.is_none(); - let mut user = match existing_user { + let mut user = match User::find_by_mail(&email, &conn).await { Some(user) => { if !user.password_hash.is_empty() { err!("Registration not allowed or user already exists") @@ -620,20 +386,19 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { // Make sure we don't leave a lingering invitation. Invitation::take(&email, &conn).await; - set_kdf_data(&mut user, data.compat.kdf())?; + set_kdf_data(&mut user, data.kdf())?; - user.set_password(&data.compat.hash(), Some(data.compat.key()), true, None, &conn).await?; + user.set_password(&data.hash(), Some(data.key()), true, None, &conn).await?; user.password_hint = password_hint; // Add extra fields if present - if let Some(name) = name { + if let Some(name) = data.name { user.name = name; } - account_keys.apply(&mut user)?; - // Like upstream, only a v2 registration records the key id; v1 accounts report it through `user-key-id` - if account_keys.is_v2() { - user.key_id = data.compat.key_id(); + if let Some(keys) = data.keys { + user.private_key = Some(keys.encrypted_private_key); + user.public_key = Some(keys.public_key); } if email_verified { @@ -655,14 +420,6 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { } } - // The key pair first, as in a rotation, but its foreign key needs the user: a new one is saved before without a - // password or keys, like an invited user, so a failure in between leaves a registration that can be retried. - if is_new_user && account_keys.is_v2() { - let mut unregistered = User::new(&user.email, None); - unregistered.uuid.clone_from(&user.uuid); - unregistered.save(&conn).await?; - } - account_keys.save_signature_key_pair(&user.uuid, &conn).await?; user.save(&conn).await?; // accept any open emergency access invitations @@ -673,85 +430,30 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { } Ok(Json(json!({ - "object": "registerFinish", + "object": "register", + "captchaBypassToken": "", }))) } -/// Upstream's shape checks of the set-password body, on the raw JSON that the untagged compat would hide. -/// -/// Ref: -fn validate_set_password_shape(body: &Value) -> EmptyResult { - let has = |name: &str| { - body.as_object().is_some_and(|o| o.iter().any(|(k, v)| k.eq_ignore_ascii_case(name) && !v.is_null())) - }; - - if has("accountKeys") && has("keys") { - err!("Cannot specify both AccountKeys and Keys. Provide exactly one keypair.") - } - let (authentication, unlock) = (has("masterPasswordAuthentication"), has("masterPasswordUnlock")); - if authentication != unlock { - err!( - "Must provide both MasterPasswordAuthentication and MasterPasswordUnlock together. Cannot provide one without the other." - ) - } - if authentication && (has("masterPasswordHash") || has("key") || has("kdf")) { - err!( - "Cannot mix modern (MasterPasswordAuthentication/MasterPasswordUnlock) and legacy (MasterPasswordHash/Key/Kdf) fields. Provide one shape or the other." - ) - } - Ok(()) -} - #[post("/accounts/set-password", data = "")] -async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data = data.into_inner(); - validate_set_password_shape(&data)?; - let Ok(data) = serde_json::from_value::(data) else { - err_code!("Unexpected SetPasswordData format", Status::UnprocessableEntity.code) - }; - if data.master_password_hint.as_ref().is_some_and(|h| h.encode_utf16().count() > 50) { - err!("The field MasterPasswordHint must be a string with a maximum length of 50.") - } +async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + let data: SetPasswordData = data.into_inner(); let mut user = headers.user; - if user.private_key.is_some() || !user.password_hash.is_empty() { + if user.private_key.is_some() { err!("Account already initialized, cannot set password") } - if data.compat.unprocessable(&user.email) { - err_code!("Unexpected SetPasswordData format", Status::UnprocessableEntity.code); - } - // Check against the password hint setting here so if it fails, // the user can retry without losing their invitation below. let password_hint = clean_password_hint(data.master_password_hint.as_ref()); enforce_password_hint_setting(password_hint.as_ref())?; - // Like upstream, `accountKeys` only through the v2 JIT flow - // Ref: - let account_keys = match data.account_keys { - Some(account_keys) => { - if !matches!(data.compat, RegisterDataCompat::RegisterDataCur(_)) - || !crate::util::is_client_feature_flag_enabled( - "enable-account-encryption-v2-jit-password-registration", - ) - { - err!("Request includes V2 AccountKeys but V2 encryption is not enabled.") - } - let account_keys = account_keys.validate()?; - if !account_keys.is_v2() { - err!("AccountKeys are only supported for V2 encryption.") - } - Some(account_keys) - } - None => data.keys.map(ValidatedAccountKeys::from), - }; - - set_kdf_data(&mut user, data.compat.kdf())?; + set_kdf_data(&mut user, &data.kdf)?; user.set_password( - &data.compat.hash(), - Some(data.compat.key()), + &data.master_password_hash, + Some(data.key), false, Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp &conn, @@ -759,12 +461,9 @@ async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> .await?; user.password_hint = password_hint; - if let Some(ref account_keys) = account_keys { - account_keys.apply(&mut user)?; - // As in `register`, only a v2 account records the user key id here - if account_keys.is_v2() { - user.key_id = data.compat.key_id(); - } + if let Some(keys) = data.keys { + user.private_key = Some(keys.encrypted_private_key); + user.public_key = Some(keys.public_key); } if let Some(identifier) = data.org_identifier @@ -788,16 +487,14 @@ async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> Membership::accept_user_invitations(&user.uuid, &conn).await?; } - log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) + .await; - // The key pair first, as in a rotation: if saving the user then fails, the password isn't set either - if let Some(account_keys) = account_keys { - account_keys.save_signature_key_pair(&user.uuid, &conn).await?; - } user.save(&conn).await?; Ok(Json(json!({ "object": "set-password", + "captchaBypassToken": "", }))) } @@ -876,91 +573,20 @@ async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> Js }))) } -#[get("/users//keys")] -async fn get_account_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> JsonResult { - let user = match User::find_by_uuid(&user_id, &conn).await { - Some(user) if user.public_key.is_some() => user, - Some(_) => err_code!("User has no public_key", Status::NotFound.code), - None => err_code!("User doesn't exist", Status::NotFound.code), - }; - - Ok(Json(user.public_keys_json(&conn).await)) -} - -#[get("/accounts/keys")] -async fn get_keys(headers: Headers, conn: DbConn) -> 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(&conn).await, - "object": "keys", - }))) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PostKeysData { - // Required like upstream, even next to `accountKeys` - public_key: Option, - encrypted_private_key: Option, - account_keys: Option, - // The id of the user key these account keys belong to, only honored for v2 `accountKeys` - user_key_id: Option, -} - #[post("/accounts/keys", data = "")] -async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data: PostKeysData = data.into_inner(); +async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + let data: KeysData = data.into_inner(); let mut user = headers.user; - // Only for an account without keys, replacing them is what a key rotation does - 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); - // Ref: - let public_key = required(data.public_key, "PublicKey")?; - let encrypted_private_key = required(data.encrypted_private_key, "EncryptedPrivateKey")?; - - // `accountKeys` supersedes the flat keys when both are sent - let account_keys = match data.account_keys { - Some(account_keys) => { - let account_keys = account_keys.validate()?; - if !account_keys.is_v2() { - err!("AccountKeys are only supported for V2 encryption.") - } - // A client that predates key ids sends none, and reports it later through `user-key-id` - if data.user_key_id.is_some() { - user.key_id = data.user_key_id; - } - account_keys - } - None => KeysData { - encrypted_private_key, - public_key, - } - .into(), - }; - - account_keys.apply(&mut user)?; - // The key pair first, as in a rotation: if saving the user then fails, the account stays without keys - account_keys.save_signature_key_pair(&user.uuid, &conn).await?; user.save(&conn).await?; Ok(Json(json!({ - "key": (!user.akey.is_empty()).then_some(&user.akey), "privateKey": user.private_key, "publicKey": user.public_key, - "accountKeys": user.account_keys_json(&conn).await, "object":"keys" }))) } @@ -987,7 +613,8 @@ async fn post_password(data: Json, headers: Headers, conn: DbCon err!("Invalid password") } - log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserChangedPassword as i32, &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) { @@ -995,13 +622,10 @@ async fn post_password(data: Json, headers: Headers, conn: DbCon err!("KDF settings must be equal for authentication and unlock") } - let salt = user.master_password_salt(); - if salt != authentication_data.salt || salt != unlock_data.salt { + if user.email != authentication_data.salt || user.email != unlock_data.salt { err!("Invalid master password salt") } - validate_key_id_unchanged(&user, &unlock_data)?; - (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) @@ -1075,59 +699,18 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub(super) struct AuthenticationData { - #[serde(alias = "Salt")] +struct AuthenticationData { salt: String, - #[serde(alias = "Kdf")] - pub(super) kdf: KDFData, - #[serde(alias = "MasterPasswordAuthenticationHash")] - 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(()) - } + kdf: KDFData, + master_password_authentication_hash: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub(super) struct UnlockData { +struct UnlockData { salt: String, kdf: KDFData, - pub(super) master_key_wrapped_user_key: String, - contained_key_id: Option, -} - -/// A password or KDF change keeps the user key, so its key id must be the current one, when both are known. -/// -/// Ref: -fn validate_key_id_unchanged(user: &User, unlock_data: &UnlockData) -> EmptyResult { - if let (Some(current), Some(contained)) = (&user.key_id, &unlock_data.contained_key_id) - && current != contained - { - err!("Invalid user key sent in master-password unlock data.") - } - Ok(()) -} - -/// A rotation's unlock data has to wrap the new user key. Neither id is sent by clients that predate them. -/// -/// Ref: -fn validate_contained_key_id(contained_key_id: Option<&KeyId>, new_user_key_id: Option<&KeyId>) -> EmptyResult { - match (contained_key_id, new_user_key_id) { - (None, None) => Ok(()), - (Some(contained), Some(new)) if contained == new => Ok(()), - _ => err!("Invalid user key sent in master-password unlock data."), - } + master_key_wrapped_user_key: String, } #[derive(Deserialize)] @@ -1146,9 +729,13 @@ async fn post_kdf(data: Json, headers: Headers, conn: DbConn, nt: err!("Invalid password") } - data.authentication_data.check(&headers.user, &data.unlock_data)?; + if data.authentication_data.kdf != data.unlock_data.kdf { + err!("KDF settings must be equal for authentication and unlock") + } - validate_key_id_unchanged(&headers.user, &data.unlock_data)?; + if headers.user.email != data.authentication_data.salt || headers.user.email != data.unlock_data.salt { + err!("Invalid master password salt") + } let mut user = headers.user; @@ -1184,72 +771,50 @@ struct UpdateFolderData { #[serde(rename_all = "camelCase")] struct UpdateEmergencyAccessData { id: EmergencyAccessId, - key_encrypted: Option, - // Only validated, upstream doesn't store it either - wait_time_days: Option, + key_encrypted: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateResetPasswordData { organization_id: OrganizationId, - // Absent in a v1 -> v2 upgrade, which keeps the key the organization already holds - reset_password_key: Option, + reset_password_key: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct KeyData { account_unlock_data: RotateAccountUnlockData, - account_keys: AccountKeysData, + account_keys: RotateAccountKeys, account_data: RotateAccountData, old_master_key_authentication_hash: String, - old_user_key_id: Option, - new_user_key_id: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RotateAccountUnlockData { + emergency_access_unlock_data: Vec, master_password_unlock_data: MasterPasswordUnlockData, - #[serde(flatten)] - common: CommonUnlockData, + organization_account_recovery_unlock_data: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MasterPasswordUnlockData { - #[serde(flatten)] - kdf: KDFData, + kdf_type: i32, + kdf_iterations: i32, + kdf_parallelism: Option, + kdf_memory: Option, email: String, master_key_authentication_hash: String, master_key_encrypted_user_key: String, - master_password_hint: Option, - master_password_salt: Option, - contained_key_id: Option, } -/// The unlock data of both rotation endpoints; passkey and device keys are ignored, as we support neither. -/// -/// Ref: #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct CommonUnlockData { - emergency_access_unlock_data: Vec, - organization_account_recovery_unlock_data: Vec, - #[expect(dead_code, reason = "Required like upstream, but unused")] - passkey_unlock_data: Vec, - #[expect(dead_code, reason = "Required like upstream, but unused")] - device_key_unlock_data: Vec, - v2_upgrade_token: Option, -} - -/// Lets other sessions with the v1 user key unwrap the v2 one after an upgrade. Opaque to us. -#[derive(Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct V2UpgradeTokenData { - wrapped_user_key1: String, - wrapped_user_key2: String, +struct RotateAccountKeys { + user_key_encrypted_account_private_key: String, + account_public_key: String, } #[derive(Deserialize)] @@ -1260,117 +825,27 @@ struct RotateAccountData { sends: Vec, } -/// Body of `rotate-user-keys`, which rotates the keys without touching the master password. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct RotateUserKeysData { - wrapped_account_cryptographic_state: WrappedAccountCryptographicState, - unlock_data: CommonUnlockData, - account_data: RotateAccountData, - unlock_method_data: UnlockMethodData, - old_user_key_id: Option, - new_user_key_id: Option, -} - -/// The v2-only account cryptographic state, where all three parts are mandatory. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct WrappedAccountCryptographicState { - public_key_encryption_key_pair: PublicKeyEncryptionKeyPairData, - signature_key_pair: SignatureKeyPairData, - security_state: SecurityStateData, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UnlockMethodData { - unlock_method: i32, - master_password_unlock_data: Option, - key_connector_key_wrapped_user_key: Option, -} - -/// Ref: -#[derive(num_derive::FromPrimitive)] -enum UnlockMethod { - Tde = 0, - MasterPassword = 1, - KeyConnector = 2, -} - -/// The rotation checks on the new keys: the public key never changes, nor the verifying key of a v2 account. -/// -/// Ref: -async fn validate_rotation_account_keys(keys: &ValidatedAccountKeys, user: &User, conn: &DbConn) -> EmptyResult { - if user.public_key.as_ref() != Some(&keys.public_key) { - err!("Changing the asymmetric keypair is not possible during key rotation") - } - - let Some(v2) = &keys.v2 else { - if user.is_v2() { - err!("Cannot downgrade an account from v2 to v1 encryption during key rotation") - } - // A v1 rotation: the private key stays wrapped by an AES-CBC-HMAC user key - if enc_string_type(&keys.private_key) != Some(ENC_TYPE_AES_CBC_256_HMAC_SHA256) { - err!("The provided account private key was not wrapped with AES-256-CBC-HMAC") - } - return Ok(()); - }; - - // Both a v2 rotation and the v1 -> v2 upgrade end up with a COSE user key wrapping the private keys - if enc_string_type(&v2.signing_key) != Some(ENC_TYPE_COSE_ENCRYPT0) { - err!("The provided signing key data is not wrapped with XChaCha20-Poly1305.") - } - if enc_string_type(&keys.private_key) != Some(ENC_TYPE_COSE_ENCRYPT0) { - err!("The provided private key encryption key is not wrapped with XChaCha20-Poly1305.") - } - if v2.verifying_key.is_empty() || v2.signed_public_key.is_empty() || v2.security_state.is_empty() { - err!("The v2 account keys are missing the verifying key, signed public key or security state") - } - - if !user.is_v2() { - // The v1 -> v2 upgrade, which is where the signature key pair comes from - return Ok(()); - } - - let Some(key_pair) = UserSignatureKeyPair::find_by_user(&user.uuid, conn).await else { - err!("The account is missing its signature key pair") - }; - if key_pair.verifying_key != v2.verifying_key { - err!("Changing the verifying key is not possible during key rotation") - } - - Ok(()) -} - -/// `AesCbc256_HmacSha256_B64`, the v1 user key -const ENC_TYPE_AES_CBC_256_HMAC_SHA256: &str = "2"; -/// `CoseEncrypt0B64`, the v2 user key -const ENC_TYPE_COSE_ENCRYPT0: &str = "7"; - -/// The encryption type of an EncString, the number before the first `.`. -fn enc_string_type(enc_string: &str) -> Option<&str> { - enc_string.split_once('.').map(|(enc_type, _)| enc_type) -} - -impl KDFData { - /// Whether these are the settings the user already has, which a key rotation can't change. - fn is_unchanged_for(&self, user: &User) -> bool { - user.client_kdf_type == self.kdf - && user.client_kdf_iter == self.kdf_iterations - && user.client_kdf_memory == self.kdf_memory - && user.client_kdf_parallelism == self.kdf_parallelism - } -} - -/// Every existing item must be in the rotation, or it would be unreadable afterwards. -fn validate_rotation_data( - data: &RotateData, +fn validate_keydata( + data: &KeyData, existing_ciphers: &[Cipher], existing_folders: &[Folder], existing_emergency_access: &[EmergencyAccess], existing_memberships: &[Membership], existing_sends: &[Send], + user: &User, ) -> EmptyResult { + if user.client_kdf_type != data.account_unlock_data.master_password_unlock_data.kdf_type + || user.client_kdf_iter != data.account_unlock_data.master_password_unlock_data.kdf_iterations + || user.client_kdf_memory != data.account_unlock_data.master_password_unlock_data.kdf_memory + || user.client_kdf_parallelism != data.account_unlock_data.master_password_unlock_data.kdf_parallelism + || user.email != data.account_unlock_data.master_password_unlock_data.email + { + err!("Changing the kdf variant or email is not supported during key rotation"); + } + if user.public_key.as_ref() != Some(&data.account_keys.account_public_key) { + err!("Changing the asymmetric keypair is not possible during key rotation") + } + // Check that we're correctly rotating all the user's ciphers let existing_cipher_ids = existing_ciphers.iter().map(|c| &c.uuid).collect::>(); let provided_cipher_ids = data @@ -1395,8 +870,12 @@ fn validate_rotation_data( // Check that we're correctly rotating all the user's emergency access keys let existing_emergency_access_ids = existing_emergency_access.iter().map(|ea| &ea.uuid).collect::>(); - let provided_emergency_access_ids = - data.unlock_data.emergency_access_unlock_data.iter().map(|ea| &ea.id).collect::>(); + let provided_emergency_access_ids = data + .account_unlock_data + .emergency_access_unlock_data + .iter() + .map(|ea| &ea.id) + .collect::>(); if !provided_emergency_access_ids.is_superset(&existing_emergency_access_ids) { err!("All existing emergency access keys must be included in the rotation") } @@ -1405,7 +884,7 @@ fn validate_rotation_data( let existing_reset_password_ids = existing_memberships.iter().map(|m| &m.org_uuid).collect::>(); let provided_reset_password_ids = data - .unlock_data + .account_unlock_data .organization_account_recovery_unlock_data .iter() .map(|rp| &rp.organization_id) @@ -1424,459 +903,126 @@ fn validate_rotation_data( Ok(()) } -/// The keys a rotation re-shares the new user key with. The SDK needs all four lists, even when empty. -/// -/// Ref: -#[get("/accounts/key-management/key-rotation-data")] -async fn get_key_rotation_data(headers: Headers, conn: DbConn) -> JsonResult { - let user_id = &headers.user.uuid; - - let mut organization_data = Vec::new(); - for membership in Membership::find_by_user(user_id, &conn).await { - // Only memberships actually enrolled in account recovery take part in the rotation. - if membership.reset_password_key.is_none() { - continue; - } - let Some(org) = Organization::find_by_uuid(&membership.org_uuid, &conn).await else { - continue; - }; - let Some(public_key) = org.public_key else { - continue; - }; - organization_data.push(json!({ - "organizationId": org.uuid, - "organizationName": org.name, - "organizationPublicKey": public_key, - "object": "organizationPasswordResetKeyData", - })); - } - - let mut emergency_access_data = Vec::new(); - for emergency_access in EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await { - // Without a stored key there is nothing to re-share. - if emergency_access.key_encrypted.is_none() { - continue; - } - let Some(grantee_id) = emergency_access.grantee_uuid.clone() else { - continue; - }; - let Some(grantee) = User::find_by_uuid(&grantee_id, &conn).await else { - continue; - }; - let Some(public_key) = grantee.public_key.clone() else { - continue; - }; - emergency_access_data.push(json!({ - "id": emergency_access.uuid, - "granteeId": grantee_id, - "granteeName": grantee.name, - "granteeEmail": grantee.email, - "publicKey": public_key, - "object": "emergencyAccessKeyData", - })); - } - - Ok(Json(json!({ - "organizationPasswordResetKeyData": organization_data, - "emergencyAccessKeyData": emergency_access_data, - "trustedDeviceKeyData": [], - "passkeyKeyData": [], - "object": "keyRotationData", - }))) -} - -/// Everything a rotation replaces, once each endpoint's wrapper has been peeled off. -struct RotateData { - account_keys: ValidatedAccountKeys, - account_data: RotateAccountData, - unlock_data: CommonUnlockData, - /// The id of the key the client rotates from, `None` from clients that don't send it. - old_user_key_id: Option, - /// `None` from clients that predate key ids. - new_user_key_id: Option, - /// The new user key, wrapped by the master key. - wrapped_user_key: String, - /// Only for `rotate-user-account-keys`: the new password hash and hint. - new_password: Option<(String, Option)>, -} - -/// Upstream's `[StringLength(max)]`, which counts UTF-16 code units. -fn validate_max_length(value: &str, max: usize, field: &str) -> EmptyResult { - if value.encode_utf16().count() > max { - err!(format!("The field {field} must be a string with a maximum length of {max}.")) - } - Ok(()) -} - -impl KDFData { - /// Upstream's check that the parameters fit the KDF type. - fn validate_parameters(&self) -> EmptyResult { - let has_argon2_parameters = (self.kdf_memory.is_some(), self.kdf_parallelism.is_some()); - if self.kdf == UserKdfType::Pbkdf2 as i32 { - if has_argon2_parameters != (false, false) { - err!("KdfMemory and KdfParallelism must be null for PBKDF2_SHA256") - } - } else if self.kdf == UserKdfType::Argon2id as i32 { - if has_argon2_parameters != (true, true) { - err!("KdfMemory and KdfParallelism must have values for Argon2id") - } - } else { - err!("Invalid KdfType") - } - Ok(()) - } -} - -impl MasterPasswordUnlockData { - /// Upstream's model validation. - /// - /// Ref: - fn validate(&self) -> EmptyResult { - self.kdf.validate_parameters()?; - if !crate::util::is_valid_email(&self.email) { - err!("The Email field is not a supported e-mail address format.") - } - validate_max_length(&self.email, 256, "Email")?; - required(Some(self.master_key_authentication_hash.clone()), "MasterKeyAuthenticationHash")?; - validate_max_length(&self.master_key_authentication_hash, 300, "MasterKeyAuthenticationHash")?; - required(Some(self.master_key_encrypted_user_key.clone()), "MasterKeyEncryptedUserKey")?; - if let Some(hint) = &self.master_password_hint { - validate_max_length(hint, 50, "MasterPasswordHint")?; - } - if self.master_password_salt.as_ref().is_some_and(|salt| salt.encode_utf16().count() > 256) { - err!("The field MasterPasswordSalt must be a string or array type with a maximum length of '256'.") - } - Ok(()) - } -} - -impl CommonUnlockData { - /// Upstream's model validation. - fn validate(&self) -> EmptyResult { - // Ref: - for emergency_access in &self.emergency_access_unlock_data { - if !emergency_access.wait_time_days.is_some_and(|days| (1..=i32::from(i16::MAX)).contains(&days)) { - err!("The field WaitTimeDays must be between 1 and 32767.") - } - } - if let Some(token) = &self.v2_upgrade_token { - required(Some(token.wrapped_user_key1.clone()), "WrappedUserKey1")?; - required(Some(token.wrapped_user_key2.clone()), "WrappedUserKey2")?; - } - Ok(()) - } -} - -/// The model validation of the parts both rotation endpoints share, which upstream runs before anything else. -fn validate_rotation_request(account_data: &RotateAccountData, unlock_data: &CommonUnlockData) -> EmptyResult { - unlock_data.validate()?; - - // Validate the import before continuing - // Bitwarden does not process the import if there is one item invalid. - // Since we check for the size of the encrypted note length, we need to do that here to pre-validate it. - // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. - Cipher::validate_cipher_data(&account_data.ciphers)?; - - if account_data.ciphers.iter().any(|cipher| cipher.id.is_none()) { - err!("The Id field is required.") - } - if account_data.sends.iter().any(|send| send.id.is_none()) { - err!("The Id field is required.") - } - Ok(()) -} - #[post("/accounts/key-management/rotate-user-account-keys", data = "")] async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { + // TODO: See if we can wrap everything within a SQL Transaction. If something fails it should revert everything. let data: KeyData = data.into_inner(); - let unlock_data = data.account_unlock_data.master_password_unlock_data; - validate_max_length(&data.old_master_key_authentication_hash, 300, "OldMasterKeyAuthenticationHash")?; - unlock_data.validate()?; - validate_rotation_request(&data.account_data, &data.account_unlock_data.common)?; - if !headers.user.check_valid_password(&data.old_master_key_authentication_hash) { err!("Invalid password") } - // Ref: - let salt = unlock_data.master_password_salt.as_ref().unwrap_or(&unlock_data.email); - if !unlock_data.kdf.is_unchanged_for(&headers.user) || *salt != headers.user.master_password_salt() { - err!("The provided master password unlock data is not valid for this user.") - } - validate_contained_key_id(unlock_data.contained_key_id.as_ref(), data.new_user_key_id.as_ref())?; - let password_hint = clean_password_hint(unlock_data.master_password_hint.as_ref()); - enforce_password_hint_setting(password_hint.as_ref())?; + // Validate the import before continuing + // Bitwarden does not process the import if there is one item invalid. + // Since we check for the size of the encrypted note length, we need to do that here to pre-validate it. + // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. + Cipher::validate_cipher_data(&data.account_data.ciphers)?; - let mut common = data.account_unlock_data.common; - // Like upstream, this endpoint logs every session out, so an upgrade token is never kept - // Ref: - common.v2_upgrade_token = None; - - rotate_account( - RotateData { - account_keys: data.account_keys.validate()?, - account_data: data.account_data, - unlock_data: common, - old_user_key_id: data.old_user_key_id, - new_user_key_id: data.new_user_key_id, - wrapped_user_key: unlock_data.master_key_encrypted_user_key, - new_password: Some((unlock_data.master_key_authentication_hash, password_hint)), - }, - headers, - conn, - nt, - ) - .await -} - -/// Both rotation endpoints: every check first, then the re-encrypted data and the new keys are saved. -async fn rotate_account(data: RotateData, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let user_id = &headers.user.uuid; + + // TODO: Ideally we'd do everything after this point in a single transaction. + let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await; let mut existing_folders = Folder::find_by_user(user_id, &conn).await; let mut existing_emergency_access = EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await; - // Like upstream, only the ones with a key take part - existing_emergency_access.retain(|ea| ea.key_encrypted.is_some()); let mut existing_memberships = Membership::find_by_user(user_id, &conn).await; // We only rotate the reset password key if it is set. existing_memberships.retain(|m| m.reset_password_key.is_some()); let mut existing_sends = Send::find_by_user(user_id, &conn).await; - validate_rotation_data( + validate_keydata( &data, &existing_ciphers, &existing_folders, &existing_emergency_access, &existing_memberships, &existing_sends, + &headers.user, )?; - // Like upstream, a client that rotates a user key that was already replaced has to sync first - // Ref: - if let (Some(current), Some(old)) = (&headers.user.key_id, &data.old_user_key_id) - && current != old - { - err_code!("The user key being rotated is not the current user key. Sync and try again.", Status::Conflict.code) - } - - validate_rotation_account_keys(&data.account_keys, &headers.user, &conn).await?; - - // An upgrade keeps the sessions; for an account already on v2 the token means nothing and is dropped - let is_upgrade = data.unlock_data.v2_upgrade_token.is_some() && !headers.user.is_v2(); - let upgrade_token = match &data.unlock_data.v2_upgrade_token { - Some(token) if is_upgrade => Some(serde_json::to_string(token)?), - _ => None, - }; - - // An upgrade can't re-wrap the recovery keys without a trust prompt, so they're kept and get the token - // Ref: - for membership in &existing_memberships { - let has_key = data - .unlock_data - .organization_account_recovery_unlock_data - .iter() - .find(|rp| rp.organization_id == membership.org_uuid) - .and_then(|rp| rp.reset_password_key.as_deref()) - .is_some_and(|key| !key.trim().is_empty()); - if is_upgrade && has_key { - err!("Account recovery keys cannot be rotated during a V1 to V2 upgrade rotation.") - } - if !is_upgrade && !has_key { - err!("Account recovery keys cannot be null or empty during rotation.") - } - } - - // Ref: - for emergency_access in &existing_emergency_access { - if data - .unlock_data - .emergency_access_unlock_data - .iter() - .find(|ea| ea.id == emergency_access.uuid) - .is_some_and(|ea| ea.key_encrypted.is_none()) - { - err!("Emergency access keys cannot be set to null during rotation.") - } - } - - // Saving's own checks, so that they can't fail halfway - for cipher_data in data.account_data.ciphers.iter().filter(|c| c.organization_id.is_none()) { - if let Some(cipher) = existing_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) { - validate_rotated_cipher(cipher, cipher_data, &headers, &conn).await?; - } - } - - // TODO: Ideally we'd do everything after this point in a single transaction. - - // Update folder data, ignoring ids that aren't the user's, like upstream + // Update folder data for folder_data in data.account_data.folders { // Skip `null` folder id entries. // See: https://github.com/bitwarden/clients/issues/8453 - if let Some(saved_folder) = folder_data.id.and_then(|id| existing_folders.iter_mut().find(|f| f.uuid == id)) { + if let Some(folder_id) = folder_data.id { + let Some(saved_folder) = existing_folders.iter_mut().find(|f| f.uuid == folder_id) else { + err!("Folder doesn't exist") + }; + saved_folder.name = folder_data.name; saved_folder.save(&conn).await?; } } - // Like upstream, the unlock data below comes from the first entry for each, as checked above - for saved_emergency_access in &mut existing_emergency_access { - if let Some(emergency_access_data) = - data.unlock_data.emergency_access_unlock_data.iter().find(|ea| ea.id == saved_emergency_access.uuid) - { - saved_emergency_access.key_encrypted.clone_from(&emergency_access_data.key_encrypted); - saved_emergency_access.save(&conn).await?; - } + // Update emergency access data + for emergency_access_data in data.account_unlock_data.emergency_access_unlock_data { + let Some(saved_emergency_access) = + existing_emergency_access.iter_mut().find(|ea| ea.uuid == emergency_access_data.id) + else { + err!("Emergency access doesn't exist or is not owned by the user") + }; + + saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted); + saved_emergency_access.save(&conn).await?; } - for membership in &mut existing_memberships { - if let Some(reset_password_data) = data - .unlock_data - .organization_account_recovery_unlock_data - .iter() - .find(|rp| rp.organization_id == membership.org_uuid) - { - if !is_upgrade { - membership.reset_password_key.clone_from(&reset_password_data.reset_password_key); - } - membership.v2_upgrade_token.clone_from(&upgrade_token); - membership.save(&conn).await?; - } + // Update reset password data + for reset_password_data in data.account_unlock_data.organization_account_recovery_unlock_data { + let Some(membership) = + existing_memberships.iter_mut().find(|m| m.org_uuid == reset_password_data.organization_id) + else { + err!("Reset password doesn't exist") + }; + + membership.reset_password_key = Some(reset_password_data.reset_password_key); + membership.save(&conn).await?; } // Update send data for send_data in data.account_data.sends { - if let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id)) { - // Like upstream, only the key changes on rotation - send.akey = send_data.key; - send.save(&conn).await?; - } + let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else { + err!("Send doesn't exist") + }; + + update_send_from_data(send, send_data, &headers, &conn, &nt, UpdateType::None).await?; } // Update cipher data for cipher_data in data.account_data.ciphers { - if cipher_data.organization_id.is_none() - && let Some(saved_cipher) = - cipher_data.id.as_ref().and_then(|id| existing_ciphers.iter_mut().find(|c| &c.uuid == id)) - { - // No cipher pushes: the other sessions still hold the old user key - rotate_cipher_data(saved_cipher, cipher_data, &headers, &conn, &nt).await?; + 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()) + else { + err!("Cipher doesn't exist") + }; + + // Prevent triggering cipher updates via WebSockets by settings UpdateType::None + // The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues. + // We force the users to logout after the user has been saved to try and prevent these issues. + update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; } } // Update user data let mut user = headers.user; - user.v2_upgrade_token = upgrade_token; - data.account_keys.apply(&mut user)?; - // The old id names a key that no longer exists, so it is replaced even when the new key has none. - user.key_id = data.new_user_key_id; + user.private_key = Some(data.account_keys.user_key_encrypted_account_private_key); + user.set_password( + &data.account_unlock_data.master_password_unlock_data.master_key_authentication_hash, + Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key), + true, + None, + &conn, + ) + .await?; - user.akey = data.wrapped_user_key; - if let Some((new_password_hash, new_password_hint)) = data.new_password { - // A password change ends every session, as `/accounts/password` does - user.password_hint = new_password_hint; - user.set_password(&new_password_hash, None, true, None, &conn).await?; - } else if !is_upgrade { - // An upgrade keeps the sessions alive, see `is_upgrade` above - user.reset_security_stamp_after_key_rotation(&conn).await?; - } - - // The key pair first, so that a v2 account is never saved without one. The data above is already re-encrypted by - // now, and for an account that was already v2 so is the signing key saved here, so a failure in either save - // leaves them under a key that isn't stored (see the TODO before the first write). - data.account_keys.save_signature_key_pair(&user.uuid, &conn).await?; - user.save(&conn).await?; + let save_result = user.save(&conn).await; // Prevent logging out the client where the user requested this endpoint from. // If you do logout the user it will causes issues at the client side. // Adding the device uuid will prevent this. - // On an upgrade, the reason lets clients sync instead - let reason = is_upgrade.then_some(LogOutReason::KeyRotation); - nt.send_logout_with_reason(&user, Some(&headers.device), reason, &conn).await; + nt.send_logout(&user, Some(&headers.device), &conn).await; - Ok(()) -} - -/// Rotates the keys without changing the master password; like upstream, the session is the only proof. -/// -/// Ref: -#[post("/accounts/key-management/rotate-user-keys", data = "")] -async fn post_rotate_user_keys( - data: Json, - headers: Headers, - conn: DbConn, - nt: Notify<'_>, -) -> EmptyResult { - let data: RotateUserKeysData = data.into_inner(); - validate_rotation_request(&data.account_data, &data.unlock_data)?; - - // Ref: - let wrapped_user_key = match UnlockMethod::from_i32(data.unlock_method_data.unlock_method) { - Some(UnlockMethod::MasterPassword) => { - let (Some(unlock_data), None) = ( - data.unlock_method_data.master_password_unlock_data, - data.unlock_method_data.key_connector_key_wrapped_user_key, - ) else { - err!( - "Invalid MasterPassword unlock method request, MasterPasswordUnlockData must be provided and KeyConnectorKeyWrappedUserKey must be null" - ) - }; - required(Some(unlock_data.master_key_wrapped_user_key.clone()), "MasterKeyWrappedUserKey")?; - required(Some(unlock_data.salt.clone()), "Salt")?; - validate_max_length(&unlock_data.salt, 256, "Salt")?; - - // Ref: - if headers.user.password_hash.is_empty() || headers.user.akey.is_empty() { - err!("User is in an invalid state for master password key rotation.") - } - if unlock_data.salt != headers.user.master_password_salt() { - err!("Invalid master password salt.") - } - if !unlock_data.kdf.is_unchanged_for(&headers.user) { - err!("Invalid KDF settings.") - } - validate_contained_key_id(unlock_data.contained_key_id.as_ref(), data.new_user_key_id.as_ref())?; - unlock_data.master_key_wrapped_user_key - } - Some(UnlockMethod::Tde) => err!("Trusted device encryption is not supported"), - Some(UnlockMethod::KeyConnector) => err!("Key connector is not supported"), - None => err!("Unrecognized unlock method"), - }; - - rotate_account( - RotateData { - account_keys: data.wrapped_account_cryptographic_state.validate()?, - account_data: data.account_data, - unlock_data: data.unlock_data, - old_user_key_id: data.old_user_key_id, - new_user_key_id: data.new_user_key_id, - wrapped_user_key, - new_password: None, - }, - headers, - conn, - nt, - ) - .await -} - -#[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; - // Only a backfill for accounts that have none. Afterwards the id changes with the key, in a rotation. - if user.key_id.is_some() { - err!("User key id is already set.") - } - - user.key_id = Some(data.into_inner().user_key_id); - user.save(&conn).await + save_result } #[post("/accounts/security-stamp", data = "")] @@ -1946,11 +1092,7 @@ async fn post_email_token(data: Json, headers: Headers, conn: Db error!("Error sending change-email email: {e:#?}"); } } else { - debug!( - "Email change request for user ({}) to email ({}) with token ({token})", - user.uuid, - data.new_email.escape_debug() - ); + debug!("Email change request for user ({}) to email ({}) with token ({token})", user.uuid, data.new_email); } user.email_new = Some(data.new_email); @@ -2103,7 +1245,7 @@ struct DeleteRecoverTokenData { } #[post("/accounts/delete-recover-token", data = "")] -async fn post_delete_recover_token(data: Json, conn: DbConn, nt: Notify<'_>) -> EmptyResult { +async fn post_delete_recover_token(data: Json, conn: DbConn) -> EmptyResult { let data: DeleteRecoverTokenData = data.into_inner(); let Ok(claims) = decode_delete(&data.token) else { @@ -2117,44 +1259,22 @@ async fn post_delete_recover_token(data: Json, conn: DbC if claims.sub != *user.uuid { err!("Invalid claim"); } - - // The devices are deleted together with the user, so check for push devices beforehand - let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await; - user.delete(&conn).await?; - - nt.send_logout(&user, None, &conn).await; - if has_push_device { - push_logout_to_relay(&user, None, None); - } - Ok(()) + user.delete(&conn).await } #[post("/accounts/delete", data = "")] -async fn post_delete_account( - data: Json, - headers: Headers, - conn: DbConn, - nt: Notify<'_>, -) -> EmptyResult { - delete_account(data, headers, conn, nt).await +async fn post_delete_account(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + delete_account(data, headers, conn).await } #[delete("/accounts", data = "")] -async fn delete_account(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { +async fn delete_account(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, true, &conn).await?; - // The devices are deleted together with the user, so check for push devices beforehand - let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await; - user.delete(&conn).await?; - - nt.send_logout(&user, None, &conn).await; - if has_push_device { - push_logout_to_relay(&user, None, None); - } - Ok(()) + user.delete(&conn).await } #[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")] @@ -2205,7 +1325,7 @@ async fn password_hint(data: Json, ip: ClientIp, conn: DbConn) mail::send_password_hint(email, hint).await?; Ok(()) } else if let Some(hint) = hint { - err_silent!(format!("Your password hint is: {hint}")); + err!(format!("Your password hint is: {hint}")); } else { err!(NO_HINT); } @@ -2219,33 +1339,19 @@ 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())] +#[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)?; 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(&email, &conn).await { + let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await { Some(user) => (user.client_kdf_type, user.client_kdf_iter, user.client_kdf_memory, user.client_kdf_parallelism), - None => unknown_email_kdf(&email), + None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None), }; Ok(Json(json!({ @@ -2518,7 +1624,7 @@ async fn post_auth_request( nt.send_auth_request(&user.uuid, &auth_request.uuid, &device, &conn).await; log_user_event( - EventType::UserRequestedDeviceApproval, + EventType::UserRequestedDeviceApproval as i32, &user.uuid, client_headers.device_type, &client_headers.ip.ip, @@ -2612,7 +1718,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, + EventType::OrganizationUserApprovedAuthRequest as i32, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -2623,7 +1729,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, + EventType::OrganizationUserRejectedAuthRequest as i32, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -2729,62 +1835,3 @@ 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.compat.unprocessable(&data.email)); - assert_eq!(data.compat.hash(), "hash"); - assert_eq!(data.compat.key(), "key"); - assert_eq!(data.compat.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.compat.unprocessable(&data.email)); - assert_eq!(data.compat.hash(), "hash"); - assert_eq!(data.compat.key(), "key"); - assert_eq!(data.compat.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 f70f2ccb..50be6732 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -6,14 +6,13 @@ use rocket::{ Route, form::{Form, FromForm}, fs::TempFile, - http::Status, serde::json::Json, }; use serde_json::Value; use crate::{ CONFIG, - api::{self, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event}, + api::{self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event}, auth::ClientVersion, auth::{Headers, OrgIdGuard, OwnerHeaders}, config::PathType, @@ -22,15 +21,14 @@ use crate::{ DbConn, DbPool, models::{ Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, - CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId, - Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId, - is_data_blob_encrypted, + CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership, + MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, }, }, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, }; -use super::{folders::FolderData, sends::SIZE_525_MB}; +use super::folders::FolderData; pub fn routes() -> Vec { // Note that many routes have an `admin` variant; this seems to be @@ -163,12 +161,6 @@ 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 { @@ -190,32 +182,23 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option, - // Added in web-v2025.6.0. Deprecated upstream for `encrypted_by_key_id`, but still checked when sent - pub encrypted_for: Option, - // Added in web-v2025.8.1, Optional for compat - pub encrypted_by_key_id: Option, - /* Login = 1, SecureNote = 2, Card = 3, Identity = 4, SshKey = 5 - BankAccount = 6 - DriversLicense = 7 - Passport = 8 */ pub r#type: i32, - // Absent on a blob-encrypted cipher, whose name is sealed inside `data` - pub name: Option, + pub name: String, pub notes: Option, fields: Option, @@ -304,12 +278,6 @@ pub struct CipherData { card: Option, identity: Option, ssh_key: Option, - bank_account: Option, - drivers_license: Option, - passport: Option, - - // The sealed blob of a v2 account's cipher, which replaces all of the fields above - data: Option, favorite: Option, reprompt: Option, @@ -332,98 +300,6 @@ pub struct CipherData { archived_date: Option, } -/// A field of a [`CipherData`] that fails upstream's model validation. -#[derive(Debug)] -pub struct CipherValidationError { - pub field: &'static str, - pub message: String, -} - -impl From for crate::Error { - fn from(e: CipherValidationError) -> Self { - Self::new_msg(e.message) - } -} - -/// The cipher must have been encrypted for the acting user. Only checked when the client sends the field. -/// -/// Ref: -fn validate_encrypted_for_user(data: &CipherData, user_id: &UserId) -> EmptyResult { - if data.encrypted_for.as_ref().is_some_and(|encrypted_for| encrypted_for != user_id) { - err!("Cipher was not encrypted for the current user. Please try again.") - } - Ok(()) -} - -/// [`validate_encrypted_for_user`], plus the key id of a user-owned cipher, when both ids are known. -/// -/// Ref: -fn validate_encrypted_by_user(data: &CipherData, user: &User, is_org_cipher: bool) -> EmptyResult { - validate_encrypted_for_user(data, &user.uuid)?; - - if !is_org_cipher - && let (Some(cipher_key_id), Some(user_key_id)) = (&data.encrypted_by_key_id, &user.key_id) - && cipher_key_id != user_key_id - { - err!("Cipher was not encrypted with the current user key. Please try again.") - } - Ok(()) -} - -/// Upstream's `[StringLength(500000)]` on `CipherRequestModel.Data` -const MAX_CIPHER_DATA_LENGTH: usize = 500_000; - -impl CipherData { - /// Whether `data` is a blob rather than the per-type fields. Parses `data`, so keep the result. - pub fn is_blob(&self) -> bool { - self.data.as_deref().is_some_and(is_data_blob_encrypted) - } - - /// Saving's last checks: a known type, and its data unless it's a blob. - pub fn validate_type_data(&self, is_blob: bool) -> EmptyResult { - let type_data = match self.r#type { - 1 => &self.login, - 2 => &self.secure_note, - 3 => &self.card, - 4 => &self.identity, - 5 => &self.ssh_key, - 6 => &self.bank_account, - 7 => &self.drivers_license, - 8 => &self.passport, - _ => err!("Invalid type"), - }; - if !is_blob && type_data.is_none() { - err!("Data missing") - } - Ok(()) - } - - /// Upstream's model checks that depend on the format: the size of `data`, and a name unless it's a blob. - /// - /// Ref: - /// and - pub fn validate_content(&self, is_blob: bool) -> Result<(), CipherValidationError> { - if let Some(data) = &self.data - && data.len() > MAX_CIPHER_DATA_LENGTH - { - return Err(CipherValidationError { - field: "Data", - message: format!("The field Data must be a string with a maximum length of {MAX_CIPHER_DATA_LENGTH}."), - }); - } - - // A blob carries the name inside it, so only the other formats need one - if !is_blob && self.name.as_deref().is_none_or(|n| n.trim().is_empty()) { - return Err(CipherValidationError { - field: "Name", - message: String::from("The Name field is required."), - }); - } - - Ok(()) - } -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PartialCipherData { @@ -457,14 +333,12 @@ async fn post_ciphers_create( ) -> JsonResult { let mut data: ShareCipherData = data.into_inner(); - validate_encrypted_by_user(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?; - // 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. enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?; - let mut cipher = Cipher::new(data.cipher.r#type); + let mut cipher = Cipher::new(data.cipher.r#type, data.cipher.name.clone()); cipher.user_uuid = Some(headers.user.uuid.clone()); cipher.save(&conn).await?; @@ -488,15 +362,13 @@ 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(); - validate_encrypted_by_user(&data, &headers.user, data.organization_id.is_some())?; - // 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 // needed when creating a new cipher, so just ignore it unconditionally. data.last_known_revision_date = None; - let mut cipher = Cipher::new(data.r#type); + let mut cipher = Cipher::new(data.r#type, data.name.clone()); update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) @@ -520,44 +392,6 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: & Ok(()) } -/// The checks of saving a cipher, for callers that run them before writing anything else -async fn validate_cipher_update( - cipher: &Cipher, - data: &CipherData, - headers: &Headers, - conn: &DbConn, - ut: UpdateType, -) -> EmptyResult { - // Check that the client isn't updating an existing cipher with stale data. - // And only perform this check when not importing ciphers, else the date/time check will fail. - if ut != UpdateType::None - && let Some(dt) = &data.last_known_revision_date - { - match NaiveDateTime::parse_from_str(dt, "%+") { - // ISO 8601 format - Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()), - Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => { - err!("The client copy of this cipher is out of date. Resync the client and try again.") - } - Ok(_) => (), - } - } - - if let Some(note) = &data.notes { - let max_note_size = CONFIG._max_note_size(); - if note.len() > max_note_size { - err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters.")) - } - } - - if let Some(folder_id) = &data.folder_id - && Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none() - { - err!("Invalid folder", "Folder does not exist or belongs to another user"); - } - Ok(()) -} - pub async fn update_cipher_from_data( cipher: &mut Cipher, data: CipherData, @@ -566,40 +400,6 @@ pub async fn update_cipher_from_data( conn: &DbConn, nt: &Notify<'_>, ut: UpdateType, -) -> EmptyResult { - enforce_personal_ownership_policy(Some(&data), headers, conn).await?; - save_cipher_data(cipher, data, headers, shared_to_collections, conn, nt, ut).await -} - -/// [`update_cipher_from_data`] for a rotation: like upstream, the personal ownership policy doesn't apply. -pub async fn rotate_cipher_data( - cipher: &mut Cipher, - data: CipherData, - headers: &Headers, - conn: &DbConn, - nt: &Notify<'_>, -) -> EmptyResult { - save_cipher_data(cipher, data, headers, None, conn, nt, UpdateType::None).await -} - -/// The checks of [`rotate_cipher_data`] that need the database, to run on every cipher before the first is saved -pub async fn validate_rotated_cipher( - cipher: &Cipher, - data: &CipherData, - headers: &Headers, - conn: &DbConn, -) -> EmptyResult { - validate_cipher_update(cipher, data, headers, conn, UpdateType::None).await -} - -async fn save_cipher_data( - cipher: &mut Cipher, - data: CipherData, - headers: &Headers, - shared_to_collections: Option>, - conn: &DbConn, - nt: &Notify<'_>, - ut: UpdateType, ) -> EmptyResult { // Cleanup cipher data, like removing the 'Response' key. // This key is somewhere generated during Javascript so no way for us this fix this. @@ -607,23 +407,42 @@ async fn save_cipher_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 let Some(items) = json_data.as_array_mut() { - for item in items.iter_mut().filter_map(Value::as_object_mut) { - item.remove("response"); - } + 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"); + }); } json_data } - let is_blob = data.is_blob(); - data.validate_content(is_blob)?; - data.validate_type_data(is_blob)?; - validate_cipher_update(cipher, &data, headers, conn, ut).await?; + enforce_personal_ownership_policy(Some(&data), headers, conn).await?; + + // Check that the client isn't updating an existing cipher with stale data. + // And only perform this check when not importing ciphers, else the date/time check will fail. + if ut != UpdateType::None + && let Some(dt) = data.last_known_revision_date + { + match NaiveDateTime::parse_from_str(&dt, "%+") { + // ISO 8601 format + Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"), + Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => { + err!("The client copy of this cipher is out of date. Resync the client and try again.") + } + Ok(_) => (), + } + } if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id { err!("Organization mismatch. Please resync the client before updating the cipher") } + if let Some(note) = &data.notes { + let max_note_size = CONFIG._max_note_size(); + if note.len() > max_note_size { + err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters.")) + } + } + // Check if this cipher is being transferred from a personal to an organization vault let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some(); @@ -633,10 +452,9 @@ async fn save_cipher_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() - || (!transfer_cipher && cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await) + || 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. @@ -654,6 +472,12 @@ async fn save_cipher_data( cipher.user_uuid = Some(headers.user.uuid.clone()); } + if let Some(ref folder_id) = data.folder_id + && Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none() + { + err!("Invalid folder", "Folder does not exist or belongs to another user"); + } + // Modify attachments name and keys when rotating if let Some(attachments) = data.attachments2 { for (id, attachment) in attachments { @@ -662,7 +486,7 @@ async fn save_cipher_data( // A missing attachment means it was removed via an other client. // Also the Desktop Client supports removing attachments and save an update afterwards. // Bitwarden it self ignores these mismatches server side. - warn!("Attachment {} doesn't exist", id.escape_debug()); + warn!("Attachment {id} doesn't exist"); continue; }; @@ -686,42 +510,27 @@ async fn save_cipher_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"), }; - if let Some(blob) = data.data.filter(|_| is_blob) { - // A blob holds everything, the name included; the column can't be null, so it's left empty - // TODO: Make `ciphers.name` nullable and store `None` here instead. - cipher.name = String::new(); - cipher.notes = None; - cipher.fields = None; - cipher.password_history = None; - cipher.data = blob; - } else { - let Some(mut type_data) = type_data_opt else { - err!("Data missing") - }; + let type_data = if let Some(mut data) = type_data_opt { // Remove the 'Response' key from the base object. - if let Some(data_obj) = type_data.as_object_mut() { - data_obj.remove("response"); - } + data.as_object_mut().unwrap().remove("response"); // Remove the 'Response' key from every Uri. - if type_data["uris"].is_array() { - type_data["uris"] = clean_cipher_data(type_data["uris"].clone()); + if data["uris"].is_array() { + data["uris"] = clean_cipher_data(data["uris"].clone()); } - - // `validate_content` made sure there is a name - cipher.name = data.name.unwrap_or_default(); - cipher.notes = data.notes; - cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string()); - cipher.password_history = data.password_history.map(|f| f.to_string()); - cipher.data = type_data.to_string(); - } + data + } else { + err!("Data missing") + }; cipher.key = data.key; + cipher.name = data.name; + cipher.notes = data.notes; + cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string()); + cipher.data = type_data.to_string(); + cipher.password_history = data.password_history.map(|f| f.to_string()); cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32); cipher.save(conn).await?; @@ -731,7 +540,7 @@ async fn save_cipher_data( match data.archived_date { Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") { Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?, - Err(err) => warn!("Error parsing ArchivedDate '{}': {err}", dt_str.escape_debug()), + Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"), }, None => cipher.unarchive(&headers.user.uuid, conn).await?, } @@ -817,11 +626,8 @@ async fn post_ciphers_import(data: Json, headers: Headers, conn: DbC for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() { let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned()); cipher_data.folder_id = folder_id; - // A personal import always creates personal ciphers, the organization ID is ignored instead of rejected - // Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58 - cipher_data.organization_id = None; - let mut cipher = Cipher::new(cipher_data.r#type); + let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; } @@ -841,11 +647,6 @@ async fn put_cipher_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - // Only organization ciphers can be edited here, put_cipher() rejects an organization ID that isn't the cipher's - if data.organization_id.is_none() { - err_code!("Resource not found.", "Cipher is not an organization cipher", Status::NotFound.code); - } - put_cipher(cipher_id, data, headers, conn, nt).await } @@ -857,7 +658,7 @@ async fn post_cipher_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - put_cipher_admin(cipher_id, data, headers, conn, nt).await + post_cipher(cipher_id, data, headers, conn, nt).await } #[post("/ciphers/", data = "")] @@ -894,14 +695,6 @@ async fn put_cipher( err!("Cipher is not write accessible") } - // Against the cipher we hold rather than the organization the client claims, like upstream - validate_encrypted_by_user(&data, &headers.user, cipher.organization_uuid.is_some())?; - - // Moving a cipher into an organization is only possible through the share endpoints - if cipher.organization_uuid != data.organization_id { - err!("Organization mismatch. Please resync the client before updating the cipher") - } - update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) @@ -1160,7 +953,9 @@ async fn post_cipher_share( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - put_cipher_share(cipher_id, data, headers, conn, nt).await + let data: ShareCipherData = data.into_inner(); + + share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await } #[put("/ciphers//share", data = "")] @@ -1173,21 +968,6 @@ async fn put_cipher_share( ) -> JsonResult { let data: ShareCipherData = data.into_inner(); - // Upstream's `PutShare` checks, before anything is written. - // Ref: - // and - if data.cipher.organization_id.is_none() { - err!("Cipher OrganizationId is required.") - } - if data.collection_ids.is_empty() { - err!("You must select at least one collection.") - } - if !Cipher::find_by_uuid(&cipher_id, &conn).await.is_some_and(|c| c.user_uuid.as_ref() == Some(&headers.user.uuid)) - { - err_code!("Cipher doesn't exist", Status::NotFound.code) - } - validate_encrypted_for_user(&data.cipher, &headers.user.uuid)?; - share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await } @@ -1198,117 +978,46 @@ struct ShareSelectedCipherData { collection_ids: Vec, } -// A bulk share returns cipherMini models, not the cipherDetails models used by -// the single-share route. Keep the encrypted legacy fields and a future v2 -// `data` blob, but do not expose details-only fields in this response. -fn cipher_mini_from_json(mut cipher: Value) -> Value { - const FIELDS: &[&str] = &[ - "id", - "organizationId", - "type", - "data", - "partialData", - "name", - "notes", - "login", - "card", - "identity", - "secureNote", - "sshKey", - "bankAccount", - "driversLicense", - "passport", - "fields", - "passwordHistory", - "attachments", - "organizationUseTotp", - "revisionDate", - "creationDate", - "deletedDate", - "reprompt", - "key", - ]; - - let Some(object) = cipher.as_object_mut() else { - return json!({ "object": "cipherMini" }); - }; - object.retain(|field, _| FIELDS.contains(&field.as_str())); - object.insert("object".into(), "cipherMini".into()); - cipher -} - #[put("/ciphers/share", data = "")] async fn put_cipher_share_selected( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, -) -> JsonResult { - let data: ShareSelectedCipherData = data.into_inner(); +) -> EmptyResult { + let mut data: ShareSelectedCipherData = data.into_inner(); - // Upstream's `PutShareMany` checks, before anything is written. - // Ref: - // and if data.ciphers.is_empty() { err!("You must select at least one cipher.") } - if data.ciphers.iter().any(|c| c.id.is_none() || c.organization_id.is_none()) { - err!("All Ciphers must have an Id and OrganizationId.") - } - if data.ciphers.iter().map(|c| &c.organization_id).collect::>().len() != 1 { - err!("All ciphers must be for the same organization.") - } + if data.collection_ids.is_empty() { err!("You must select at least one collection.") } + for cipher in &data.ciphers { - let is_blob = cipher.is_blob(); - cipher.validate_content(is_blob)?; - cipher.validate_type_data(is_blob)?; - } - for cipher in &data.ciphers { - validate_encrypted_for_user(cipher, &headers.user.uuid)?; - } - let owned_ciphers = Cipher::find_owned_by_user(&headers.user.uuid, &conn).await; - for cipher_data in &data.ciphers { - let Some(cipher) = owned_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) else { - err!("Trying to share ciphers that you do not own.") - }; - validate_cipher_update(cipher, cipher_data, &headers, &conn, UpdateType::None).await?; + if cipher.id.is_none() { + err!("Request missing ids field") + } } - let mut responses = Vec::with_capacity(data.ciphers.len()); - for cipher in data.ciphers { + while let Some(cipher) = data.ciphers.pop() { let mut shared_cipher_data = ShareCipherData { cipher, collection_ids: data.collection_ids.clone(), }; if let Some(id) = shared_cipher_data.cipher.id.take() { - let response = - share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?; - - // Like upstream's VisibleToClient, withhold a cipher if sharing it - // removed the user's access to it. Only then include its encrypted - // mini response; the full single-share response stays internal. - if let Some(shared_cipher) = Cipher::find_by_uuid(&id, &conn).await - && shared_cipher.is_accessible_to_user(&headers.user.uuid, &conn).await - { - responses.push(cipher_mini_from_json(response.into_inner())); - } + share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await? } else { err!("Request missing ids field") - } + }; } // Multi share actions do not send out a push for each cipher, we need to send a general sync here nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await; - Ok(Json(json!({ - "data": responses, - "object": "list", - "continuationToken": null, - }))) + Ok(()) } async fn share_cipher_by_uuid( @@ -1336,22 +1045,6 @@ async fn share_cipher_by_uuid( err!("Organization mismatch. Please resync the client before updating the cipher") } - // When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate. - // If there is an override, like when handling multiple items, we want to prevent a push notification for every single item - let ut = if let Some(ut) = override_ut { - ut - } else if data.cipher.last_known_revision_date.is_some() { - UpdateType::SyncCipherUpdate - } else { - UpdateType::SyncCipherCreate - }; - - // For the same reason, the other checks of saving - let is_blob = data.cipher.is_blob(); - data.cipher.validate_content(is_blob)?; - data.cipher.validate_type_data(is_blob)?; - validate_cipher_update(&cipher, &data.cipher, headers, conn, ut).await?; - let mut shared_to_collections = vec![]; if let Some(organization_id) = &data.cipher.organization_id { @@ -1370,6 +1063,16 @@ async fn share_cipher_by_uuid( } } + // When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate. + // If there is an override, like when handling multiple items, we want to prevent a push notification for every single item + let ut = if let Some(ut) = override_ut { + ut + } else if data.cipher.last_known_revision_date.is_some() { + UpdateType::SyncCipherUpdate + } else { + UpdateType::SyncCipherCreate + }; + update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) @@ -1417,40 +1120,6 @@ enum FileUploadType { // Azure = 1, // only used upstream } -/// Remaining attachment storage in bytes for the cipher's owner, `None` when unlimited. -/// `size_adjust` credits a v2 attachment whose record already counts as used. -async fn attachment_size_limit(cipher: &Cipher, size_adjust: i64, conn: &DbConn) -> ApiResult> { - let (limit_kb, already_used) = if let Some(ref user_id) = cipher.user_uuid { - let Some(limit_kb) = CONFIG.user_attachment_limit() else { - return Ok(None); - }; - (limit_kb, Attachment::size_by_user(user_id, conn).await) - } else if let Some(ref org_id) = cipher.organization_uuid { - let Some(limit_kb) = CONFIG.org_attachment_limit() else { - return Ok(None); - }; - (limit_kb, Attachment::size_by_org(org_id, conn).await) - } else { - err!("Cipher is neither owned by a user nor an organization"); - }; - - if limit_kb == 0 { - err!("Attachments are disabled") - } - - let Some(left) = - limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)).and_then(|l| l.checked_add(size_adjust)) - else { - err!("Attachment size overflow"); - }; - - if left <= 0 { - err!("Attachment storage limit reached! Delete some attachments to free up space") - } - - Ok(Some(left)) -} - /// v2 API for creating an attachment associated with a cipher. /// This redirects the client to the API it should use to upload the attachment. /// For upstream's cloud-hosted service, it's an Azure object storage API. @@ -1461,7 +1130,6 @@ async fn post_attachment_v2( data: Json, headers: Headers, conn: DbConn, - nt: Notify<'_>, ) -> JsonResult { let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") @@ -1477,22 +1145,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.") - } - - if let Some(size_limit) = attachment_size_limit(&cipher, 0, &conn).await? - && file_size > size_limit - { - err!("Attachment storage limit exceeded with this file"); - } - 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)); attachment.save(&conn).await.expect("Error saving attachment"); - notify_attachment_created(&cipher, &headers, &conn, &nt).await; let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid); let response_key = match data.admin_request { @@ -1555,7 +1211,55 @@ async fn save_attachment( Some(a) => a.file_size, // v2 API }; - if let Some(size_limit) = attachment_size_limit(&cipher, size_adjust, &conn).await? + let size_limit = if let Some(ref user_id) = cipher.user_uuid { + match CONFIG.user_attachment_limit() { + Some(0) => err!("Attachments are disabled"), + Some(limit_kb) => { + let already_used = Attachment::size_by_user(user_id, &conn).await; + let left = limit_kb + .checked_mul(1024) + .and_then(|l| l.checked_sub(already_used)) + .and_then(|l| l.checked_add(size_adjust)); + + let Some(left) = left else { + err!("Attachment size overflow"); + }; + + if left <= 0 { + err!("Attachment storage limit reached! Delete some attachments to free up space") + } + + Some(left) + } + None => None, + } + } else if let Some(ref org_id) = cipher.organization_uuid { + match CONFIG.org_attachment_limit() { + Some(0) => err!("Attachments are disabled"), + Some(limit_kb) => { + let already_used = Attachment::size_by_org(org_id, &conn).await; + let left = limit_kb + .checked_mul(1024) + .and_then(|l| l.checked_sub(already_used)) + .and_then(|l| l.checked_add(size_adjust)); + + let Some(left) = left else { + err!("Attachment size overflow"); + }; + + if left <= 0 { + err!("Attachment storage limit reached! Delete some attachments to free up space") + } + + Some(left) + } + None => None, + } + } else { + err!("Cipher is neither owned by a user nor an organization"); + }; + + if let Some(size_limit) = size_limit && size > size_limit { err!("Attachment storage limit exceeded with this file"); @@ -1612,23 +1316,13 @@ async fn save_attachment( save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?; - // With the v2 API, post_attachment_v2() already did this when it created the attachment record - if attachment.is_none() { - notify_attachment_created(&cipher, headers, &conn, &nt).await; - } - - Ok((cipher, conn)) -} - -/// Sends the cipher update to the clients and logs the attachment event -async fn notify_attachment_created(cipher: &Cipher, headers: &Headers, conn: &DbConn, nt: &Notify<'_>) { nt.send_cipher_update( UpdateType::SyncCipherUpdate, - cipher, - &cipher.update_users_revision(conn).await, + &cipher, + &cipher.update_users_revision(&conn).await, &headers.device, None, - conn, + &conn, ) .await; @@ -1640,10 +1334,12 @@ async fn notify_attachment_created(cipher: &Cipher, headers: &Headers, conn: &Db &headers.user.uuid, headers.device.atype, &headers.ip.ip, - conn, + &conn, ) .await; } + + Ok((cipher, conn)) } /// v2 API for uploading the actual data content of an attachment. @@ -1919,22 +1615,12 @@ async fn move_cipher_selected( // Find all ciphers a user has access to, all others will be ignored let accessible_ciphers = Cipher::find_by_user_and_ciphers(user_id, &data.ids, &conn).await; let accessible_ciphers_count = accessible_ciphers.len(); - let mut move_result = Ok(()); for cipher in accessible_ciphers { - move_result = cipher.move_to_folder_without_revision_update(data.folder_id.clone(), user_id, &conn).await; - if move_result.is_err() { - break; - } + cipher.move_to_folder(data.folder_id.clone(), user_id, &conn).await?; if cipher_count == 1 { single_cipher = Some(cipher); } } - // Update the revision once for the whole batch. Also when a move failed halfway, - // since the ciphers moved before it are already changed. - if accessible_ciphers_count > 0 { - User::update_uuid_revision(user_id, &conn).await; - } - move_result?; if let Some(cipher) = single_cipher { nt.send_cipher_update( @@ -2146,7 +1832,7 @@ async fn delete_cipher_by_uuid( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CipherIdsData { - ids: HashSet, + ids: Vec, } async fn delete_multiple_ciphers( @@ -2457,15 +2143,8 @@ 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: Vec = members.keys().cloned().collect(); + let orgs = Membership::get_orgs_by_user(user_id, conn).await; 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 { @@ -2480,6 +2159,13 @@ 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/emergency_access.rs b/src/api/core/emergency_access.rs index 773697e5..2eb95502 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -1,4 +1,4 @@ -use chrono::Utc; +use chrono::{TimeDelta, Utc}; use rocket::{Route, serde::json::Json}; use serde_json::Value; @@ -6,17 +6,14 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, - core::{ - CipherSyncData, CipherSyncType, - accounts::{AuthenticationData, UnlockData}, - }, + core::{CipherSyncData, CipherSyncType}, }, auth::{Headers, decode_emergency_access_invite}, db::{ DbConn, DbPool, models::{ - Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, - EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, + Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, + Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, }, }, mail, @@ -136,21 +133,19 @@ async fn post_emergency_access( let data: EmergencyAccessUpdateData = data.into_inner(); - let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else { - err!("Invalid emergency access type.") - }; - - if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&data.wait_time_days) { - err!("Emergency access wait time is out-of-bounds!") - } - let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await else { err!("Emergency access not valid.") }; - emergency_access.atype = new_type as i32; + let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) { + new_type as i32 + } else { + err!("Invalid emergency access type.") + }; + + emergency_access.atype = new_type; emergency_access.wait_time_days = data.wait_time_days; if data.key_encrypted.is_some() { emergency_access.key_encrypted = data.key_encrypted; @@ -212,11 +207,11 @@ async fn send_invite(data: Json, headers: Headers, co let email = data.email.to_lowercase(); let wait_time_days = data.wait_time_days; - if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&wait_time_days) { - err!("Emergency access wait time is out-of-bounds!") - } + let emergency_access_status = EmergencyAccessStatus::Invited as i32; - let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else { + let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) { + new_type as i32 + } else { err!("Invalid emergency access type.") }; @@ -230,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.escape_debug())) + err!(format!("Grantee user does not exist: {email}")) } if !CONFIG.is_email_domain_allowed(&email) { @@ -262,8 +257,6 @@ async fn send_invite(data: Json, headers: Headers, co err!(format!("Grantee user already invited: {}", &grantee_user.email)) } - let new_type = new_type as i32; - let emergency_access_status = EmergencyAccessStatus::Invited as i32; let mut new_emergency_access = EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days); new_emergency_access.save(&conn).await?; @@ -296,7 +289,7 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbCon }; if emergency_access.status != EmergencyAccessStatus::Invited as i32 { - err!("The grantee user is already accepted or confirmed for emergency access"); + err!("The grantee user is already accepted or confirmed to the organization"); } let Some(email) = emergency_access.email.clone() else { @@ -457,24 +450,11 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers, let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await else { - err!( - "Emergency access not valid.", - format!("Emergency access '{emer_id}', not found for user '{}'.", initiating_user.email) - ) + err!("Emergency access not valid.") }; if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 { - err!( - "Emergency access not valid.", - format!("Emergency access '{emer_id}', not confirmed for user '{}'.", initiating_user.email) - ) - } - - if !EMERGENCY_ACCESS_TIMEOUT_RANGE.contains(&emergency_access.wait_time_days) { - err!( - "Emergency access not valid.", - format!("Emergency access '{emer_id}' timeout range '{}' is invalid", emergency_access.wait_time_days) - ) + err!("Emergency access not valid.") } let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { @@ -507,17 +487,11 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers, let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await else { - err!( - "Emergency access not valid.", - format!("Emergency access '{emer_id}', not found for user '{}'.", headers.user.email) - ) + err!("Emergency access not valid.") }; if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 { - err!( - "Emergency access not valid.", - format!("Emergency access '{emer_id}', not initiated for user '{}'.", headers.user.email) - ) + err!("Emergency access not valid.") } let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else { @@ -641,7 +615,6 @@ 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", }); @@ -651,13 +624,8 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct EmergencyAccessPasswordData { - // Legacy payload - new_master_password_hash: Option, - key: Option, - - // Current payload - authentication_data: Option, - unlock_data: Option, + new_master_password_hash: String, + key: String, } #[post("/emergency-access//password", data = "")] @@ -670,6 +638,8 @@ 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) = @@ -686,23 +656,8 @@ 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(new_key), true, None, &conn).await?; + grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?; grantor_user.save(&conn).await?; // Disable TwoFactor providers since they will otherwise block logins @@ -770,71 +725,52 @@ pub async fn emergency_request_timeout_job(pool: DbPool) { return; } - let Ok(conn) = pool.get().await else { - error!("Failed to get DB connection for emergency time-outs!"); - return; - }; + if let Ok(conn) = pool.get().await { + let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; - let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; - - if emergency_access_list.is_empty() { - debug!("No emergency request timeout to approve"); - return; - } - - let now = Utc::now().naive_utc(); - for mut emer in emergency_access_list { - match emer.is_recovery_allowed(&now) { - Ok(true) => {} // Recovery is allowed - Ok(false) => continue, - Err(e) => { - error!("Failed to check if recovery is allowed for '{}': {e}", emer.uuid); - continue; - } + if emergency_access_list.is_empty() { + debug!("No emergency request timeout to approve"); } - // Get grantor user to send Accepted email - let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else { - error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid); - continue; - }; + let now = Utc::now().naive_utc(); + for mut emer in emergency_access_list { + // The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None) + let recovery_allowed_at = + emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days)).unwrap(); + if recovery_allowed_at.le(&now) { + // Only update the access status + // Updating the whole record could cause issues when the emergency_notification_reminder_job is also active + emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn) + .await + .expect("Unable to update emergency access status"); - let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else { - error!("Missing grantee for emergency access '{}'", emer.uuid); - continue; - }; + if CONFIG.mail_enabled() { + // get grantor user to send Accepted email + let grantor_user = + User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found"); - // Get grantee user to send Accepted email - let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else { - error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid); - continue; - }; + // get grantee user to send Accepted email + let grantee_user = + User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn) + .await + .expect("Grantee user not found"); - // Only update the access status - // Updating the whole record could cause issues when the emergency_notification_reminder_job is also active - let status_initiated = EmergencyAccessStatus::RecoveryInitiated as i32; - let status_approved = EmergencyAccessStatus::RecoveryApproved as i32; - if let Err(e) = emer.update_access_status_and_save(status_initiated, status_approved, &now, &conn).await { - error!("Unable to update emergency access status for '{}': {e}", emer.uuid); - continue; - } + mail::send_emergency_access_recovery_timed_out( + &grantor_user.email, + &grantee_user.name, + emer.get_type_as_str(), + ) + .await + .expect("Error on sending email"); - if CONFIG.mail_enabled() { - if let Err(e) = mail::send_emergency_access_recovery_timed_out( - &grantor_user.email, - &grantee_user.name, - emer.get_type_as_str(), - ) - .await - { - error!("Failed to send emergency access recovery time-out email for '{}': {e}", emer.uuid); - } - - if let Err(e) = mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await - { - error!("Failed to send emergency access recovery approved email for '{}': {e}", emer.uuid); + mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name) + .await + .expect("Error on sending email"); + } } } + } else { + error!("Failed to get DB connection while searching emergency request timed out"); } } @@ -844,85 +780,55 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) { return; } - let Ok(conn) = pool.get().await else { - error!("Failed to get DB connection for emergency reminders!"); - return; - }; + if let Ok(conn) = pool.get().await { + let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; - let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; - - if emergency_access_list.is_empty() { - debug!("No emergency request reminder notification to send"); - return; - } - - let now = Utc::now().naive_utc(); - for mut emer in emergency_access_list { - let Ok(deadline_at) = emer.deadline_at() else { - error!("Invalid deadline for emergency access '{}'", emer.uuid); - continue; - }; - - let Some(final_reminder_at) = deadline_at.checked_sub_days(chrono::Days::new(1)) else { - error!("Cannot calculate reminder date for emergency access '{}'", emer.uuid); - continue; - }; - - // Only send reminders during the last 24 hours, else continue - if now < final_reminder_at || now >= deadline_at { - continue; + if emergency_access_list.is_empty() { + debug!("No emergency request reminder notification to send"); } - // Check if we did not sent a recovery mail in the past 24h already, else skip it. - // In case something goes wrong with the date calculation or `last_notification_at` is None we use now. - // This is to prevent possible missed reminders, this might cause an extra reminder, - // but that is better then no reminder at all. - let next_reminder_at = if let Some(last_notification_at) = emer.last_notification_at { - last_notification_at.checked_add_days(chrono::Days::new(1)).unwrap_or(now) - } else { - now - }; + let now = Utc::now().naive_utc(); + for mut emer in emergency_access_list { + // The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None) + // Calculate the day before the recovery will become active + let final_recovery_reminder_at = + emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days - 1)).unwrap(); + // Calculate if a day has passed since the previous notification, else no notification has been sent before + let next_recovery_reminder_at = if let Some(last_notification_at) = emer.last_notification_at { + last_notification_at + TimeDelta::try_days(1).unwrap() + } else { + now + }; + if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) { + // Only update the last notification date + // Updating the whole record could cause issues when the emergency_request_timeout_job is also active + emer.update_last_notification_date_and_save(&now, &conn) + .await + .expect("Unable to update emergency access notification date"); - // Only send a reminder if the `next_reminder_at` is less then or equal to `now` - // Else we can skip the current emergency access this run. - if next_reminder_at.gt(&now) { - continue; - } - - // Get grantor user to send Reminder email - let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else { - error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid); - continue; - }; - - let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else { - error!("Missing grantee for emergency access '{}'", emer.uuid); - continue; - }; - - // Get grantee user to send Reminder email - let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else { - error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid); - continue; - }; - - // Only update the last notification date - // Updating the whole record could cause issues when the emergency_request_timeout_job is also active - if let Err(e) = emer.update_last_notification_date_and_save(&now, &conn).await { - error!("Unable to update emergency access reminder date for '{}': {e}", emer.uuid); - continue; - } - - if CONFIG.mail_enabled() - && let Err(e) = mail::send_emergency_access_recovery_reminder( - &grantor_user.email, - &grantee_user.name, - emer.get_type_as_str(), - "1", // This notification is only triggered one day before the activation - ) - .await - { - error!("Failed to send emergency access reminder email for '{}': {e}", emer.uuid); + if CONFIG.mail_enabled() { + // get grantor user to send Accepted email + let grantor_user = + User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found"); + + // get grantee user to send Accepted email + let grantee_user = + User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn) + .await + .expect("Grantee user not found"); + + mail::send_emergency_access_recovery_reminder( + &grantor_user.email, + &grantee_user.name, + emer.get_type_as_str(), + "1", // This notification is only triggered one day before the activation + ) + .await + .expect("Error on sending email"); + } + } } + } else { + error!("Failed to get DB connection while searching emergency notification reminder"); } } diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 3b758bb2..2c437a36 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -6,13 +6,12 @@ use serde_json::Value; use crate::{ CONFIG, - api::{ApiResult, EmptyResult, JsonResult}, + api::{EmptyResult, JsonResult}, auth::{AdminHeaders, Headers}, db::{ DbConn, DbPool, models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId}, }, - error::MapResult, util::parse_date, }; @@ -30,14 +29,6 @@ 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 { @@ -48,7 +39,12 @@ 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, end_date) = data.date_range()?; + 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) + }; Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn) .await @@ -73,7 +69,12 @@ 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, end_date) = data.date_range()?; + 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) + }; Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect() } else { @@ -101,7 +102,12 @@ 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, end_date) = data.date_range()?; + 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) + }; Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn) .await @@ -162,10 +168,9 @@ async fn post_events_collect(data: Json>, headers: Headers, } for event in data.iter() { - 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. + let event_date = parse_date(&event.date); match event.r#type { - t if t == EventType::UserClientExportedVault as i32 => { + 1000..=1099 => { log_user_event_impl( event.r#type, &headers.user.uuid, @@ -176,7 +181,7 @@ async fn post_events_collect(data: Json>, headers: Headers, ) .await; } - t if t == EventType::OrganizationClientExportedVault as i32 => { + 1600..=1699 => { // 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() @@ -194,28 +199,7 @@ 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; - } - } - // Client-side cipher events (viewed, copied, toggled, autofilled, HTTP auth). - 1107..=1114 | 1117 | 1119..=1133 => { + _ => { // 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 @@ -236,17 +220,16 @@ async fn post_events_collect(data: Json>, headers: Headers, .await; } } - _ => {} } } Ok(()) } -pub async fn log_user_event(event_type: EventType, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { +pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { if !CONFIG.org_events_enabled() { return; } - log_user_event_impl(event_type as i32, user_id, device_type, None, ip, conn).await; + log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await; } async fn log_user_event_impl( @@ -347,17 +330,14 @@ async fn log_event_impl( pub async fn event_cleanup_job(pool: DbPool) { debug!("Start events cleanup job"); - if CONFIG.events_days_retain().is_none_or(|d| d == 0) { - debug!("events_days_retain is not configured or set to 0, skipping"); + if CONFIG.events_days_retain().is_none() { + debug!("events_days_retain is not configured, abort"); return; } - let Ok(conn) = pool.get().await else { + if let Ok(conn) = pool.get().await { + Event::clean_events(&conn).await.ok(); + } else { error!("Failed to get DB connection while trying to cleanup the events table"); - return; - }; - - if let Err(e) = Event::clean_events(&conn).await { - error!("Failed to cleanup the events table: {e}"); } } diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index b7923982..e6a184dd 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -247,7 +247,6 @@ fn config() -> Json { "notifications": format!("{domain}/notifications"), "sso": "", "cloudRegion": null, - "fillAssistRules": CONFIG.fill_assist_rules_url(), }, // Bitwarden uses this for the self-hosted servers to indicate the default push technology "push": { diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index bca0745f..4f490854 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -8,17 +8,17 @@ use crate::{ CONFIG, api::admin::FAKE_ADMIN_UUID, api::{ - ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, db::{ DbConn, models::{ - Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, - CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, - Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, - OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled, + 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, }, }, mail, @@ -26,13 +26,12 @@ use crate::{ util::{NumberOrString, convert_json_key_lcase_first}, }; -use super::accounts::{AuthenticationData, UnlockData}; - pub fn routes() -> Vec { routes![ get_organization, create_organization, delete_organization, + post_delete_organization, leave_organization, get_user_collections, get_org_collections, @@ -40,10 +39,13 @@ 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, @@ -58,6 +60,7 @@ pub fn routes() -> Vec { get_org_user_mini_details, get_user, edit_member, + put_member, delete_member, bulk_delete_member, post_org_import, @@ -67,6 +70,7 @@ pub fn routes() -> Vec { get_master_password_policy, get_policy, put_policy, + put_policy_vnext, get_plans, post_org_keys, get_organization_keys, @@ -82,16 +86,22 @@ 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, @@ -131,16 +141,13 @@ impl FullCollectionData { let org_groups = Group::find_by_organization(org_id, conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) { - err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id.escape_debug(), org_id)) + err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id, org_id)) } 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) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) { - err!( - "Invalid member", - format!("Member {} does not belong to organization {}!", e.id.escape_debug(), org_id) - ) + err!("Invalid member", format!("Member {} does not belong to organization {}!", e.id, org_id)) } Ok(()) @@ -238,6 +245,16 @@ 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 { @@ -283,6 +300,16 @@ 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"); @@ -388,9 +415,8 @@ 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_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect(); + Membership::find_confirmed_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() @@ -555,7 +581,7 @@ async fn post_bulk_access_collections( let org_groups = Group::find_by_organization(&org_id, &conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect(); if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) { - err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id.escape_debug(), org_id)) + err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id)) } for col_id in data.collection_ids { @@ -613,6 +639,17 @@ 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"); @@ -708,6 +745,16 @@ 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 { @@ -771,9 +818,11 @@ 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 - // 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 membership_type: HashMap = Membership::find_confirmed_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) @@ -911,9 +960,8 @@ 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_details_for_admin( + u.to_json_user_details( data.include_collections.unwrap_or(false), data.include_groups.unwrap_or(false), &conn, @@ -978,16 +1026,13 @@ impl InviteData { let org_collections = Collection::find_by_organization(org_id, conn).await; let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) { - err!( - "Invalid collection", - format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id) - ) + err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id)) } let org_groups = Group::find_by_organization(org_id, conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(g)) { - err!("Invalid group", format!("Group {} does not belong to organization {}!", e.escape_debug(), org_id)) + err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id)) } Ok(()) @@ -1008,12 +1053,14 @@ async fn send_invite( data.validate(&org_id, &conn).await?; // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission - // Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3) + // The from_str() will convert the custom role type into a manager role type let raw_type = &data.r#type.into_string(); - let Some(requested_type) = MembershipType::from_str(raw_type) else { + // Membership::from_str will convert custom (4) to manager (3) + let new_type = if let Some(new_type) = MembershipType::from_str(raw_type) { + new_type as i32 + } else { err!("Invalid type") }; - let new_type = MembershipType::requested(requested_type, None, custom_roles_enabled()); if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner { err!("Only Owners can invite Managers, Admins or Owners") @@ -1023,26 +1070,18 @@ async fn send_invite( // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes // If the box is not checked, the user will still be a manager, but not with the access_all permission let access_all = new_type >= MembershipType::Admin - || (new_type == MembershipType::Manager - && raw_type.eq("4") + || (raw_type.eq("4") && data.permissions.get("editAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("createNewCollections") == Some(&json!(true))); - // Checked before anything is created, so a rejected permission leaves nothing behind - let custom_permissions = if new_type == MembershipType::Custom { - Some(CustomPermission::requested(&data.permissions)?) - } else { - None - }; - - let mut user_created: bool; + let mut user_created: bool = false; for email in &data.emails { let mut member_status = MembershipStatus::Invited as i32; let user = match User::find_by_mail(email, &conn).await { None => { if !CONFIG.invitations_allowed() { - err!(format!("User does not exist: {}", email.escape_debug())) + err!(format!("User does not exist: {email}")) } if !CONFIG.is_email_domain_allowed(email) { @@ -1060,7 +1099,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.escape_debug())) + err!(format!("User already in organization: {email}")) } if !CONFIG.mail_enabled() { @@ -1071,17 +1110,13 @@ async fn send_invite( member_status = MembershipStatus::Accepted as i32; } } - user_created = false; user } }; let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); - new_member.set_type(new_type); new_member.access_all = access_all; - if let Some(permissions) = &custom_permissions { - new_member.set_custom_permissions(permissions); - } + new_member.atype = new_type; new_member.status = member_status; new_member.save(&conn).await?; @@ -1171,10 +1206,7 @@ async fn bulk_reinvite_members( for member_id in data.ids { let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await { Ok(()) => String::new(), - Err(e) => { - warn!("Unable to reinvite member {}: {e:?}", member_id.escape_debug()); - e.message().to_owned() - } + Err(e) => format!("{e:?}"), }; bulk_response.push(json!( @@ -1341,16 +1373,11 @@ async fn bulk_confirm_invite( match data.keys { Some(keys) => { for invite in keys { - let Some(member_id) = invite.id else { - err!("Member id is required") - }; + let member_id = invite.id.unwrap(); 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(), - Err(e) => { - warn!("Unable to confirm member {}: {e:?}", member_id.escape_debug()); - e.message().to_owned() - } + Err(e) => format!("{e:?}"), }; bulk_response.push(json!( @@ -1488,9 +1515,7 @@ 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_details_for_admin(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await, - )) + Ok(Json(user.to_json_user_details(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await)) } #[derive(Deserialize)] @@ -1504,6 +1529,17 @@ 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, @@ -1517,32 +1553,24 @@ async fn edit_member( let data: EditUserData = data.into_inner(); // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission - // Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3) + // The from_str() will convert the custom role type into a manager role type let raw_type = &data.r#type.into_string(); - let Some(requested_type) = MembershipType::from_str(raw_type) else { + // MembershipType::from_str will convert custom (4) to manager (3) + let Some(new_type) = MembershipType::from_str(raw_type) else { err!("Invalid type") }; - let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { - err!("The specified user isn't member of the organization") - }; - let new_type = MembershipType::requested(requested_type, Some(&member_to_edit), custom_roles_enabled()); - // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes // If the box is not checked, the user will still be a manager, but not with the access_all permission let access_all = new_type >= MembershipType::Admin - || (new_type == MembershipType::Manager - && raw_type.eq("4") + || (raw_type.eq("4") && data.permissions.get("editAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("createNewCollections") == Some(&json!(true))); - // While Custom roles are disabled, a Custom member keeps their stored permissions untouched - let custom_permissions = if new_type == MembershipType::Custom && custom_roles_enabled() { - Some(CustomPermission::requested(&data.permissions)?) - } else { - None + let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { + err!("The specified user isn't member of the organization") }; if new_type != member_to_edit.atype @@ -1566,11 +1594,8 @@ async fn edit_member( } } - member_to_edit.set_type(new_type); member_to_edit.access_all = access_all; - if let Some(permissions) = &custom_permissions { - member_to_edit.set_custom_permissions(permissions); - } + member_to_edit.atype = new_type as i32; // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // We need to perform the check after changing the type since `admin` is exempt. @@ -1642,10 +1667,7 @@ async fn bulk_delete_member( for member_id in data.ids { let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await { Ok(()) => String::new(), - Err(e) => { - warn!("Unable to delete member {}: {e:?}", member_id.escape_debug()); - e.message().to_owned() - } + Err(e) => format!("{e:?}"), }; bulk_response.push(json!( @@ -1812,10 +1834,6 @@ 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 @@ -1866,7 +1884,7 @@ async fn post_org_import( cipher_data.folder_id = None; // Replace the client-provided, unvalidated organizationId with the real target org cipher_data.organization_id = Some(org_id.clone()); - let mut cipher = Cipher::new(cipher_data.r#type); + let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); update_cipher_from_data( &mut cipher, cipher_data, @@ -1897,7 +1915,7 @@ async fn post_org_import( #[serde(rename_all = "camelCase")] struct BulkCollectionsData { organization_id: OrganizationId, - cipher_ids: HashSet, + cipher_ids: Vec, collection_ids: HashSet, remove_collections: bool, } @@ -2074,29 +2092,6 @@ async fn put_policy( err!("Invalid or unsupported policy type") }; - let current_policy = OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await; - let is_currently_enabled = current_policy.as_ref().is_some_and(|policy| policy.enabled); - - // Fill Assist depends on Single Organization. Match Bitwarden's dependency checks by only checking when the - // policy changes state. - if pol_type_enum == OrgPolicyType::FillAssist && data.enabled && !is_currently_enabled { - let single_org_policy_enabled = - OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await.is_some_and(|p| p.enabled); - - if !single_org_policy_enabled { - err!("Turn on the Single organization policy because it is required for the Activate fill assist policy.") - } - } - - if pol_type_enum == OrgPolicyType::SingleOrg && !data.enabled && is_currently_enabled { - let fill_assist_policy_enabled = - OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::FillAssist, &conn).await.is_some_and(|p| p.enabled); - - if fill_assist_policy_enabled { - err!("Turn off the Activate fill assist policy because it requires the Single organization policy.") - } - } - // Bitwarden only allows the Reset Password policy when Single Org policy is enabled // Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past // Now that groups are available we can enforce this option when wanted. @@ -2129,37 +2124,6 @@ async fn put_policy( } } - if pol_type_enum == OrgPolicyType::FillAssist { - let fill_assist_data = match data.data.as_ref() { - None | Some(Value::Null) => FillAssistPolicyData { - rules_url: None, - }, - Some(data) => { - let Ok(data) = serde_json::from_value::(data.clone()) else { - err!("Invalid data for FillAssist policy: rulesUrl has an invalid value.") - }; - data - } - }; - - if data.enabled { - let Some(rules_url) = fill_assist_data.rules_url.filter(|url| !url.trim().is_empty()) else { - err!("The RulesUrl field is required.") - }; - let Ok(rules_url) = url::Url::parse(&rules_url) else { - err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.") - }; - - if !rules_url.has_host() || !matches!(rules_url.scheme(), "http" | "https" | "ftp") { - err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.") - } - - if rules_url.scheme() != "https" { - err!("RulesUrl must use HTTPS.") - } - } - } - // When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled { two_factor::enforce_2fa_policy_for_org( @@ -2191,7 +2155,7 @@ async fn put_policy( } log_event( - EventType::OrganizationUserRevokedSingleOrganizationNonCompliance, + EventType::OrganizationUserRemoved, &member.uuid, &org_id, &headers.user.uuid, @@ -2207,7 +2171,7 @@ async fn put_policy( } } - let mut policy = match current_policy { + let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { Some(p) => p, None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()), }; @@ -2230,6 +2194,18 @@ 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. @@ -2258,6 +2234,12 @@ 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!({ @@ -2277,6 +2259,14 @@ 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 { @@ -2311,10 +2301,7 @@ async fn bulk_revoke_members( for member_id in members { let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await { Ok(()) => String::new(), - Err(e) => { - warn!("Unable to revoke member {}: {e:?}", member_id.escape_debug()); - e.message().to_owned() - } + Err(e) => format!("{e:?}"), }; bulk_response.push(json!( @@ -2417,10 +2404,7 @@ async fn bulk_restore_members( for member_id in data.ids { let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await { Ok(()) => String::new(), - Err(e) => { - warn!("Unable to restore member {}: {e:?}", member_id.escape_debug()); - e.message().to_owned() - } + Err(e) => format!("{e:?}"), }; bulk_response.push(json!( @@ -2573,16 +2557,13 @@ impl GroupRequest { let org_collections = Collection::find_by_organization(org_id, conn).await; let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) { - err!( - "Invalid collection", - format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id) - ) + err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id)) } 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) = self.users.iter().find(|m| !org_membership_ids.contains(m)) { - err!("Invalid member", format!("Member {} does not belong to organization {}!", e.escape_debug(), org_id)) + err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id)) } Ok(()) @@ -2604,6 +2585,17 @@ 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, @@ -2713,6 +2705,7 @@ 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,6 +2732,16 @@ 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 @@ -2840,6 +2843,90 @@ 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 { @@ -2851,14 +2938,9 @@ 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)] @@ -2869,26 +2951,8 @@ struct OrganizationUserRecoverAccountRequest { // But the clients do not seem to use this at all // Just add it here in case they will #[get("/organizations//public-key")] -async fn get_organization_public_key( - org_id: OrganizationId, - headers: Headers, - member: Result, - conn: DbConn, -) -> JsonResult { - // SSO users without an org get the fake one, whose key the v2 JIT password flow fetches anyway - if org_id.eq_ignore_ascii_case(FAKE_SSO_IDENTIFIER) && headers.user.private_key.is_none() { - return Ok(Json(json!({ - "object": "organizationPublicKey", - "publicKey": fake_sso_org_public_key().await?, - }))); - } - let member = match member { - Ok(member) => member, - // The guard already logged it - Err(e) => return Err(crate::error::Error::new_msg(e).with_code(Status::Unauthorized.code)), - }; - - if org_id != member.membership.org_uuid { +async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { + if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { @@ -2901,30 +2965,11 @@ async fn get_organization_public_key( }))) } -/// The SDK only uses this key to wrap an account recovery key that it never sends for the fake org, -/// so it is made once and its private half dropped. -async fn fake_sso_org_public_key() -> ApiResult { - static PUBLIC_KEY: std::sync::LazyLock> = std::sync::LazyLock::new(|| { - let der = openssl::rsa::Rsa::generate(2048).and_then(|rsa| rsa.public_key_to_der()).ok()?; - Some(data_encoding::BASE64.encode(&der)) - }); - - let Ok(Some(public_key)) = tokio::task::spawn_blocking(|| PUBLIC_KEY.clone()).await else { - err!("Failed to generate the organization key") - }; - Ok(public_key) -} - // Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492 #[get("/organizations//keys")] -async fn get_organization_keys( - org_id: OrganizationId, - headers: Headers, - member: Result, - conn: DbConn, -) -> JsonResult { - get_organization_public_key(org_id, headers, member, conn).await +async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { + get_organization_public_key(org_id, headers, conn).await } // Will allow to reset 2FA too @@ -2941,6 +2986,19 @@ 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, @@ -2973,33 +3031,10 @@ async fn recover_account( err!("Organization user must be confirmed for password reset functionality"); } - let fallback_2fa_email = req.reset_two_factor - && CONFIG.mail_enabled() - && CONFIG.email_2fa_auto_fallback() - && user.verified() - && TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none(); - - // 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)) + 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 { - None + false }; // Sending email first ensure working email configuration and the resulting user notification. @@ -3017,13 +3052,18 @@ async fn recover_account( err!(format!("Error sending user reset password email: {e:#?}")); } - 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_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 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?; @@ -3077,7 +3117,6 @@ 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, }))) @@ -3193,7 +3232,7 @@ async fn api_key( org_id: &OrganizationId, data: Json, rotate: bool, - headers: OwnerHeaders, + headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != &headers.org_id { @@ -3202,7 +3241,7 @@ async fn api_key( let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - // Validate the owner users password/otp + // Validate the admin 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 { @@ -3230,7 +3269,7 @@ async fn api_key( async fn post_api_key( org_id: OrganizationId, data: Json, - headers: OwnerHeaders, + headers: AdminHeaders, conn: DbConn, ) -> JsonResult { api_key(&org_id, data, false, headers, conn).await @@ -3240,7 +3279,7 @@ async fn post_api_key( async fn rotate_api_key( org_id: OrganizationId, data: Json, - headers: OwnerHeaders, + headers: AdminHeaders, conn: DbConn, ) -> JsonResult { api_key(&org_id, data, true, headers, conn).await diff --git a/src/api/core/public.rs b/src/api/core/public.rs index 1ee80515..3db25df9 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -58,13 +58,25 @@ 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 { - let revoked = member.atype != MembershipType::Owner && member.revoke(); + // 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 ext_modified = member.set_external_id(Some(user_data.external_id.clone())); if revoked || ext_modified { member.save(&conn).await?; @@ -72,7 +84,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.atype != MembershipType::Owner && member.restore(); + let mut restored = 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. @@ -137,13 +149,9 @@ 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(mut group) = + let group_uuid = if let Some(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 = @@ -172,8 +180,14 @@ 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 f8cc92bc..042ce95b 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::{Headers, Host, SendHeaders}, + auth::{ClientIp, Headers, Host, SendHeaders}, config::PathType, db::{ DbConn, DbPool, @@ -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 = DateTime::UNIX_EPOCH.naive_utc(); + let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z"); Device { uuid: String::from("00000000-0000-0000-0000-000000000000").into(), created_at: dt, @@ -39,15 +39,18 @@ static ANON_PUSH_DEVICE: LazyLock = LazyLock::new(|| { }); // The max file size allowed by Bitwarden clients and add an extra 5% to avoid issues -pub(crate) const SIZE_525_MB: i64 = 550_502_400; +const SIZE_525_MB: i64 = 550_502_400; pub fn routes() -> Vec { routes![ 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, @@ -70,7 +73,7 @@ pub async fn purge_sends(pool: DbPool) { #[serde(rename_all = "camelCase")] pub struct SendData { r#type: i32, - pub key: String, + key: String, password: Option, max_access_count: Option, expiration_date: Option>, @@ -198,7 +201,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/v2") + err!("File sends should use /api/sends/file") } let mut send = create_send(data, headers.user.uuid)?; @@ -215,11 +218,93 @@ 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 { @@ -268,12 +353,11 @@ async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) let file_id = crate::crypto::generate_send_file_id(); let mut data_value: Value = serde_json::from_str(&send.data)?; - let Some(o) = data_value.as_object_mut() else { - err!("Invalid Send file data") - }; - o.insert(String::from("id"), Value::String(file_id.clone())); - o.insert(String::from("size"), Value::Number(file_length.into())); - o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length))); + if let Some(o) = data_value.as_object_mut() { + o.insert(String::from("id"), Value::String(file_id.clone())); + o.insert(String::from("size"), Value::Number(file_length.into())); + o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length))); + } send.data = serde_json::to_string(&data_value)?; send.save(&conn).await?; @@ -329,8 +413,8 @@ async fn post_send_file_v2_data( "Send file name does not match.", format!( "Expected file name '{}' got '{}'", - send_data.fileName.escape_debug(), - raw_file_name.dangerous_unsafe_unsanitized_raw().as_str().escape_debug() + send_data.fileName, + raw_file_name.dangerous_unsafe_unsanitized_raw() ) ), _ => err!("Send file name does not match or is not provided."), @@ -366,16 +450,63 @@ async fn post_send_file_v2_data( #[post("/sends/access")] async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult { - let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else { + let Some(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) } - // Files are incremented during the download - if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? { + 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?; + } + process_access(send, conn, nt).await } @@ -400,27 +531,55 @@ async fn post_access_file( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else { + let Some(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) } - 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 } -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."); +// 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) } - match serde_json::from_str::(&send.data) { - Ok(data) if &data.id == file_id => Ok(()), - _ => 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."), + None => err_code!("Password not provided", 401), + } + } + + if !send.register_access(&conn).await? { + 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 { @@ -483,7 +642,7 @@ async fn put_send(send_id: SendId, data: Json, headers: Headers, conn: Ok(Json(send.to_json())) } -async fn update_send_from_data( +pub async fn update_send_from_data( send: &mut Send, data: SendData, headers: &Headers, diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index 55984443..692e8248 100644 --- a/src/api/core/two_factor/authenticator.rs +++ b/src/api/core/two_factor/authenticator.rs @@ -3,7 +3,7 @@ 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, two_factor}, + auth::{ClientIp, Headers}, crypto, db::{ DbConn, @@ -20,23 +20,27 @@ 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 twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await; + let type_ = TwoFactorType::Authenticator as i32; + let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &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!({ - "authenticator": json!({ - "enabled": enabled, - "key": key, - }), - "userVerificationToken": two_factor::authenticator_token(user.uuid, key, enabled), + "enabled": enabled, + "key": key, + "object": "twoFactorAuthenticator" }))) } @@ -45,7 +49,8 @@ async fn generate_authenticator(data: Json, headers: Headers, struct EnableAuthenticatorData { key: String, token: NumberOrString, - user_verification_token: String, + master_password_hash: Option, + otp: Option, } #[post("/two-factor/authenticator", data = "")] @@ -56,7 +61,12 @@ async fn activate_authenticator(data: Json, headers: He let mut user = headers.user; - two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &key, false)?; + PasswordOrOtpData { + master_password_hash: data.master_password_hash, + otp: data.otp, + } + .validate(&user, true, &conn) + .await?; // Validate key as base32 and 20 bytes length let decoded_key: Vec = if let Ok(decoded) = BASE32.decode(key.as_bytes()) { @@ -74,13 +84,12 @@ async fn activate_authenticator(data: Json, headers: He generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ - "authenticator": json!({ - "enabled": true, - "key": key, - }), + "enabled": true, + "key": key, + "object": "twoFactorAuthenticator" }))) } @@ -116,7 +125,8 @@ pub async fn validate_totp_code( err!("Invalid TOTP secret") }; - let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator, conn).await { + let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await + { Some(tf) => tf, _ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()), }; @@ -174,25 +184,36 @@ pub async fn validate_totp_code( #[serde(rename_all = "camelCase")] struct DisableAuthenticatorData { key: String, - user_verification_token: String, + master_password_hash: String, + r#type: NumberOrString, } #[delete("/two-factor/authenticator", data = "")] -async fn disable_authenticator(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +async fn disable_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let user = headers.user; + let type_ = data.r#type.into_i32()?; - two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &data.key, true)?; + if !user.check_valid_password(&data.master_password_hash) { + err!("Invalid password"); + } - if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await { + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { if twofactor.data == data.key { twofactor.delete(&conn).await?; - log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserDisabled2fa as i32, &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)); } } - super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?; + 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?; + } - Ok(()) + Ok(Json(json!({ + "enabled": false, + "keys": type_, + "object": "twoFactorProvider" + }))) } diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index 23116b2c..ed112eb9 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -1,13 +1,14 @@ use chrono::Utc; +use data_encoding::BASE64; use rocket::{Route, serde::json::Json}; use crate::{ + CONFIG, api::{ - ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, - core::log_user_event, - core::two_factor::{VerificationTokenData, generate_recover_code}, + ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, + core::two_factor::generate_recover_code, }, - auth::{Headers, two_factor, two_factor::DuoData}, + auth::Headers, crypto, db::{ DbConn, @@ -18,7 +19,55 @@ use crate::{ }; pub fn routes() -> Vec { - routes![get_duo, activate_duo, activate_duo_put, disable_duo,] + 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, + } + } } enum DuoStatus { @@ -47,19 +96,22 @@ async fn get_duo(data: Json, headers: Headers, conn: DbConn) data.validate(&user, false, &conn).await?; - let (enabled, duo) = match get_user_duo_data(&user.uuid, &conn).await { + let data = get_user_duo_data(&user.uuid, &conn).await; + + let (enabled, data) = match data { 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 duo_json = if let Some(data) = duo.as_ref() { + let json = if let Some(data) = data { json!({ "enabled": enabled, "host": data.host, "clientSecret": data.sk, "clientId": data.ik, + "object": "twoFactorDuo" }) } else { json!({ @@ -67,13 +119,11 @@ async fn get_duo(data: Json, headers: Headers, conn: DbConn) "host": null, "clientSecret": null, "clientId": null, + "object": "twoFactorDuo" }) }; - Ok(Json(rocket::serde::json::json!({ - "duo": duo_json, - "userVerificationToken": two_factor::duo_token(user.uuid, duo, enabled), - }))) + Ok(Json(json)) } #[derive(Deserialize)] @@ -82,7 +132,8 @@ struct EnableDuoData { host: String, client_secret: String, client_id: String, - user_verification_token: String, + master_password_hash: Option, + otp: Option, } impl From for DuoData { @@ -109,7 +160,12 @@ async fn activate_duo(data: Json, headers: Headers, conn: DbConn) let data: EnableDuoData = data.into_inner(); let mut user = headers.user; - two_factor::validate_duo(&data.user_verification_token, &user.uuid, None, false)?; + PasswordOrOtpData { + master_password_hash: data.master_password_hash.clone(), + otp: data.otp.clone(), + } + .validate(&user, true, &conn) + .await?; let (data, data_str) = if check_duo_fields_custom(&data) { let data_req: DuoData = data.into(); @@ -126,15 +182,14 @@ async fn activate_duo(data: Json, headers: Headers, conn: DbConn) generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ - "duo": json!({ - "enabled": true, - "host": data.host, - "clientSecret": data.sk, - "clientId": data.ik, - }), + "enabled": true, + "host": data.host, + "clientSecret": data.sk, + "clientId": data.ik, + "object": "twoFactorDuo" }))) } @@ -143,29 +198,6 @@ 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; @@ -190,16 +222,21 @@ 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; + // If the user doesn't have an entry, disabled - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Duo, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, type_, 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); @@ -214,13 +251,118 @@ fn to_user_duo_data(twofactor: &TwoFactor) -> DuoStatus { DuoStatus::Disabled(false) } -// 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 (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 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, data.host)) + 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()) } diff --git a/src/api/core/two_factor/duo_oidc.rs b/src/api/core/two_factor/duo_oidc.rs index e5e1df0f..adb030af 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 15bfdbc3..3667b871 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -5,26 +5,20 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, PasswordOrOtpData, - core::{ - log_user_event, - two_factor::{VerificationTokenData, generate_recover_code}, - }, + core::{log_user_event, two_factor::generate_recover_code}, }, - auth::{ClientHeaders, Headers, two_factor}, + auth::{ClientHeaders, Headers}, crypto, db::{ DbConn, - models::{ - AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, - UserId, - }, + models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, }, error::{Error, MapResult}, mail, }; pub fn routes() -> Vec { - routes![get_email, send_email_login, send_email, email, disable_email] + routes![get_email, send_email_login, send_email, email,] } #[derive(Deserialize)] @@ -71,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: {}.", client_headers.ip.ip, email.escape_debug()) + format!("IP: {}. Username: {email}.", client_headers.ip.ip) ) }; @@ -80,45 +74,23 @@ 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: {}.", client_headers.ip.ip, email.escape_debug()) + format!("IP: {}. Username: {email}.", client_headers.ip.ip) ) } } else if let Some(auth_request_id) = auth_request_id { + let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else { + err!("AuthRequest doesn't exist", "User not found") + }; let Some(code) = &data.auth_request_access_code else { err!("no auth request access code") }; - let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, &conn).await - else { - err!("AuthRequest doesn't exist", "Invalid auth request or does not belong to user") - }; - let request_expired = Utc::now().naive_utc() >= auth_request.creation_date + TimeDelta::minutes(5); - if !auth_request.approved.unwrap_or(false) - || auth_request.authentication_date.is_some() - || request_expired - || auth_request.device_type != client_headers.device_type + if auth_request.device_type != client_headers.device_type || auth_request.request_ip != client_headers.ip.ip.to_string() || !auth_request.check_access_code(code) { - err!( - "AuthRequest doesn't exist", - "Auth request not approved, already used or expired, or invalid device, IP or code" - ) + 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.") } @@ -132,7 +104,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: {}.", client_headers.ip.ip, device_identifier.to_string().escape_debug()) + format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip) ) }; @@ -144,8 +116,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 mut twofactor = - TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?; + 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 generated_token = crypto::generate_email_token(CONFIG.email_token_size()); @@ -168,19 +140,18 @@ async fn get_email(data: Json, headers: Headers, conn: DbConn data.validate(&user, false, &conn).await?; let (enabled, mfa_email) = - 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) + 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), }; - 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), + Ok(Json(json!({ + "email": mfa_email, + "enabled": enabled, + "object": "twoFactorEmail" }))) } @@ -189,36 +160,43 @@ 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, - user_verification_token: String, + master_password_hash: Option, + otp: Option, } /// 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) -> JsonResult { +async fn send_email(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let data: SendEmailData = data.into_inner(); let user = headers.user; - two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email.clone(), false)?; + PasswordOrOtpData { + master_password_hash: data.master_password_hash, + otp: data.otp, + } + .validate(&user, false, &conn) + .await?; if !CONFIG._enable_email_2fa() { err!("Email 2FA is disabled") } - if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await { + let type_ = TwoFactorType::Email as i32; + + if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { tf.delete(&conn).await?; } let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); - let twofactor_data = EmailTokenData::new(data.email, Some(generated_token)); + let twofactor_data = EmailTokenData::new(data.email, generated_token); // Uses EmailVerificationChallenge as type to show that it's not verified yet. - let twofactor = - TwoFactor::new(user.uuid.clone(), TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json()); + let twofactor = TwoFactor::new(user.uuid, 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(Json(json!({}))) + Ok(()) } #[derive(Deserialize, Serialize)] @@ -226,7 +204,8 @@ async fn send_email(data: Json, headers: Headers, conn: DbConn) - struct EmailData { email: String, token: String, - user_verification_token: String, + master_password_hash: Option, + otp: Option, } /// Verify email belongs to user and can be used for 2FA email codes. @@ -235,11 +214,17 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes let data: EmailData = data.into_inner(); let mut user = headers.user; - two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email, false)?; + // 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?; - let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge, &conn) - .await - .map_res("Two factor not found")?; + 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 email_data = EmailTokenData::from_json(&twofactor.data)?; @@ -258,26 +243,13 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; - 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(()) + Ok(Json(json!({ + "email": email_data.email, + "enabled": "true", + "object": "twoFactorEmail" + }))) } /// Validate the email code when used as TwoFactor token mechanism @@ -289,8 +261,9 @@ 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, conn).await.map_res("Two factor not found")?; + let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn) + .await + .map_res("Two factor not found")?; let Some(issued_token) = &email_data.last_token else { err!( format!("No token available! IP: {ip}"), @@ -302,7 +275,7 @@ pub async fn validate_email_code_str( if !crypto::ct_eq(issued_token, token) { email_data.add_attempt(); - if email_data.attempts >= u64::from(CONFIG.email_attempts_limit()) { + if email_data.attempts >= CONFIG.email_attempts_limit() { email_data.reset_token(); } twofactor.data = email_data.to_json(); @@ -321,7 +294,7 @@ pub async fn validate_email_code_str( twofactor.save(conn).await?; let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc(); - let max_time = i64::from(CONFIG.email_expiration_time()); + let max_time = CONFIG.email_expiration_time().cast_signed(); if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() { err!( "Token has expired", @@ -349,10 +322,10 @@ pub struct EmailTokenData { } impl EmailTokenData { - pub fn new(email: String, token: Option) -> EmailTokenData { + pub fn new(email: String, token: String) -> EmailTokenData { EmailTokenData { email, - last_token: token, + last_token: Some(token), token_sent: Utc::now().timestamp(), attempts: 0, } @@ -390,8 +363,7 @@ 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!"); } - // The token is set when the first code is sent - let twofactor_data = EmailTokenData::new(user.email.clone(), None); + let twofactor_data = EmailTokenData::new(user.email.clone(), String::new()); 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 cfec98c7..0eb6563e 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -7,7 +7,10 @@ use serde_json::Value; use crate::{ CONFIG, - api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_event}, + api::{ + EmptyResult, JsonResult, PasswordOrOtpData, + core::{log_event, log_user_event}, + }, auth::Headers, crypto, db::{ @@ -18,6 +21,7 @@ use crate::{ }, }, mail, + util::NumberOrString, }; pub mod authenticator; @@ -65,7 +69,13 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data } pub fn routes() -> Vec { - let mut routes = routes![get_twofactor, get_recover, get_device_verification_settings]; + let mut routes = routes![ + get_twofactor, + get_recover, + disable_twofactor, + disable_twofactor_put, + get_device_verification_settings, + ]; routes.append(&mut authenticator::routes()); routes.append(&mut duo::routes()); @@ -77,12 +87,6 @@ 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; @@ -122,14 +126,50 @@ async fn generate_recover_code(user: &mut User, conn: &DbConn) { } } -/// 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?; +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DisableTwoFactorData { + master_password_hash: Option, + otp: Option, + r#type: NumberOrString, +} + +#[post("/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, } - Ok(()) + .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" + }))) +} + +#[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( @@ -151,7 +191,7 @@ pub async fn enforce_2fa_policy( member.save(conn).await?; log_event( - EventType::OrganizationUserRevokedTwoFactorNonCompliance, + EventType::OrganizationUserRevoked, &member.uuid, &member.org_uuid, act_user_id, @@ -185,16 +225,8 @@ pub async fn enforce_2fa_policy_for_org( member.revoke(); member.save(conn).await?; - log_event( - EventType::OrganizationUserRevokedTwoFactorNonCompliance, - &member.uuid, - org_id, - act_user_id, - device_type, - ip, - conn, - ) - .await; + log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn) + .await; } } @@ -204,7 +236,7 @@ pub async fn enforce_2fa_policy_for_org( pub async fn send_incomplete_2fa_notifications(pool: DbPool) { debug!("Sending notifications for incomplete 2FA logins"); - if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() { + if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { return; } @@ -214,21 +246,11 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) { }; let now = Utc::now().naive_utc(); - let Some(time_limit) = TimeDelta::try_minutes(i64::from(CONFIG.incomplete_2fa_time_limit())) else { - error!("Failed to calculate the time limit for sending incomplete 2fa attempts"); - return; - }; - let Some(time_before) = now.checked_sub_signed(time_limit) else { - error!("Failed to calculate the timestamp for sending incomplete 2fa attempts"); - return; - }; - + let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap(); + let time_before = now - time_limit; let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await; for login in incomplete_logins { - let Some(user) = User::find_by_uuid(&login.user_uuid, &conn).await else { - error!("User '{}', not found!", login.user_uuid); - continue; - }; + let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found"); info!( "User {} did not complete a 2FA login within the configured time limit. IP: {}", user.email, login.ip_address diff --git a/src/api/core/two_factor/protected_actions.rs b/src/api/core/two_factor/protected_actions.rs index bf757d14..c0c1b5e8 100644 --- a/src/api/core/two_factor/protected_actions.rs +++ b/src/api/core/two_factor/protected_actions.rs @@ -72,7 +72,8 @@ 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, &conn).await { + if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &conn).await + { let pa_data = ProtectedActionData::from_json(&pa.data)?; let elapsed = pa_data.time_since_sent().num_seconds(); let delay = 30; @@ -124,28 +125,27 @@ 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, conn) + let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, 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)?; + pa_data.add_attempt(); + pa.data = pa_data.to_json(); // Fail after x attempts if the token has been used too many times. // Don't delete it, as we use it to keep track of attempts. - if pa_data.attempts >= u64::from(CONFIG.email_attempts_limit()) { + if pa_data.attempts >= CONFIG.email_attempts_limit() { err!("Token has expired") } // Check if the token has expired (Using the email 2fa expiration time) - let max_time = i64::from(CONFIG.email_expiration_time()); + let max_time = CONFIG.email_expiration_time().cast_signed(); if pa_data.time_since_sent().num_seconds() > max_time { pa.delete(conn).await?; err!("Token has expired") } if !crypto::ct_eq(&pa_data.token, otp) { - pa_data.add_attempt(); - pa.data = pa_data.to_json(); - pa.save(conn).await?; err!("Token is invalid") } diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 7eab5546..07b964e5 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, str::FromStr, sync::LazyLock, time::Duration}; +use std::{str::FromStr, sync::LazyLock, time::Duration}; use rocket::{Route, serde::json::Json}; use serde_json::Value; @@ -18,18 +18,16 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, PasswordOrOtpData, - core::{ - log_user_event, - two_factor::{VerificationTokenData, generate_recover_code}, - }, + core::{log_user_event, two_factor::generate_recover_code}, }, - auth::{Headers, two_factor}, + auth::Headers, crypto::ct_eq, db::{ DbConn, models::{EventType, TwoFactor, TwoFactorType, UserId}, }, error::Error, + util::NumberOrString, }; static WEBAUTHN: LazyLock = LazyLock::new(|| { @@ -47,14 +45,7 @@ static WEBAUTHN: LazyLock = LazyLock::new(|| { }); pub fn routes() -> Vec { - routes![ - get_webauthn, - generate_webauthn_challenge, - activate_webauthn, - activate_webauthn_put, - delete_webauthn, - delete_webauthns - ] + routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,] } // Some old u2f structs still needed for migrating from u2f to WebAuthn @@ -128,36 +119,34 @@ 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!({ - "webAuthn": json!({ - "enabled": enabled, - "keys": registrations_json, - }), - "userVerificationToken": two_factor::webauthn_token(user.uuid, keys, enabled), + "enabled": enabled, + "keys": registrations_json, + "object": "twoFactorWebAuthn" }))) } #[post("/two-factor/get-webauthn-challenge", data = "")] -async fn generate_webauthn_challenge(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn generate_webauthn_challenge(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?; - let keys: Vec = registrations.iter().map(|r| r.id).collect(); - let creds = registrations + data.validate(&user, false, &conn).await?; + + let registrations = get_webauthn_registrations(&user.uuid, &conn) + .await? + .1 .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(creds), + Some(registrations), )?; let mut state = serde_json::to_value(&state)?; @@ -177,19 +166,17 @@ async fn generate_webauthn_challenge(data: Json, headers: let mut challenge_value = serde_json::to_value(challenge.public_key)?; challenge_value["status"] = "ok".into(); challenge_value["errorMessage"] = "".into(); - - Ok(Json(json!({ - "options": challenge_value - }))) + Ok(Json(challenge_value)) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EnableWebauthnData { - id: i32, + id: NumberOrString, // 1..5 name: String, device_response: RegisterPublicKeyCredentialCopy, - user_verification_token: String, + master_password_hash: Option, + otp: Option, } #[derive(Debug, Deserialize)] @@ -270,14 +257,16 @@ async fn activate_webauthn(data: Json, headers: Headers, con let data: EnableWebauthnData = data.into_inner(); let mut user = headers.user; - 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())?; + PasswordOrOtpData { + master_password_hash: data.master_password_hash, + otp: data.otp, + } + .validate(&user, true, &conn) + .await?; // Retrieve and delete the saved challenge state - let state = if let Some(tf) = - TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnRegisterChallenge, &conn).await - { + 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: PasskeyRegistration = serde_json::from_str(&tf.data)?; tf.delete(&conn).await?; state @@ -288,9 +277,10 @@ 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, + id: data.id.into_i32()?, name: data.name, migrated: false, @@ -303,13 +293,13 @@ async fn activate_webauthn(data: Json, headers: Headers, con .await?; generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + let keys_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ - "webAuthn": json!({ - "enabled": true, - "keys": registrations.iter().map(WebauthnRegistration::to_json).collect::>(), - }), + "enabled": true, + "keys": keys_json, + "object": "twoFactorU2f" }))) } @@ -320,87 +310,57 @@ async fn activate_webauthn_put(data: Json, headers: Headers, #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct DeleteWebauthnData { - id: i32, - user_verification_token: String, +struct DeleteU2FData { + id: NumberOrString, + master_password_hash: String, } #[delete("/two-factor/webauthn", data = "")] -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 -} +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"); + } -#[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(&user.uuid, TwoFactorType::Webauthn, conn).await else { + let Some(mut tf) = + TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await + else { err!("Webauthn data not found!") }; - let mut keys: Vec = serde_json::from_str(&tf.data)?; - let keys_id: Vec = keys.iter().map(|r| r.id).collect(); + let mut data: Vec = serde_json::from_str(&tf.data)?; - two_factor::validate_webauthn(token, &user.uuid, &keys_id, true)?; - - 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() { + let Some(item_pos) = data.iter().position(|r| r.id == id) else { err!("Webauthn entry not found") - } + }; - 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); - } + let removed_item = data.remove(item_pos); + tf.data = serde_json::to_string(&data)?; + tf.save(&conn).await?; + drop(tf); // If entry is migrated from u2f, delete the u2f entry as well - 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 { + 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 { err!("Error parsing U2F data") }; - data.retain(|old| !removed.contains(&old.reg.key_handle)); + data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); + let new_data_str = serde_json::to_string(&data)?; - 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?; - } + u2f.data = new_data_str; + u2f.save(&conn).await?; } - super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, conn).await?; + let keys_json: Vec = data.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ - "webAuthn": json!({ - "enabled": !keys.is_empty(), - "keys": keys.iter().map(WebauthnRegistration::to_json).collect::>(), - }), + "enabled": true, + "keys": keys_json, + "object": "twoFactorU2f" }))) } @@ -408,7 +368,8 @@ pub async fn get_webauthn_registrations( user_id: &UserId, conn: &DbConn, ) -> Result<(bool, Vec), Error> { - match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Webauthn, conn).await { + let type_ = TwoFactorType::Webauthn as i32; + match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)), None => Ok((false, Vec::new())), // If no data, return empty list } @@ -455,9 +416,8 @@ 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 mut state = if let Some(tf) = - TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginChallenge, conn).await - { + 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 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 17c4e9a0..eb3d6dfd 100644 --- a/src/api/core/two_factor/yubikey.rs +++ b/src/api/core/two_factor/yubikey.rs @@ -10,12 +10,9 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, PasswordOrOtpData, - core::{ - log_user_event, - two_factor::{VerificationTokenData, generate_recover_code}, - }, + core::{log_user_event, two_factor::generate_recover_code}, }, - auth::{Headers, two_factor}, + auth::Headers, db::{ DbConn, models::{EventType, TwoFactor, TwoFactorType}, @@ -25,7 +22,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![generate_yubikey, activate_yubikey, activate_yubikey_put, delete_yubikeys,] + routes![generate_yubikey, activate_yubikey, activate_yubikey_put,] } struct HttpClientTransport { @@ -63,7 +60,8 @@ struct EnableYubikeyData { key4: Option, key5: Option, nfc: bool, - user_verification_token: String, + master_password_hash: Option, + otp: Option, } #[derive(Deserialize, Serialize, Debug)] @@ -127,47 +125,55 @@ 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 (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), - }))) + 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", + }))) + } } #[post("/two-factor/yubikey", data = "")] async fn activate_yubikey(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: EnableYubikeyData = data.into_inner(); - let yubikeys = parse_yubikeys(&data); 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); + if yubikeys.is_empty() { // Return an error to prevent saving empty keys which would cause users not being able to login anymore. // To remove all keys users should click the `Deactivate all keys` button 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 { @@ -189,12 +195,15 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa as i32, &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); - Ok(Json(json!({"yubiKey": result}))) + result["object"] = Value::String("twoFactorU2f".to_owned()); + + Ok(Json(result)) } #[put("/two-factor/yubikey", data = "")] @@ -202,32 +211,13 @@ 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"); } let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata"); - let Some(response_id) = response.get(..12) else { - err!("Invalid Yubikey OTP"); - }; + let response_id = &response[..12]; 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 1c7b2a57..8f3e730b 100644 --- a/src/api/icons.rs +++ b/src/api/icons.rs @@ -62,7 +62,7 @@ static CLIENT: LazyLock = LazyLock::new(|| { // Generate the cookie store let cookie_store = Arc::new(Jar::default()); - let icon_download_timeout = Duration::from_secs(u64::from(CONFIG.icon_download_timeout())); + let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout()); let pool_idle_timeout = Duration::from_secs(10); // Reuse the client between requests get_reqwest_client_builder(true) @@ -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.escape_debug()); + warn!("Invalid host: {host}"); 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.escape_debug()); + warn!("Invalid host: {host}"); return Cached::ttl( (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), CONFIG.icon_cache_negttl(), @@ -193,14 +193,14 @@ async fn get_cached_icon(path: &str) -> Option> { None } -async fn file_is_expired(path: &str, ttl: u32) -> Result { +async fn file_is_expired(path: &str, ttl: u64) -> Result { let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?; let meta = operator.stat(path).await?; let modified = meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?; let age = SystemTime::now().duration_since(modified.into())?; - Ok(ttl > 0 && u64::from(ttl) <= age.as_secs()) + Ok(ttl > 0 && ttl <= age.as_secs()) } async fn icon_is_negcached(path: &str) -> bool { @@ -264,7 +264,7 @@ fn get_favicons_node(dom: Tokenizer, FaviconEmitter>, icons: &m TAG_BASE => { base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) { let href = std::str::from_utf8(href).unwrap_or_default(); - debug!("Found base href: {}", href.escape_debug()); + debug!("Found base href: {href}"); match base_url.join(href) { Ok(inner_url) => inner_url, _ => continue, @@ -524,7 +524,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { Ok(()) }) { Ok(_) => { - // Also check if the size is at least 67 bytes, which seems to be the smallest png I could create + // Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create if body.len() >= 67 { // Check if the icon type is allowed, else try an icon from the list. icon_type = get_icon_type(&body); @@ -663,17 +663,6 @@ impl reqwest::cookie::CookieStore for Jar { use time::Duration; let mut cookie_store = self.0.write().unwrap(); - - // The store only skips expired cookies when reading them, so remove them here or they are kept forever - let expired: Vec<_> = cookie_store - .iter_any() - .filter(|c| c.is_expired()) - .map(|c| (String::from(&c.domain), String::from(&c.path), c.name().to_owned())) - .collect(); - for (domain, path, name) in expired { - cookie_store.remove(&domain, &path, &name); - } - let cookies = cookie_headers.filter_map(|val| { std::str::from_utf8(val.as_bytes()) .map_err(RawCookieParseError::from) diff --git a/src/api/identity.rs b/src/api/identity.rs index d7ae5068..6808ddde 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -17,19 +17,20 @@ use crate::{ accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, log_user_event, two_factor::{ - authenticator, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey, + authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, + yubikey, }, }, - identity_master_password_policy, + master_password_policy, push::register_push_device, }, auth, - auth::{AuthMethod, ClientHeaders, ClientIp, Secure, generate_organization_api_key_login_claims}, + auth::{AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims}, crypto, db::{ DbConn, models::{ - AuthRequest, AuthRequestId, Device, DeviceId, DeviceType, EventType, Invitation, OIDCCodeResponseError, + AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId, }, @@ -45,8 +46,8 @@ pub fn routes() -> Vec { login, post_prelogin, prelogin_password, + identity_register, register_verification_email, - register_verification_email_clicked, register_finish, prevalidate, authorize, @@ -56,7 +57,12 @@ pub fn routes() -> Vec { } #[post("/connect/token", data = "")] -async fn login(data: Form, client_header: ClientHeaders, conn: DbConn) -> JsonResult { +async fn login( + data: Form, + client_header: ClientHeaders, + client_version: Option, + conn: DbConn, +) -> JsonResult { let data: ConnectData = data.into_inner(); let mut user_id: Option = None; @@ -77,14 +83,18 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo 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).await + password_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await } "client_credentials" => { check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?; check_is_some(data.scope.as_ref(), "scope cannot be blank")?; - api_key_login(data, &mut user_id, &conn, &client_header).await + 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() => { check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; @@ -95,7 +105,7 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo 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).await + sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await } "authorization_code" => err!("SSO sign-in is not available"), "send_access" => { @@ -112,14 +122,14 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo .await?; Ok(Json(tokens.to_json())) } - t => err!("Invalid type", t.escape_debug().to_string()), + t => err!("Invalid type", t), }; if let Some(user_id) = user_id { match &login_result { Ok(_) => { log_user_event( - EventType::UserLoggedIn, + EventType::UserLoggedIn as i32, &user_id, client_header.device_type, &client_header.ip.ip, @@ -129,7 +139,8 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo } Err(e) => { if let Some(ev) = e.get_event() { - log_user_event(ev.event, &user_id, client_header.device_type, &client_header.ip.ip, &conn).await; + log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn) + .await; } } } @@ -183,9 +194,9 @@ async fn sso_login( data: ConnectData, user_id: &mut Option, conn: &DbConn, - client_header: &ClientHeaders, + ip: &ClientIp, + client_version: Option<&ClientVersion>, ) -> JsonResult { - let ip = &client_header.ip; AuthMethod::Sso.check_scope(data.scope.as_ref())?; // Ratelimit the login @@ -323,8 +334,6 @@ async fn sso_login( } let mut user = User::new(&user_infos.email, user_infos.user_name.clone()); - // Before saving, so that a client that can't log in doesn't create the account - check_client_version(&user, client_header)?; user.verified_at = Some(now); user.save(conn).await?; @@ -333,8 +342,6 @@ async fn sso_login( (user, device, None, None) } Some((user, _)) if !user.enabled => { - // Set in both arms, not only after the match, so that their failed logins are logged - *user_id = Some(user.uuid.clone()); err!( "This user has been disabled", format!("IP: {}. Username: {}.", ip.ip, user.email), @@ -344,12 +351,9 @@ async fn sso_login( ) } Some((mut user, sso_user)) => { - *user_id = Some(user.uuid.clone()); - check_client_version(&user, client_header)?; - let mut device = get_device(&data, conn, &user).await?; - let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?; + let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; if user.private_key.is_none() { // User was invited a stub was created @@ -385,9 +389,9 @@ async fn password_login( data: ConnectData, user_id: &mut Option, conn: &DbConn, - client_header: &ClientHeaders, + ip: &ClientIp, + client_version: Option<&ClientVersion>, ) -> JsonResult { - let ip = &client_header.ip; // Validate scope AuthMethod::Password.check_scope(data.scope.as_ref())?; @@ -396,14 +400,24 @@ 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: {log_username}.", ip.ip)) + err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {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 @@ -411,7 +425,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: {log_username}.", ip.ip), + format!("IP: {}. Username: {username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } @@ -423,14 +437,13 @@ async fn password_login( 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) { err!( "Username or access code is incorrect. Try again", - format!("IP: {}. Username: {log_username}.", ip.ip), + format!("IP: {}. Username: {username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } @@ -439,26 +452,13 @@ async fn password_login( } else if !user.check_valid_password(password) { err!( "Username or password is incorrect. Try again", - format!("IP: {}. Username: {log_username}.", ip.ip), + format!("IP: {}. Username: {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 - } - ) - } - - check_client_version(&user, client_header)?; - // 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?; @@ -469,9 +469,9 @@ async fn password_login( if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { if user.last_verifying_at.is_none() || now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds() - > i64::from(CONFIG.signups_verify_resend_time()) + > CONFIG.signups_verify_resend_time().cast_signed() { - let resend_limit = i32::from(CONFIG.signups_verify_resend_limit()); + let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed(); if resend_limit == 0 || user.login_verify_count < resend_limit { // We want to send another email verification if we require signups to verify // their email address, and we haven't sent them a reminder in a while... @@ -491,7 +491,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: {log_username}.", ip.ip), + format!("IP: {}. Username: {username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn } @@ -500,86 +500,13 @@ async fn password_login( let mut device = get_device(&data, conn, &user).await?; - 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 twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; 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 } -/// Like upstream's `ClientVersionValidator`, every login needs the `Bitwarden-Client-Version` header, and a V2 account -/// ([`User::has_v2_encryption`]) a client of at least 2026.10.1 (2026.10.2 for the desktop app). Called right after the -/// credentials are checked, like upstream, so that it doesn't tell anyone else which accounts are V2. -/// -/// Ref: -/// Ref: -fn check_client_version(user: &User, client_header: &ClientHeaders) -> EmptyResult { - let Some((error, description)) = client_version_error( - user.has_v2_encryption(), - client_header.client_version.as_ref(), - client_header.device_type, - ) else { - return Ok(()); - }; - Err(crate::error::Error::from(( - // Escaped: on a first SSO login the email comes from the provider and isn't validated yet - format!("IP: {}. Username: {}. {error}", client_header.ip.ip, user.email.escape_debug()), - json!({ - "error": error, - "error_description": description, - "ErrorModel": { "Message": description, "Object": "error" }, - }), - )) - .with_event(crate::error::ErrorEvent { - event: EventType::UserFailedLogIn, - })) -} - -/// The error id and message of [`check_client_version`], `None` when the client may log in. -fn client_version_error( - is_v2: bool, - client_version: Option<&semver::Version>, - device_type: i32, -) -> Option<(&'static str, &'static str)> { - const MIN_V2_CLIENT: semver::Version = semver::Version::new(2026, 10, 1); - const MIN_V2_DESKTOP_CLIENT: semver::Version = semver::Version::new(2026, 10, 2); - - let Some(version) = client_version else { - return Some(( - "version_header_missing", - "No client version header found, required to prevent encryption errors. Please confirm your client is supplying the header: \"Bitwarden-Client-Version\"", - )); - }; - if !is_v2 { - return None; - } - // Upstream's desktop device types - // Ref: - let is_desktop = matches!( - DeviceType::from_i32(device_type), - DeviceType::WindowsDesktop | DeviceType::MacOsDesktop | DeviceType::LinuxDesktop | DeviceType::Uwp - ); - let min_version = if is_desktop { - &MIN_V2_DESKTOP_CLIENT - } else { - &MIN_V2_CLIENT - }; - (version < min_version).then_some(("invalid_client_version", "Please update your app to continue using Bitwarden")) -} - async fn authenticated_response( user: &User, device: &mut Device, @@ -612,7 +539,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 = identity_master_password_policy(user, conn).await; + let master_password_policy = master_password_policy(user, conn).await; let has_master_password = !user.password_hash.is_empty(); let master_password_unlock = if has_master_password { @@ -627,13 +554,24 @@ async fn authenticated_response( // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 "MasterKeyEncryptedUserKey": user.akey, "MasterKeyWrappedUserKey": user.akey, - "Salt": user.master_password_salt() + "Salt": user.email }) } else { Value::Null }; - let account_keys = user.account_keys_json(conn).await; + let account_keys = if user.private_key.is_some() { + json!({ + "publicKeyEncryptionKeyPair": { + "wrappedPrivateKey": user.private_key, + "publicKey": user.public_key, + "Object": "publicKeyEncryptionKeyPair" + }, + "Object": "privateKeys" + }) + } else { + Value::Null + }; let mut result = json!({ "access_token": auth_tokens.access_token(), @@ -645,6 +583,7 @@ 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(), @@ -668,26 +607,13 @@ async fn authenticated_response( Ok(Json(result)) } -async fn api_key_login( - data: ConnectData, - user_id: &mut Option, - conn: &DbConn, - client_header: &ClientHeaders, -) -> JsonResult { - let ip = &client_header.ip; +async fn api_key_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; // Validate scope match data.scope.as_ref() { - 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, client_header).await - } + Some(scope) if scope == &AuthMethod::UserApiKey.scope() => 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"), } @@ -697,9 +623,8 @@ async fn user_api_key_login( data: ConnectData, user_id: &mut Option, conn: &DbConn, - client_header: &ClientHeaders, + ip: &ClientIp, ) -> JsonResult { - let ip = &client_header.ip; // Get the user via the client_id let client_id = data.client_id.as_ref().unwrap(); let Some(client_user_id) = client_id.strip_prefix("user.") else { @@ -736,8 +661,6 @@ async fn user_api_key_login( ) } - check_client_version(&user, client_header)?; - let mut device = get_device(&data, conn, &user).await?; if CONFIG.mail_enabled() && device.is_new() { @@ -782,13 +705,24 @@ async fn user_api_key_login( // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 "MasterKeyEncryptedUserKey": user.akey, "MasterKeyWrappedUserKey": user.akey, - "Salt": user.master_password_salt() + "Salt": user.email }) } else { Value::Null }; - let account_keys = user.account_keys_json(conn).await; + let account_keys = if user.private_key.is_some() { + json!({ + "publicKeyEncryptionKeyPair": { + "wrappedPrivateKey": user.private_key, + "publicKey": user.public_key, + "Object": "publicKeyEncryptionKeyPair" + }, + "Object": "privateKeys" + }) + } else { + Value::Null + }; // Note: No refresh_token is returned. The CLI just repeats the // client_credentials login flow when the existing token expires. @@ -803,6 +737,7 @@ 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, @@ -868,6 +803,7 @@ 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; @@ -896,12 +832,17 @@ 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, data, conn).await?, "Invalid two factor provider") + err_json!( + json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, 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, data, conn).await?, "2FA token not provided") + 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" + ) }; let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled); @@ -915,14 +856,20 @@ 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) => { - duo_oidc::validate_duo_login( - &user.email, - twofactor_code, - data.client_id.as_ref().unwrap(), - data.device_identifier.as_ref().unwrap(), - conn, - ) - .await?; + 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?; + } } Some(TwoFactorType::Email) => { email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?; @@ -944,22 +891,16 @@ async fn twofactor_auth( device.save(true, conn).await?; } err_json!( - json_err_twofactor(&twofactor_ids, user, data, conn).await?, + json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, "2FA Remember token not provided or expired" ) } } } Some(TwoFactorType::RecoveryCode) => { - // 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 - } - ) + // Check if recovery code is correct + if !user.check_valid_recovery_code(twofactor_code) { + err!("Recovery code is incorrect. Try again.") } // Remove all twofactors from the user @@ -972,14 +913,7 @@ async fn twofactor_auth( enforce_2fa_policy(user, &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) = - mail::send_recover_twofactor(&user.email, &ip.ip.to_string(), &Utc::now().naive_utc()).await - { - error!("Error sending two-step login recovered email: {e:#?}"); - } + log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await; // Remove the recovery code, not needed without twofactors user.totp_recover = None; @@ -1008,14 +942,21 @@ 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: &User, data: &ConnectData, conn: &DbConn) -> ApiResult { - let user_id = &user.uuid; +async fn json_err_twofactor( + providers: &[i32], + user_id: &UserId, + data: &ConnectData, + client_version: Option<&ClientVersion>, + conn: &DbConn, +) -> ApiResult { let mut result = json!({ "error" : "invalid_grant", "error_description" : "Two factor required.", "TwoFactorProviders" : providers.iter().map(ToString::to_string).collect::>(), "TwoFactorProviders2" : {}, // { "0" : null } - "MasterPasswordPolicy": identity_master_password_policy(user, conn).await, + "MasterPasswordPolicy": { + "Object": "masterPasswordPolicy" + } }); for provider in providers { @@ -1028,21 +969,37 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, } Some(TwoFactorType::Duo) => { - let auth_url = duo_oidc::get_duo_auth_url( - &user.email, - data.client_id.as_ref().unwrap(), - data.device_identifier.as_ref().unwrap(), - conn, - ) - .await?; + let email = if let Some(u) = User::find_by_uuid(user_id, conn).await { + u.email + } else { + err!("User does not exist") + }; - result["TwoFactorProviders2"][provider.to_string()] = json!({ - "AuthUrl": auth_url, - }); + 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?; + + result["TwoFactorProviders2"][provider.to_string()] = json!({ + "AuthUrl": auth_url, + }); + } } Some(tf_type @ TwoFactorType::YubiKey) => { - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { err!("No YubiKey devices registered") }; @@ -1054,10 +1011,23 @@ 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, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { 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), @@ -1095,6 +1065,11 @@ 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 { @@ -1103,13 +1078,6 @@ struct RegisterVerificationData { // receiveMarketingEmails: bool, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RegisterVerificationEmailClickedData { - email: String, - email_verification_token: String, -} - #[derive(rocket::Responder)] enum RegisterVerificationResponse { #[response(status = 204)] @@ -1172,29 +1140,9 @@ async fn register_verification_email( } } -#[post("/accounts/register/verification-email-clicked", data = "")] -async fn register_verification_email_clicked( - data: Json, - conn: DbConn, -) -> EmptyResult { - let data = data.into_inner(); - let token_valid = auth::decode_register_verify(&data.email_verification_token) - .is_ok_and(|claims| claims.verified && claims.sub.eq_ignore_ascii_case(&data.email)); - let user_exists = User::find_by_mail(&data.email, &conn) - .await - .is_some_and(|user| !user.password_hash.is_empty() || user.private_key.is_some()); - - if !token_valid || user_exists { - err!("Expired link. Please restart registration or try logging in. You may already have an account") - } - - Ok(()) -} - #[post("/accounts/register/finish", data = "")] -async fn register_finish(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { - crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; - register(data, conn).await +async fn register_finish(data: Json, conn: DbConn) -> JsonResult { + register(data, true, conn).await } // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts @@ -1324,7 +1272,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.escape_debug())) + err!(format!("Cannot retrieve sso_auth for {state}")) }; // Browser-binding check @@ -1333,7 +1281,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.escape_debug())), + _ => err!(format!("SSO session binding mismatch for {state}")), } cookies .remove(Cookie::build(SSO_BINDING_COOKIE).path(format!("{}/identity/connect/", CONFIG.domain_path())).build()); @@ -1419,33 +1367,3 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, Ok(Redirect::temporary(String::from(auth_url))) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_client_version_error() { - let version = |v| semver::Version::parse(v).unwrap(); - let web = DeviceType::ChromeBrowser as i32; - let desktop = DeviceType::WindowsDesktop as i32; - // Every login needs the header - assert_eq!(client_version_error(false, None, web).map(|e| e.0), Some("version_header_missing")); - assert_eq!(client_version_error(true, None, desktop).map(|e| e.0), Some("version_header_missing")); - // V1 accounts log in from any version - assert_eq!(client_version_error(false, Some(&version("2024.1.0")), web), None); - assert_eq!(client_version_error(false, Some(&version("2024.1.0")), desktop), None); - // V2 accounts need 2026.10.1, and 2026.10.2 for the desktop app - assert_eq!( - client_version_error(true, Some(&version("2026.10.0")), web).map(|e| e.0), - Some("invalid_client_version") - ); - assert_eq!(client_version_error(true, Some(&version("2026.10.1")), web), None); - assert_eq!( - client_version_error(true, Some(&version("2026.10.1")), desktop).map(|e| e.0), - Some("invalid_client_version") - ); - assert_eq!(client_version_error(true, Some(&version("2026.10.2")), DeviceType::Uwp as i32), None); - assert_eq!(client_version_error(true, Some(&version("2026.11.0")), desktop), None); - } -} diff --git a/src/api/mod.rs b/src/api/mod.rs index 42c6d53d..9a79ce95 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -23,14 +23,14 @@ pub use crate::api::{ icons::routes as icons_routes, identity::routes as identity_routes, notifications::routes as notifications_routes, - notifications::{AnonymousNotify, LogOutReason, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, + notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, push::{ - push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update, - register_push_device, unregister_push_device, + push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device, + unregister_push_device, }, web::catchers as web_catchers, web::routes as web_routes, - web::{STATIC_FILES, invalidate_css_cache}, + web::{invalidate_css_cache, static_files}, }; use crate::{ CONFIG, @@ -46,7 +46,7 @@ pub type JsonResult = ApiResult>; pub type EmptyResult = ApiResult<()>; // Common structs representing JSON data received -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PasswordOrOtpData { #[serde(alias = "MasterPasswordHash")] @@ -124,21 +124,3 @@ 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/api/notifications.rs b/src/api/notifications.rs index 304ce447..8bfcd518 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -1,7 +1,7 @@ use std::{ net::IpAddr, sync::{Arc, LazyLock}, - time::{Duration, Instant}, + time::Duration, }; use chrono::{NaiveDateTime, Utc}; @@ -41,14 +41,6 @@ pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled()); pub fn routes() -> Vec { @@ -164,16 +156,12 @@ fn websockets_hub<'r>( rocket_ws::Stream! { ws => { let mut ws = ws; let _guard = guard; - let mut interval = tokio::time::interval(WS_PING_INTERVAL); - let mut last_received = Instant::now(); + let mut interval = tokio::time::interval(Duration::from_secs(15)); 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), @@ -207,13 +195,7 @@ fn websockets_hub<'r>( } } - _ = interval.tick() => { - // The client stopped responding without closing the connection, drop it - if last_received.elapsed() > WS_CLIENT_TIMEOUT { - break; - } - yield Message::Ping(PING.to_vec()); - } + _ = interval.tick() => yield Message::Ping(create_ping()) } } }} @@ -247,16 +229,12 @@ 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(WS_PING_INTERVAL); - let mut last_received = Instant::now(); + let mut interval = tokio::time::interval(Duration::from_secs(15)); 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), @@ -290,13 +268,7 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R } } - _ = interval.tick() => { - // The client stopped responding without closing the connection, drop it - if last_received.elapsed() > WS_CLIENT_TIMEOUT { - break; - } - yield Message::Ping(PING.to_vec()); - } + _ = interval.tick() => yield Message::Ping(create_ping()) } } }} @@ -359,7 +331,6 @@ fn convert_option>(option: Option) -> Value { const RECORD_SEPARATOR: u8 = 0x1e; const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, -const PING: [u8; 3] = [0x02, 0x91, 0x06]; #[derive(Deserialize, Copy, Clone, Eq, PartialEq)] struct InitialMessage<'a> { @@ -412,35 +383,23 @@ impl WebSocketUsers { } pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) { - self.send_logout_with_reason(user, acting_device, None, conn).await; - } - - /// A logout that clients may handle differently depending on `reason` - pub async fn send_logout_with_reason( - &self, - user: &User, - acting_device: Option<&Device>, - reason: Option, - conn: &DbConn, - ) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let acting_device_id = acting_device.map(|d| d.uuid.clone()); - let mut payload = - vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))]; - if let Some(reason) = reason { - payload.push(("Reason".into(), (reason as i32).into())); - } - let data = create_update(payload, UpdateType::LogOut, acting_device_id); + let data = create_update( + vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))], + UpdateType::LogOut, + acting_device_id, + ); if CONFIG.enable_websocket() { self.send_update(&user.uuid, &data).await; } if CONFIG.push_enabled() { - push_logout(user, acting_device, reason, conn).await; + push_logout(user, acting_device, conn).await; } } @@ -704,12 +663,8 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id serialize(&value) } -/// Why a logout was pushed, absent for a plain one. `KdfChange` isn't sent yet. -/// -/// Ref: -#[derive(Copy, Clone, Eq, PartialEq)] -pub enum LogOutReason { - KeyRotation = 1, +fn create_ping() -> Vec { + serialize(&Value::Array(vec![6.into()])) } // https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs diff --git a/src/api/push.rs b/src/api/push.rs index dab31c22..e87a0985 100644 --- a/src/api/push.rs +++ b/src/api/push.rs @@ -12,7 +12,7 @@ use tokio::sync::RwLock; use crate::{ CONFIG, - api::{ApiResult, EmptyResult, LogOutReason, UpdateType}, + api::{ApiResult, EmptyResult, UpdateType}, db::{ DbConn, models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId}, @@ -139,25 +139,21 @@ pub async fn unregister_push_device(push_id: Option<&PushId>) -> EmptyResult { if !CONFIG.push_enabled() || push_id.is_none() { return Ok(()); } - let data = json!({ - "id": push_id.as_ref().unwrap(), - }); - let auth_api_token = get_auth_api_token().await?; + let auth_header = format!("Bearer {auth_api_token}"); - if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/delete"))? - .header(CONTENT_TYPE, "application/json") - .header(ACCEPT, "application/json") - .header(AUTHORIZATION, auth_header) - .json(&data) - .send() - .await? - .error_for_status() + match make_http_request( + Method::POST, + &format!("{}/push/delete/{}", CONFIG.push_relay_uri(), push_id.as_ref().unwrap()), + )? + .header(AUTHORIZATION, auth_header) + .send() + .await { - err!(format!("An error occurred during device unregistration: {e}")); - } - + Ok(r) => r, + Err(e) => err!(format!("An error occurred during device unregistration: {e}")), + }; Ok(()) } @@ -192,29 +188,22 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device } } -pub async fn push_logout(user: &User, acting_device: Option<&Device>, reason: Option, conn: &DbConn) { +pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) { if Device::check_user_has_push_device(&user.uuid, conn).await { - push_logout_to_relay(user, acting_device, reason); - } -} - -// Sends the logout without checking for push devices first. -// Needed after deleting a user, when the devices are already gone from the database. -pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>, reason: Option) { - tokio::task::spawn(send_to_push_relay(json!({ - "userId": user.uuid, - "organizationId": (), - "deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()), - "identifier": acting_device.map(|d| &d.uuid), - "type": UpdateType::LogOut as i32, - "payload": { + tokio::task::spawn(send_to_push_relay(json!({ "userId": user.uuid, - "date": format_date(&user.updated_at), - "reason": reason.map(|r| r as i32), - }, - "clientType": null, - "installationId": null - }))); + "organizationId": (), + "deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()), + "identifier": acting_device.map(|d| &d.uuid), + "type": UpdateType::LogOut as i32, + "payload": { + "userId": user.uuid, + "date": format_date(&user.updated_at) + }, + "clientType": null, + "installationId": null + }))); + } } pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) { diff --git a/src/api/web.rs b/src/api/web.rs index 2818b5b7..d6d8d62c 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -1,14 +1,13 @@ use std::{ - collections::HashMap, path::{Path, PathBuf}, - sync::{Arc, LazyLock, RwLock}, + sync::{Arc, RwLock}, }; use rocket::{ Catcher, Route, fs::NamedFile, http::ContentType, - response::{Redirect, content::RawHtml as Html}, + response::{Redirect, content::RawCss as Css, content::RawHtml as Html}, serde::json::Json, }; use serde_json::Value; @@ -20,7 +19,7 @@ use crate::{ crypto::sha256_hex, db::{ DbConn, - models::{AttachmentId, CipherId, custom_roles_enabled}, + models::{AttachmentId, CipherId}, }, error::Error, util::{Cached, EtagCached}, @@ -80,18 +79,16 @@ pub fn invalidate_css_cache() { } #[get("/css/vaultwarden.css")] -fn vaultwarden_css() -> EtagCached { +fn vaultwarden_css() -> EtagCached> { // If reload_templates is false, and we already have the CSS Cached, return this if !CONFIG.reload_templates() && let Some(cached) = CSS_CACHE.read().unwrap().as_ref() { - return EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag); + return EtagCached::new(Css(cached.css.clone()), &cached.etag); } // Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS let css_options = json!({ - "custom_roles_enabled": custom_roles_enabled(), - "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(), @@ -149,7 +146,7 @@ fn vaultwarden_css() -> EtagCached { } // Etag Caching will let the browser send us an etag to verify and send new content if needed - EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag) + EtagCached::new(Css(cached.css.clone()), &cached.etag) } #[get("/")] @@ -258,7 +255,7 @@ fn alive_head(_conn: DbConn) -> EmptyResult { // This endpoint/function is used during development and development only. // It allows to easily develop the admin interface by always loading the files from disk instead from a slice of bytes // This will only be active during a debug build and only when `RELOAD_TEMPLATES` is set to `true` -// NOTE: Do not forget to add any new files added to the `STATIC_FILES` HashMap below! +// NOTE: Do not forget to add any new files added to the `static_files` function below! #[cfg(debug_assertions)] #[get("/vw_static/", rank = 1)] pub async fn static_files_dev(filename: PathBuf) -> Option { @@ -278,61 +275,32 @@ pub async fn static_files_dev(filename: PathBuf) -> Option { None } -pub struct StaticFile { - pub content_type: ContentType, - pub bytes: &'static [u8], - pub etag: String, -} - -pub static STATIC_FILES: LazyLock> = LazyLock::new(|| { - [ - ("404.png", ContentType::PNG, &include_bytes!("../static/images/404.png")[..]), - ("mail-github.png", ContentType::PNG, &include_bytes!("../static/images/mail-github.png")[..]), - ("logo-gray.png", ContentType::PNG, &include_bytes!("../static/images/logo-gray.png")[..]), - ("error-x.svg", ContentType::SVG, &include_bytes!("../static/images/error-x.svg")[..]), - ("hibp.png", ContentType::PNG, &include_bytes!("../static/images/hibp.png")[..]), - ("vaultwarden-icon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-icon.png")[..]), - ("vaultwarden-favicon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-favicon.png")[..]), - ("404.css", ContentType::CSS, &include_bytes!("../static/scripts/404.css")[..]), - ("admin.css", ContentType::CSS, &include_bytes!("../static/scripts/admin.css")[..]), - ("admin.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin.js")[..]), - ("admin_settings.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_settings.js")[..]), - ("admin_users.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_users.js")[..]), - ( - "admin_organizations.js", - ContentType::JavaScript, - &include_bytes!("../static/scripts/admin_organizations.js")[..], - ), - ( - "admin_diagnostics.js", - ContentType::JavaScript, - &include_bytes!("../static/scripts/admin_diagnostics.js")[..], - ), - ("bootstrap.css", ContentType::CSS, &include_bytes!("../static/scripts/bootstrap.css")[..]), - ("bootstrap.bundle.js", ContentType::JavaScript, &include_bytes!("../static/scripts/bootstrap.bundle.js")[..]), - ("jdenticon-3.3.0.js", ContentType::JavaScript, &include_bytes!("../static/scripts/jdenticon-3.3.0.js")[..]), - ("datatables.js", ContentType::JavaScript, &include_bytes!("../static/scripts/datatables.js")[..]), - ("datatables.css", ContentType::CSS, &include_bytes!("../static/scripts/datatables.css")[..]), - ] - .into_iter() - .map(|(name, content_type, bytes)| { - ( - name, - StaticFile { - content_type, - bytes, - etag: sha256_hex(bytes), - }, - ) - }) - .collect() -}); - #[get("/vw_static/", rank = 2)] -pub fn static_files(filename: &str) -> Result, Error> { - if let Some(file) = STATIC_FILES.get(filename) { - Ok(EtagCached::new(file.bytes, file.content_type.clone(), &file.etag)) - } else { - err!(format!("Static file not found: {}", filename.escape_debug())) +pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Error> { + match filename { + "404.png" => Ok((ContentType::PNG, include_bytes!("../static/images/404.png"))), + "mail-github.png" => Ok((ContentType::PNG, include_bytes!("../static/images/mail-github.png"))), + "logo-gray.png" => Ok((ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))), + "error-x.svg" => Ok((ContentType::SVG, include_bytes!("../static/images/error-x.svg"))), + "hibp.png" => Ok((ContentType::PNG, include_bytes!("../static/images/hibp.png"))), + "vaultwarden-icon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-icon.png"))), + "vaultwarden-favicon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-favicon.png"))), + "404.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/404.css"))), + "admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))), + "admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))), + "admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))), + "admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))), + "admin_organizations.js" => { + Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js"))) + } + "admin_diagnostics.js" => { + Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js"))) + } + "bootstrap.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))), + "bootstrap.bundle.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap.bundle.js"))), + "jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))), + "datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))), + "datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))), + _ => err!(format!("Static file not found: {filename}")), } } diff --git a/src/auth.rs b/src/auth.rs index ff858c0e..07373389 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,6 +1,3 @@ -#[path = "auth/two_factor.rs"] -pub mod two_factor; - #[path = "auth/send.rs"] pub mod send; pub type SendTokens = send::SendTokens; @@ -17,7 +14,6 @@ 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::{ @@ -34,7 +30,7 @@ use crate::{ models::{ AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId, EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, - SendFileId, SendId, User, UserId, + SendFileId, SendId, User, UserId, UserStampException, }, }, error::Error, @@ -69,7 +65,6 @@ 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; @@ -104,22 +99,9 @@ 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, @@ -271,7 +253,7 @@ impl LoginJwtClaims { premium: true, name: user.name.clone(), email: user.email.clone(), - email_verified: user.verified(), + email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(), // --- // Disabled these keys to be added to the JWT since they could cause the JWT to get too large @@ -314,17 +296,6 @@ impl LoginJwtClaims { } } -fn calculate_invite_expire_hours(now: &DateTime) -> Result, Error> { - let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); - let Some(hours_delta) = TimeDelta::try_hours(expire_hours) else { - err!("Unable to calculate time delta for invite expire hours. Check `INVITATION_EXPIRATION_HOURS`."); - }; - let Some(exp) = now.checked_add_signed(hours_delta) else { - err!("Unable to calculate invitation expire date. Check `INVITATION_EXPIRATION_HOURS`."); - }; - Ok(exp) -} - #[derive(Debug, Serialize, Deserialize)] pub struct InviteJwtClaims { // Not before @@ -348,19 +319,19 @@ pub fn generate_invite_claims( org_id: OrganizationId, member_id: MembershipId, invited_by_email: Option, -) -> Result { +) -> InviteJwtClaims { let time_now = Utc::now(); - let exp = calculate_invite_expire_hours(&time_now)?; - Ok(InviteJwtClaims { + let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); + InviteJwtClaims { nbf: time_now.timestamp(), - exp: exp.timestamp(), + exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_INVITE_ISSUER.to_string(), sub: user_id, email, org_id, member_id, invited_by_email, - }) + } } #[derive(Debug, Serialize, Deserialize)] @@ -386,19 +357,19 @@ pub fn generate_emergency_access_invite_claims( emer_id: EmergencyAccessId, grantor_name: String, grantor_email: String, -) -> Result { +) -> EmergencyAccessInviteJwtClaims { let time_now = Utc::now(); - let exp = calculate_invite_expire_hours(&time_now)?; - Ok(EmergencyAccessInviteJwtClaims { + let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); + EmergencyAccessInviteJwtClaims { nbf: time_now.timestamp(), - exp: exp.timestamp(), + exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(), sub: user_id, email, emer_id, grantor_name, grantor_email, - }) + } } #[derive(Debug, Serialize, Deserialize)] @@ -451,7 +422,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(1).unwrap()).timestamp(), + exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(), iss: JWT_FILE_DOWNLOAD_ISSUER.to_string(), sub: cipher_id, file_id, @@ -503,17 +474,13 @@ pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) -> let time_now = Utc::now(); TwoFactorRememberClaims { nbf: time_now.timestamp(), - exp: two_factor_remember_expiration(time_now, CONFIG.two_factor_remember_days()), + exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(), iss: JWT_2FA_REMEMBER_ISSUER.to_string(), sub: device_uuid, user_uuid, } } -fn two_factor_remember_expiration(time_now: DateTime, remember_days: i64) -> i64 { - (time_now + TimeDelta::try_days(remember_days).expect("TWO_FACTOR_REMEMBER_DAYS is validated")).timestamp() -} - #[derive(Debug, Serialize, Deserialize)] pub struct BasicJwtClaims { // Not before @@ -536,43 +503,36 @@ impl BasicJwtClaims { } } -pub fn generate_delete_claims(uuid: String) -> Result { +pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { let time_now = Utc::now(); - let exp = calculate_invite_expire_hours(&time_now)?; - Ok(BasicJwtClaims { + let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); + BasicJwtClaims { nbf: time_now.timestamp(), - exp: exp.timestamp(), + exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_DELETE_ISSUER.to_string(), sub: uuid, - }) + } } -pub fn generate_verify_email_claims(user_id: &UserId) -> Result { +pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims { let time_now = Utc::now(); - let exp = calculate_invite_expire_hours(&time_now)?; - Ok(BasicJwtClaims { + let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); + BasicJwtClaims { nbf: time_now.timestamp(), - exp: exp.timestamp(), + exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_VERIFYEMAIL_ISSUER.to_string(), sub: user_id.to_string(), - }) + } } -pub fn generate_admin_claims() -> Result { +pub fn generate_admin_claims() -> BasicJwtClaims { let time_now = Utc::now(); - let lifetime_minutes = i64::from(CONFIG.admin_session_lifetime()); - let Some(minutes_delta) = TimeDelta::try_minutes(lifetime_minutes) else { - err!("Unable to calculate time delta for admin session lifetime. Check `ADMIN_SESSION_LIFETIME`."); - }; - let Some(exp) = time_now.checked_add_signed(minutes_delta) else { - err!("Unable to calculate admin session lifetime. Check `ADMIN_SESSION_LIFETIME`."); - }; - Ok(BasicJwtClaims { + BasicJwtClaims { nbf: time_now.timestamp(), - exp: exp.timestamp(), + exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(), iss: JWT_ADMIN_ISSUER.to_string(), sub: "admin_panel".to_owned(), - }) + } } pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims { @@ -632,9 +592,6 @@ impl<'r> FromRequest<'r> for Host { pub struct ClientHeaders { pub device_type: i32, pub ip: ClientIp, - /// `Bitwarden-Client-Version`, `None` when missing or not a version. Unlike the [`ClientVersion`] guard, this - /// doesn't log an error, as not every client sends it. - pub client_version: Option, } #[rocket::async_trait] @@ -651,13 +608,10 @@ impl<'r> FromRequest<'r> for ClientHeaders { .get_one("device-type") .and_then(|d| d.parse().ok()) .unwrap_or(DeviceType::UnknownBrowser as i32); - let client_version = - request.headers().get_one("Bitwarden-Client-Version").and_then(|v| semver::Version::parse(v).ok()); Outcome::Success(ClientHeaders { device_type, ip, - client_version, }) } } @@ -713,12 +667,31 @@ impl<'r> FromRequest<'r> for Headers { }; if user.security_stamp != claims.sstamp { - let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else { - err_handler!("Error getting current route for stamp exception") - }; + if let Some(stamp_exception) = + user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::(s).ok()) + { + let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else { + err_handler!("Error getting current route for stamp exception") + }; - // Expired exceptions are only dropped on reset; writing them back here could undo a newer reset - if !user.stamp_exceptions().iter().any(|e| e.allows(&claims.sstamp, current_route)) { + // Check if the stamp exception has expired first. + // Then, check if the current route matches any of the allowed routes. + // After that check the stamp in exception matches the one in the claims. + if Utc::now().timestamp() > stamp_exception.expire { + // If the stamp exception has been expired remove it from the database. + // This prevents checking this stamp exception for new requests. + let mut user = user; + user.reset_stamp_exception(); + if let Err(e) = user.save(&conn).await { + error!("Error updating user: {e:#?}"); + } + err_handler!("Stamp exception is expired") + } else if !stamp_exception.routes.contains(¤t_route.to_owned()) { + err_handler!("Invalid security stamp: Current route and exception route do not match") + } else if stamp_exception.security_stamp != claims.sstamp { + err_handler!("Invalid security stamp for matched stamp exception") + } + } else { err_handler!("Invalid security stamp") } } @@ -1118,31 +1091,6 @@ 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 = (); @@ -1151,7 +1099,15 @@ 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) { - client_ip_from_header(req, &CONFIG.ip_header(), |ip| ip_header_is_trusted(Some(ip))) + 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() + }) } 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 @@ -1270,7 +1226,7 @@ impl AuthMethod { match scope { None => err!("Missing scope"), Some(scope) if scope == &method_scope => Ok(method_scope), - Some(scope) => err!(format!("Scope ({}) not supported", scope.escape_debug())), + Some(scope) => err!(format!("Scope ({scope}) not supported")), } } } @@ -1389,52 +1345,3 @@ 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/auth/send.rs b/src/auth/send.rs index 85b91d99..2554488a 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -72,14 +72,10 @@ impl SendTokens { conn: &DbConn, ) -> ApiResult { let Some(send_id) = Self::as_send_id(access_id) else { - return Self::invalid_error( - &format!("Can't convert {}", access_id.escape_debug()), - "send_id_invalid", - false, - ); + return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false); }; - let Some(send) = Send::find_by_uuid(&send_id, conn).await else { + let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else { return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false); }; @@ -107,6 +103,10 @@ 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/auth/two_factor.rs b/src/auth/two_factor.rs deleted file mode 100644 index 533c0cf4..00000000 --- a/src/auth/two_factor.rs +++ /dev/null @@ -1,281 +0,0 @@ -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/config.rs b/src/config.rs index 3f04b8d7..37fc3e85 100644 --- a/src/config.rs +++ b/src/config.rs @@ -111,7 +111,6 @@ macro_rules! make_config { _usr: ConfigBuilder, _overrides: Vec<&'static str>, - _config_file_status: ConfigFileStatus, } // Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html @@ -260,22 +259,11 @@ macro_rules! make_config { builder } - async fn from_file() -> Result, Error> { - // The Fs operator would create the folder if it doesn't exist, which we never want - if !CONFIG_FILE_PARENT_DIR.starts_with("s3://") && !std::path::Path::new(&*CONFIG_FILE_PARENT_DIR).is_dir() { - return Ok(None); - } + async fn from_file() -> Result { let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; - let config_bytes = match operator.read(&CONFIG_FILENAME).await { - Ok(bytes) => bytes, - // No config file is fine, should not return any error - Err(e) if e.kind() == opendal::ErrorKind::NotFound => return Ok(None), - // Any other error might be an issue and should be passed back to the caller - Err(e) => return Err(e.into()), - }; - let builder = serde_json::from_slice(&config_bytes.to_vec())?; + let config_bytes = operator.read(&CONFIG_FILENAME).await?; println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE); - Ok(Some(builder)) + serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into) } fn clear_non_editable(&mut self) { @@ -452,8 +440,6 @@ macro_rules! make_config { "domain_origin", "domain_path", "domain", - "duo_ikey", - "duo_host", "helo_name", "org_creation_users", "signups_domains_whitelist", @@ -465,8 +451,6 @@ macro_rules! make_config { "sso_authority", "sso_callback_path", "sso_client_id", - "yubico_client_id", - "yubico_server", ]; let cfg = { @@ -499,10 +483,6 @@ macro_rules! make_config { }; overrides } - - pub fn get_config_file_status(&self) -> ConfigFileStatus { - self.inner.read().unwrap()._config_file_status - } } }; } @@ -559,7 +539,7 @@ make_config! { jobs { /// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run. /// Set to 0 to globally disable scheduled jobs. - job_poll_interval_ms: u32, false, def, 30_000; + job_poll_interval_ms: u64, false, def, 30_000; /// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date. /// Defaults to hourly. Set blank to disable this job. send_purge_schedule: String, false, def, "0 5 * * * *".to_owned(); @@ -581,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. + /// 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. /// 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. @@ -607,9 +587,6 @@ make_config! { /// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy. sends_allowed: bool, true, def, true; - /// Fill Assist Rules URL |> HTTPS URL for the server-wide Fill Assist rules feed base directory. - fill_assist_rules_url: String, true, def, "https://fillassist.bitwarden.com".to_owned(); - /// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key hibp_api_key: Pass, true, option; @@ -618,19 +595,19 @@ make_config! { /// Per-organization attachment storage limit (KB) |> Max kilobytes of attachment storage allowed per org. When this limit is reached, org members will not be allowed to upload further attachments for ciphers owned by that org. org_attachment_limit: i64, true, option; /// Per-user send storage limit (KB) |> Max kilobytes of sends storage allowed per user. When this limit is reached, the user will not be allowed to upload further sends. - user_send_limit: i64, true, option; + user_send_limit: i64, true, option; /// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item. - /// If unset or 0, trashed items are not auto-deleted. This setting applies globally, so make + /// If unset, trashed items are not auto-deleted. This setting applies globally, so make /// sure to inform all users of any changes to this setting. - trash_auto_delete_days: u16, true, option; + trash_auto_delete_days: i64, true, option; /// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is /// considered incomplete, resulting in an email notification. An incomplete 2FA login is one /// where the correct master password was provided but the required 2FA step was not completed, /// which potentially indicates a master password compromise. Set to 0 to disable this check. /// This setting applies globally to all users. - incomplete_2fa_time_limit: u16, true, def, 3; + incomplete_2fa_time_limit: i64, true, def, 3; /// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service. /// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external @@ -643,9 +620,9 @@ make_config! { /// this will prevent logins from succeeding until the address has been verified signups_verify: bool, true, def, false; /// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds) - signups_verify_resend_time: u32, true, def, 3_600; + signups_verify_resend_time: u64, true, def, 3_600; /// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit) - signups_verify_resend_limit: u16, true, def, 6; + signups_verify_resend_limit: u32, true, def, 6; /// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled signups_domains_whitelist: String, true, def, String::new(); /// Enable event logging |> Enables event logging for organizations. @@ -657,7 +634,7 @@ make_config! { invitations_allowed: bool, true, def, true; /// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token, /// email verification token and deletion request token will expire (must be at least 1) - invitation_expiration_hours: u16, false, def, 120; + invitation_expiration_hours: u32, false, def, 120; /// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users. emergency_access_allowed: bool, true, def, true; /// Allow email change |> Controls whether users can change their email. This setting applies globally to all users. @@ -678,8 +655,8 @@ make_config! { /// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization invitation_org_name: String, true, def, "Vaultwarden".to_owned(); - /// Events days retain |> Number of days to retain events stored in the database. If unset or 0, events are kept indefinitely. - events_days_retain: u16, false, option; + /// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely. + events_days_retain: i64, false, option; }, client { @@ -698,8 +675,7 @@ 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. 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. + /// or a comma separated list of IPs and CIDR ranges. 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 `{}`, @@ -717,13 +693,13 @@ make_config! { /// Temporary redirects are useful while testing different icon services, but once a service /// has been decided on, consider using permanent redirects for cacheability. The legacy codes /// are currently better supported by the Bitwarden clients. - icon_redirect_code: u16, true, def, 302; + icon_redirect_code: u32, true, def, 302; /// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed - icon_cache_ttl: u32, true, def, 2_592_000; + icon_cache_ttl: u64, true, def, 2_592_000; /// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again. - icon_cache_negttl: u32, true, def, 259_200; + icon_cache_negttl: u64, true, def, 259_200; /// Icon download timeout |> Number of seconds when to stop attempting to download an icon. - icon_download_timeout: u16, true, def, 10; + icon_download_timeout: u64, true, def, 10; /// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead icon_blacklist_regex: String, false, option; @@ -740,8 +716,6 @@ make_config! { /// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time. /// Note that the checkbox would still be present, but ignored. disable_2fa_remember: bool, true, def, false; - /// Two-Factor remember duration |> Number of days before a remembered Two-Factor login expires (min: 1, max: 90). - two_factor_remember_days: i64, true, def, 30; /// Disable authenticator time drifted codes to be valid |> Enabling this only allows the current TOTP code to be valid /// TOTP codes of the previous and next 30 seconds will be invalid. @@ -776,11 +750,11 @@ make_config! { /// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely db_connection_retries: u32, false, def, 15; - /// Timeout in seconds when acquiring database connection - database_timeout: u32, false, def, 30; + /// Timeout when acquiring database connection + database_timeout: u64, false, def, 30; /// Timeout in seconds before idle connections to the database are closed - database_idle_timeout: u32, false, def, 600; + database_idle_timeout: u64, false, def, 600; /// Database connection max pool size database_max_conns: u32, false, def, 10; @@ -797,34 +771,30 @@ make_config! { /// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets allowed_iframe_ancestors: String, true, def, String::new(); - /// Allowed connect-src (Know the risks!) |> Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature + /// Allowed connect-src (Know the risks!) |> Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature allowed_connect_src: String, true, def, String::new(); /// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in - login_ratelimit_seconds: u16, false, def, 60; + login_ratelimit_seconds: u64, false, def, 60; /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2 - login_ratelimit_max_burst: u16, false, def, 10; + login_ratelimit_max_burst: u32, false, def, 10; /// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints - unauthenticated_ratelimit_seconds: u16, false, def, 60; + unauthenticated_ratelimit_seconds: u64, false, def, 60; /// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one - unauthenticated_ratelimit_max_burst: u16, false, def, 50; + unauthenticated_ratelimit_max_burst: u32, false, def, 50; /// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in - admin_ratelimit_seconds: u16, false, def, 300; + admin_ratelimit_seconds: u64, false, def, 300; /// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds` - admin_ratelimit_max_burst: u16, false, def, 3; + admin_ratelimit_max_burst: u32, false, def, 3; /// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes). - admin_session_lifetime: u32, true, def, 20; + admin_session_lifetime: i64, true, def, 20; /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). org_groups_enabled: bool, false, def, false; - /// Enable Custom roles (EXPERIMENTAL!) (Know the risks!) |> Enables the in-development Custom member role with granular permissions for organizations. - /// Only the permissions this version enforces can be granted. Disabling it again keeps the stored Custom roles, but they grant nothing until it is re-enabled. - experimental_custom_roles: bool, false, def, false; - /// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000. /// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! increase_note_size_limit: bool, true, def, false; @@ -873,8 +843,8 @@ make_config! { sso_master_password_policy: String, true, option; /// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days) sso_auth_only_not_session: bool, true, def, false; - /// Client cache for discovery endpoint. |> Duration in seconds (0 to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache - sso_client_cache_expiration: u32, true, def, 0; + /// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache + sso_client_cache_expiration: u64, true, def, 0; /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required sso_debug_tokens: bool, true, def, false; }, @@ -895,12 +865,16 @@ 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 @@ -932,11 +906,11 @@ make_config! { /// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','. smtp_auth_mechanism: String, true, option; /// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server - smtp_timeout: u16, true, def, 15; + smtp_timeout: u64, true, def, 15; /// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters helo_name: String, true, option; /// Embed images as email attachments. - smtp_embed_images: bool, true, def, true; + smtp_embed_images: bool, true, def, true; /// _smtp_img_src _smtp_img_src: String, false, generated, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain); /// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting! @@ -954,9 +928,9 @@ make_config! { /// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting. email_token_size: u8, true, def, 6; /// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token. - email_expiration_time: u32, true, def, 600; + email_expiration_time: u64, true, def, 600; /// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent - email_attempts_limit: u8, true, def, 3; + email_attempts_limit: u64, true, def, 3; /// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy email_2fa_enforce_on_verified_invite: bool, true, def, false; /// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed @@ -1012,15 +986,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } if cfg.database_min_conns > cfg.database_max_conns { - err!("`DATABASE_MIN_CONNS` must be less than or equal to `DATABASE_MAX_CONNS`."); - } - - if cfg.database_timeout < 1 { - err!("`DATABASE_TIMEOUT` must be at least 1 second"); - } - - if cfg.database_idle_timeout < 1 { - err!("`DATABASE_IDLE_TIMEOUT` must be at least 1 second"); + err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`."); } if let Some(log_file) = &cfg.log_file @@ -1036,21 +1002,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { ); } - let valid_fill_assist_rules_url = Url::parse(&cfg.fill_assist_rules_url).is_ok_and(|url| { - url.scheme() == "https" - && url.has_host() - && url.username().is_empty() - && url.password().is_none() - && url.query().is_none() - && url.fragment().is_none() - && cfg.fill_assist_rules_url.trim() == cfg.fill_assist_rules_url - }); - if !valid_fill_assist_rules_url { - err!( - "`FILL_ASSIST_RULES_URL` must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace." - ); - } - let connect_src = cfg.allowed_connect_src.to_lowercase(); for url in connect_src.split_whitespace() { if !url.starts_with("https://") || Url::parse(url).is_err() { @@ -1285,10 +1236,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { err!("`INVITATION_EXPIRATION_HOURS` has a minimum duration of 1 hour") } - if !(1..=90).contains(&cfg.two_factor_remember_days) { - err!("`TWO_FACTOR_REMEMBER_DAYS` must be between 1 and 90 days") - } - // Validate schedule crontab format if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::().is_err() { err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression") @@ -1322,38 +1269,6 @@ 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.login_ratelimit_seconds < 1 { - err!("`LOGIN_RATELIMIT_SECONDS` must be at least 1 second"); - } - - if cfg.login_ratelimit_max_burst < 1 { - err!("`LOGIN_RATELIMIT_MAX_BURST` must be at least 1"); - } - - if cfg.admin_ratelimit_seconds < 1 { - err!("`ADMIN_RATELIMIT_SECONDS` must be at least 1 second"); - } - - if cfg.admin_ratelimit_max_burst < 1 { - err!("`ADMIN_RATELIMIT_MAX_BURST` must be at least 1"); - } - - if cfg.unauthenticated_ratelimit_seconds < 1 { - err!("`UNAUTHENTICATED_RATELIMIT_SECONDS` must be at least 1 second"); - } - - if cfg.unauthenticated_ratelimit_max_burst < 1 { - err!("`UNAUTHENTICATED_RATELIMIT_MAX_BURST` must be at least 1"); - } - if !cfg.disable_admin_token { match cfg.admin_token.as_ref() { Some(t) if t.starts_with("$argon2") => { @@ -1503,57 +1418,36 @@ 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", - "fill-assist-targeting-rules", + "ssh-agent", "ssh-agent-v2", - "windows-desktop-autotype", - "windows-desktop-autotype-ga", // Key Management Team - "biometrics-sdk-ipc", - "windows-native-credential-sync", - "enable-account-encryption-v2-jit-password-registration", - "pm-27278-v2-password-registration", + "ssh-key-vault-item", + "pm-25373-windows-biometrics-v2", + "pm-26340-linux-biometrics-v2", // Mobile Team - "pm-34171-card-scanner", + "anon-addy-self-host-alias", + "simple-login-self-host-alias", + "mutual-tls", + "cxp-import-mobile", + "cxp-export-mobile", // Platform Team "pm-30529-webauthn-related-origins", - // Vault Team - "pm-32009-new-item-types", ]; -#[derive(Clone, Copy, Serialize)] -pub enum ConfigFileStatus { - NotFound, - Loaded, - Error, -} - impl Config { pub async fn load() -> Result { // Loading from env and file let env = ConfigBuilder::from_env(); - let (usr, config_file_status) = match ConfigBuilder::from_file().await { - Ok(Some(usr)) => (usr, ConfigFileStatus::Loaded), - Ok(None) => (ConfigBuilder::default(), ConfigFileStatus::NotFound), - Err(e) => { - println!( - "[ERROR] Failed to load configuration from `{}`\n\ - #####################################################################################\n\ - # The file seems to exist but might contain invalid JSON or invalid values. #\n\ - # Check the syntax and make sure every value is valid and within its allowed range. #\n\ - # This file is not used until those errors are resolved. #\n\ - #####################################################################################\n\ - {e:?}\n", - *CONFIG_FILE - ); - (ConfigBuilder::default(), ConfigFileStatus::Error) - } - }; + let usr = ConfigBuilder::from_file().await.unwrap_or_default(); // Create merged config, config file overwrites env let mut overrides = Vec::new(); @@ -1573,7 +1467,6 @@ impl Config { _env: env, _usr: usr, _overrides: overrides, - _config_file_status: config_file_status, }), }) } @@ -1607,7 +1500,6 @@ impl Config { writer.config = config; writer._usr = builder; writer._overrides = overrides; - writer._config_file_status = ConfigFileStatus::Loaded; } //Save to file @@ -1620,13 +1512,22 @@ 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). pub fn is_email_domain_allowed(&self, email: &str) -> bool { let e: Vec<&str> = email.rsplitn(2, '@').collect(); if e.len() != 2 || e[0].is_empty() || e[1].is_empty() { - warn!("Failed to parse email address '{}'", email.escape_debug()); + warn!("Failed to parse email address '{email}'"); return false; } let email_domain = e[0].to_lowercase(); @@ -1699,7 +1600,6 @@ impl Config { writer.config = config; writer._usr = usr; writer._overrides = Vec::new(); - writer._config_file_status = ConfigFileStatus::NotFound; } // Invalidate CSS Cache because several config items might have impact on the rendered CSS @@ -1716,6 +1616,23 @@ 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() } @@ -1847,7 +1764,6 @@ 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/mod.rs b/src/db/mod.rs index 62b40965..2eae3f3c 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -212,8 +212,8 @@ impl DbPool { let pool = Pool::builder() .max_size(max_conns) .min_idle(Some(CONFIG.database_min_conns())) - .idle_timeout(Some(Duration::from_secs(u64::from(CONFIG.database_idle_timeout())))) - .connection_timeout(Duration::from_secs(u64::from(CONFIG.database_timeout()))) + .idle_timeout(Some(Duration::from_secs(CONFIG.database_idle_timeout()))) + .connection_timeout(Duration::from_secs(CONFIG.database_timeout())) .connection_customizer(Box::new(DbConnOptions { init_stmts: conn_type.get_init_stmts(), })) @@ -233,7 +233,7 @@ impl DbPool { // Get a connection from the pool pub async fn get(&self) -> Result { - let duration = Duration::from_secs(u64::from(CONFIG.database_timeout())); + let duration = Duration::from_secs(CONFIG.database_timeout()); let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await { Ok(p) => p.expect("Semaphore should be open"), Err(_) => { @@ -534,74 +534,3 @@ mod postgresql_migrations { Ok(()) } } - -/// A throwaway SQLite database with the real schema, for tests that need to run queries. -#[cfg(all(test, sqlite))] -pub mod test_db { - use super::sqlite_migrations::MIGRATIONS; - use diesel::connection::SimpleConnection; - use diesel_migrations::MigrationHarness; - - #[test] - fn custom_role_migration_preserves_memberships_and_guards_downgrade() { - use super::schema::users_organizations::dsl::*; - use diesel::{migration::MigrationSource, prelude::*}; - let mut conn = SqliteConnection::establish(":memory:").unwrap(); - for migration in conn.pending_migrations(MIGRATIONS).unwrap() { - if migration.name().version().to_string() == "20261010120000" { - break; - } - conn.run_migration(&migration).unwrap(); - } - conn.batch_execute( - "PRAGMA foreign_keys=OFF; - INSERT INTO organizations (uuid,name,billing_email) VALUES ('org','Org','a@b'); - INSERT INTO users_organizations (uuid,user_uuid,org_uuid,access_all,akey,status,atype) VALUES - ('0','0','org',TRUE,'key',2,0), ('1','1','org',TRUE,'key',2,1), - ('2','2','org',FALSE,'key',1,2), ('3','3','org',FALSE,'key',2,3), ('4','4','org',TRUE,'key',2,3);", - ) - .unwrap(); - let rows = |conn: &mut SqliteConnection| { - users_organizations - .select((uuid, user_uuid, org_uuid, access_all, akey, status, atype)) - .order(uuid) - .load::<(String, String, String, bool, String, i32, i32)>(conn) - .unwrap() - }; - let before = rows(&mut conn); - conn.run_pending_migrations(MIGRATIONS).unwrap(); - assert_eq!(rows(&mut conn), before); - let grants = |conn: &mut SqliteConnection| { - diesel::select(diesel::dsl::sql::( - "(SELECT COUNT(*) FROM users_organizations WHERE manage_users OR manage_groups OR manage_policies - OR create_new_collections OR edit_any_collection OR delete_any_collection - OR access_event_logs OR access_import_export OR access_reports)", - )) - .get_result::(conn) - .unwrap() - }; - assert_eq!(grants(&mut conn), 0); - let migration = MigrationSource::::migrations(&MIGRATIONS) - .unwrap() - .into_iter() - .find(|m| m.name().version().to_string() == "20261010120000") - .unwrap(); - for member_status in [1, 2] { - conn.batch_execute(&format!( - "UPDATE users_organizations SET atype=4,manage_users=TRUE,status={member_status} WHERE uuid='2'" - )) - .unwrap(); - let custom = rows(&mut conn); - assert!(conn.revert_migration(&*migration).is_err()); - assert_eq!(rows(&mut conn), custom); - assert!(users_organizations.filter(uuid.eq("2")).select(manage_users).first::(&mut conn).unwrap()); - } - conn.batch_execute("UPDATE users_organizations SET atype=2,manage_users=FALSE,status=1 WHERE uuid='2'") - .unwrap(); - conn.revert_migration(&*migration).unwrap(); - assert!(diesel::sql_query("SELECT manage_users FROM users_organizations").execute(&mut conn).is_err()); - conn.run_pending_migrations(MIGRATIONS).unwrap(); - assert_eq!(grants(&mut conn), 0); - assert_eq!(rows(&mut conn), before); - } -} diff --git a/src/db/models/attachment.rs b/src/db/models/attachment.rs index 659648d0..0536dde5 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(1)).await?.uri().to_string()) + Ok(operator.presign_read(&self.get_file_path(), Duration::from_mins(5)).await?.uri().to_string()) } } diff --git a/src/db/models/auth_request.rs b/src/db/models/auth_request.rs index f2b0ecdc..cc4b60fd 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::{ApiResult, EmptyResult}, + api::EmptyResult, crypto::ct_eq, db::{DbConn, schema::auth_requests}, error::MapResult, @@ -166,30 +166,6 @@ impl AuthRequest { .await } - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { - diesel::delete(auth_requests::table.filter(auth_requests::user_uuid.eq(user_uuid))) - .execute(conn) - .map_res("Error deleting auth requests for user") - }) - .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) } @@ -197,7 +173,7 @@ impl AuthRequest { pub async fn purge_expired_auth_requests(conn: &DbConn) { // delete auth requests older than 15 minutes which is functionally equivalent to upstream: // https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql - let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::minutes(15); + let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap(); for auth_request in Self::find_created_before(&expiry_time, conn).await { auth_request.delete(conn).await.ok(); } diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index f87d61d8..721d9790 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -19,13 +19,13 @@ use crate::{ }, }, error::MapResult, - util::{LowerCase, convert_json_key_lcase_first}, + util::LowerCase, }; use macros::UuidFromParam; use super::{ Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, - MembershipStatus, OrganizationId, User, UserId, organization::ORG_ADMIN_ATYPES, + MembershipStatus, MembershipType, OrganizationId, User, UserId, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -63,18 +63,6 @@ pub struct Cipher { pub reprompt: Option, } -/// Whether `data` is a v2 cipher blob, recognized like upstream by a top-level `format_version` key. -/// -/// Ref: -pub fn is_data_blob_encrypted(data: &str) -> bool { - serde_json::from_str::(data).is_ok_and(|d| is_blob_value(&d)) -} - -/// [`is_data_blob_encrypted`] for `data` that was already parsed. -fn is_blob_value(data: &Value) -> bool { - data.get("format_version").is_some() -} - pub enum RepromptType { None = 0, Password = 1, @@ -82,8 +70,7 @@ pub enum RepromptType { /// Local methods impl Cipher { - /// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data` - pub fn new(atype: i32) -> Self { + pub fn new(atype: i32, name: String) -> Self { let now = Utc::now().naive_utc(); Self { @@ -97,7 +84,7 @@ impl Cipher { key: None, atype, - name: String::new(), + name, notes: None, fields: None, @@ -123,16 +110,6 @@ impl Cipher { .insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap()); } - let is_blob = cipher.is_blob(); - if let Err(e) = cipher.validate_content(is_blob) { - validation_errors - .insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap()); - } - if let Err(e) = cipher.validate_type_data(is_blob) { - validation_errors - .insert(format!("Ciphers[{index}].Type"), serde_json::to_value([e.message()]).unwrap()); - } - // Validate the password history if it contains `null` values and if so, return a warning if let Some(Value::Array(password_history)) = &cipher.password_history { for pwh in password_history { @@ -177,10 +154,6 @@ impl Cipher { ) -> Result { use crate::util::{format_date, validate_and_format_date}; - // Parsed once, since `data` can be large and this runs for every cipher in a sync - let type_data = serde_json::from_str::(&self.data); - let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value); - let mut attachments_json: Value = Value::Null; if let Some(cipher_sync_data) = cipher_sync_data { if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid) @@ -277,9 +250,63 @@ impl Cipher { }) .unwrap_or_default(); - // Like upstream, fields and password history stored as NULL are sent as null, not `[]` - let fields_json = self.fields.is_some().then_some(fields_json); - let password_history_json = self.password_history.is_some().then_some(password_history_json); + // Get the type_data or a default to an empty json object '{}'. + // If not passing an empty object, mobile clients will crash. + let mut type_data_json = serde_json::from_str::>(&self.data) + .inspect_err(|_| warn!("Error parsing data field for {}", self.uuid)) + .map_or_else(|_| Value::Object(serde_json::Map::new()), |d| d.data); + + // NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream + // Set the first element of the Uris array as Uri, this is needed several (mobile) clients. + if self.atype == 1 { + // Upstream always has an `uri` key/value + type_data_json["uri"] = Value::Null; + if let Some(uris) = type_data_json["uris"].as_array_mut() + && !uris.is_empty() + { + // Fix uri match values first, they are only allowed to be a number or null + // If it is a string, convert it to an int or null if that fails + for uri in &mut *uris { + if uri["match"].is_string() { + let match_value = match uri["match"].as_str().unwrap_or_default().parse::() { + Ok(n) => json!(n), + _ => Value::Null, + }; + uri["match"] = match_value; + } + } + type_data_json["uri"] = uris[0]["uri"].clone(); + } + + // Check if `passwordRevisionDate` is a valid date, else convert it + if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() { + type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision)); + } + } + + // Fix secure note issues when data is invalid + // This breaks at least the native mobile clients + if self.atype == 2 { + match type_data_json { + Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {} + _ => { + type_data_json = json!({"type": 0}); + } + } + } + + // Fix invalid SSH Entries + // This breaks at least the native mobile client if invalid + // The only way to fix this is by setting type_data_json to `null` + // Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it + if self.atype == 5 + && (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty) + || type_data_json["privateKey"].as_str().is_none_or(str::is_empty) + || type_data_json["publicKey"].as_str().is_none_or(str::is_empty)) + { + warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid); + type_data_json = Value::Null; + } let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) { @@ -376,84 +403,10 @@ impl Cipher { _ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)), }; - if is_blob_encrypted { - // Like upstream, sent as-is with the structured fields null - json_object["data"] = json!(self.data); - json_object["name"] = Value::Null; - } else { - json_object[key] = self.legacy_type_data_json(type_data); - } + json_object[key] = type_data_json; Ok(json_object) } - /// The per-type data of a legacy cipher, with fixups for values known to break clients. - fn legacy_type_data_json(&self, type_data: Result) -> Value { - use crate::util::validate_and_format_date; - - // Get the type_data or a default to an empty json object '{}'. - // If not passing an empty object, mobile clients will crash. - let mut type_data_json = if let Ok(data @ Value::Object(_)) = type_data { - convert_json_key_lcase_first(data) - } else { - warn!("Error parsing data field for {}", self.uuid); - Value::Object(serde_json::Map::new()) - }; - - // NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream - // Set the first element of the Uris array as Uri, this is needed several (mobile) clients. - if self.atype == 1 { - // Upstream always has an `uri` key/value - type_data_json["uri"] = Value::Null; - if let Some(uris) = type_data_json["uris"].as_array_mut() - && !uris.is_empty() - { - // Fix uri match values first, they are only allowed to be a number or null - // If it is a string, convert it to an int or null if that fails - for uri in &mut *uris { - if uri["match"].is_string() { - let match_value = match uri["match"].as_str().unwrap_or_default().parse::() { - Ok(n) => json!(n), - _ => Value::Null, - }; - uri["match"] = match_value; - } - } - type_data_json["uri"] = uris[0]["uri"].clone(); - } - - // Check if `passwordRevisionDate` is a valid date, else convert it - if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() { - type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision)); - } - } - - // Fix secure note issues when data is invalid - // This breaks at least the native mobile clients - if self.atype == 2 { - match type_data_json { - Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {} - _ => { - type_data_json = json!({"type": 0}); - } - } - } - - // Fix invalid SSH Entries - // This breaks at least the native mobile client if invalid - // The only way to fix this is by setting type_data_json to `null` - // Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it - if self.atype == 5 - && (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty) - || type_data_json["privateKey"].as_str().is_none_or(str::is_empty) - || type_data_json["publicKey"].as_str().is_none_or(str::is_empty)) - { - warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid); - type_data_json = Value::Null; - } - - type_data_json - } - pub async fn update_users_revision(&self, conn: &DbConn) -> Vec { let mut user_uuids = Vec::new(); match self.user_uuid { @@ -541,22 +494,12 @@ impl Cipher { /// Purge all ciphers that are old enough to be auto-deleted. pub async fn purge_trash(conn: &DbConn) { - let Some(auto_delete_days) = CONFIG.trash_auto_delete_days().filter(|&d| d != 0) else { - debug!("trash_auto_delete_days is not configured or set to 0, skipping"); - return; - }; - - let Some(days) = TimeDelta::try_days(i64::from(auto_delete_days)) else { - error!("Unable to calculate auto delete days!"); - return; - }; - let now = Utc::now().naive_utc(); - let Some(dt) = now.checked_sub_signed(days) else { - error!("Unable to calculate trash purge date!"); - return; - }; - for cipher in Self::find_deleted_before(&dt, conn).await { - cipher.delete(conn).await.ok(); + if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() { + let now = Utc::now().naive_utc(); + let dt = now - TimeDelta::try_days(auto_delete_days).unwrap(); + for cipher in Self::find_deleted_before(&dt, conn).await { + cipher.delete(conn).await.ok(); + } } } @@ -567,16 +510,7 @@ impl Cipher { conn: &DbConn, ) -> EmptyResult { User::update_uuid_revision(user_uuid, conn).await; - self.move_to_folder_without_revision_update(folder_uuid, user_uuid, conn).await - } - /// Same as `move_to_folder`, but the caller is responsible for updating the user's revision. - pub async fn move_to_folder_without_revision_update( - &self, - folder_uuid: Option, - user_uuid: &UserId, - conn: &DbConn, - ) -> EmptyResult { match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) { // No changes (None, None) => Ok(()), @@ -723,27 +657,16 @@ 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) - // Only allow collection access via the confirmed membership. - .and(users_organizations::user_uuid.eq(users_collections::user_uuid))), + .and(users_collections::user_uuid.eq(user_uuid))), ) .select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) .load::<(bool, bool, bool)>(conn) @@ -774,10 +697,6 @@ 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)) - .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") @@ -919,7 +838,7 @@ impl Cipher { if !visible_only { query = query.or_filter( - users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner + users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner ); } @@ -954,7 +873,7 @@ impl Cipher { if !visible_only { query = query.or_filter( - users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner + users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner ); } @@ -1070,7 +989,6 @@ 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 @@ -1102,7 +1020,6 @@ 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 @@ -1147,7 +1064,6 @@ 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 @@ -1158,7 +1074,7 @@ impl Cipher { .or(collections_groups::collections_uuid .is_not_null() // Access via groups .and(collections_groups::read_only.eq(false))) - .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner + .or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner ) .select(ciphers_collections::collection_uuid) .load::(conn) @@ -1180,14 +1096,13 @@ 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 .or(users_collections::user_uuid .eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))) - .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner + .or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner ) .select(ciphers_collections::collection_uuid) .load::(conn) @@ -1229,10 +1144,9 @@ impl Cipher { ) .or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection .or_filter(users_organizations::access_all.eq(true)) // User has access all - .or_filter(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner + .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 68c48ce9..be108f13 100644 --- a/src/db/models/collection.rs +++ b/src/db/models/collection.rs @@ -18,7 +18,7 @@ use macros::UuidFromParam; use super::{ CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, - User, UserId, organization::ORG_ADMIN_ATYPES, + User, UserId, }; // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs @@ -367,7 +367,6 @@ 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) @@ -375,7 +374,7 @@ impl Collection { // Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization - users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner + users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner ), ) .or( @@ -407,12 +406,11 @@ 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( // access_all in Organization - users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner + users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner ), )) .select(collections::all_columns) @@ -432,8 +430,7 @@ 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::status.eq(MembershipStatus::Confirmed as i32))), + .and(users_organizations::user_uuid.eq(user_uuid.clone()))), ) .left_join( users_collections::table.on(users_collections::collection_uuid @@ -455,7 +452,7 @@ impl Collection { ) .filter( users_organizations::atype - .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner + .le(MembershipType::Admin as i32) // Org admin or owner .or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_collections::collection_uuid .eq(&self.uuid) // write access given to collection @@ -479,8 +476,7 @@ 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::status.eq(MembershipStatus::Confirmed as i32))), + .and(users_organizations::user_uuid.eq(user_uuid.clone()))), ) .left_join( users_collections::table.on(users_collections::collection_uuid @@ -489,7 +485,7 @@ impl Collection { ) .filter( users_organizations::atype - .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner + .le(MembershipType::Admin as i32) // Org admin or owner .or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_collections::collection_uuid .eq(&self.uuid) // write access given to collection @@ -531,7 +527,6 @@ 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) @@ -540,7 +535,7 @@ impl Collection { // Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization - users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner + users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner ), ) .or( @@ -591,7 +586,6 @@ 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) @@ -600,7 +594,7 @@ impl Collection { // Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization - users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner + users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner ), ) .or( @@ -656,7 +650,6 @@ 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)), @@ -826,18 +819,10 @@ 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/emergency_access.rs b/src/db/models/emergency_access.rs index 02c05d5e..09783061 100644 --- a/src/db/models/emergency_access.rs +++ b/src/db/models/emergency_access.rs @@ -1,4 +1,4 @@ -use chrono::{NaiveDateTime, TimeDelta, Utc}; +use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use serde_json::Value; @@ -112,26 +112,8 @@ impl EmergencyAccess { "object": "emergencyAccessGranteeDetails", })) } - - pub fn deadline_at(&self) -> Result { - if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&self.wait_time_days) { - err!("Emergency access wait time is out-of-bounds!") - } - - let initiated_at = self.recovery_initiated_at.map_res("Emergency access initiation date missing!")?; - - TimeDelta::try_days(i64::from(self.wait_time_days)) - .and_then(|w| initiated_at.checked_add_signed(w)) - .map_res("Emergency access deadline is invalid") - } - - pub fn is_recovery_allowed(&self, now: &NaiveDateTime) -> Result { - Ok(self.deadline_at()?.le(now)) - } } -pub const EMERGENCY_ACCESS_TIMEOUT_RANGE: std::ops::RangeInclusive = 1..=90; - #[derive(Copy, Clone)] pub enum EmergencyAccessType { View = 0, @@ -187,42 +169,27 @@ impl EmergencyAccess { pub async fn update_access_status_and_save( &mut self, - from_status: i32, - to_status: i32, + status: i32, date: &NaiveDateTime, conn: &DbConn, ) -> EmptyResult { - let uuid = self.uuid.clone(); - let updated_at = *date; - let res = conn - .run(move |conn| { - crate::util::retry( - || { - diesel::update( - emergency_access::table - .filter(emergency_access::uuid.eq(&uuid)) - .filter(emergency_access::status.eq(from_status)), - ) - .set((emergency_access::status.eq(to_status), emergency_access::updated_at.eq(updated_at))) - .execute(conn) - }, - 10, - ) - }) - .await; - - match res { - Ok(1) => {} // One record updated, which is valid - Ok(_) => err!("Emergency access status was changed already"), - Err(e) => err!("Error updating emergency access status", format!("{e}")), - } - - self.status = to_status; - self.updated_at = updated_at; - // Update the grantee so that it will refresh it's status. User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await; - Ok(()) + self.status = status; + date.clone_into(&mut self.updated_at); + + conn.run(move |conn| { + crate::util::retry( + || { + diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid))) + .set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date))) + .execute(conn) + }, + 10, + ) + .map_res("Error updating emergency access status") + }) + .await } pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { diff --git a/src/db/models/event.rs b/src/db/models/event.rs index e020bb3d..2d9ed8b2 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -79,7 +79,6 @@ pub enum EventType { CipherSoftDeleted = 1115, CipherRestored = 1116, CipherClientToggledCardNumberVisible = 1117, - CipherClientHttpAuthReleased = 1133, // Collection CollectionCreated = 1300, @@ -112,9 +111,9 @@ pub enum EventType { // OrganizationUserAutomaticallyConfirmed = 1517, // OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy OrganizationUserAdminResetTwoFactor = 1519, - OrganizationUserRevokedTwoFactorNonCompliance = 1520, - OrganizationUserRevokedSingleOrganizationNonCompliance = 1521, - OrganizationUserNotificationBannerActionClicked = 1522, + // OrganizationUserRevoked_TwoFactorNonCompliance = 1520, + // OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521, + // OrganizationUserNotificationBannerActionClicked = 1522, // Organization OrganizationUpdated = 1600, @@ -348,23 +347,17 @@ impl Event { } pub async fn clean_events(conn: &DbConn) -> EmptyResult { - let Some(days_to_retain) = CONFIG.events_days_retain().filter(|&d| d != 0) else { - return Ok(()); - }; - - let Some(days_to_retain) = TimeDelta::try_days(i64::from(days_to_retain)) else { - err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'") - }; - - let Some(dt) = Utc::now().naive_utc().checked_sub_signed(days_to_retain) else { - err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'") - }; - conn.run(move |conn| { - diesel::delete(event::table.filter(event::event_date.lt(dt))) - .execute(conn) - .map_res("Error cleaning old events") - }) - .await + if let Some(days_to_retain) = CONFIG.events_days_retain() { + let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap(); + conn.run(move |conn| { + diesel::delete(event::table.filter(event::event_date.lt(dt))) + .execute(conn) + .map_res("Error cleaning old events") + }) + .await + } else { + Ok(()) + } } } diff --git a/src/db/models/group.rs b/src/db/models/group.rs index c8df7ef2..32e9333f 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, MembershipStatus, OrganizationId, User, UserId}; +use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId}; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = groups)] @@ -104,6 +104,7 @@ 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" @@ -137,15 +138,15 @@ impl CollectionGroup { } pub fn to_json_details_for_group(&self) -> Value { - // Return the stored manage flag. Deriving it from !read_only && !hide_passwords turned the group - // permission "Can edit items" (readOnly=false, hidePasswords=false, manage=false) into "Can manage" - // in GET /organizations//collections/details, while GET /groups//details reported the stored - // value. Saving the collection access dialog then persisted manage=true. + // If both read_only and hide_passwords are false, then manage should be true + // You can't have an entry with read_only and manage, or hide_passwords and manage + // Or an entry with everything to false + // For backwards compatibility and migration proposes we keep checking read_only and hide_password json!({ "id": self.groups_uuid, "readOnly": self.read_only, "hidePasswords": self.hide_passwords, - "manage": self.manage, + "manage": self.manage || (!self.read_only && !self.hide_passwords), }) } } @@ -248,7 +249,6 @@ 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() @@ -268,9 +268,6 @@ 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) @@ -387,7 +384,6 @@ 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") @@ -573,6 +569,26 @@ 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/mod.rs b/src/db/models/mod.rs index 32681652..0ed8ef91 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -17,30 +17,26 @@ mod two_factor; mod two_factor_duo_context; mod two_factor_incomplete; mod user; -mod user_signature_key_pair; pub use self::archive::Archive; pub use self::attachment::{Attachment, AttachmentId}; pub use self::auth_request::{AuthRequest, AuthRequestId}; -pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted}; +pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; -pub use self::emergency_access::{ - EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, -}; +pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType}; pub use self::event::{Event, EventType}; pub use self::favorite::Favorite; pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; -pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType}; +pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::organization::{ - CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, - OrganizationApiKey, OrganizationId, custom_roles_enabled, + Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, + OrganizationId, }; pub use self::send::{Send, SendFileId, SendId, SendType}; 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, KeyId, SsoUser, User, UserId, UserKdfType}; -pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair}; +pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException}; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 554f51fc..2b45cd86 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -1,4 +1,3 @@ -use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, From}; use diesel::prelude::*; use serde::Deserialize; @@ -12,7 +11,6 @@ use crate::{ schema::{org_policies, users_organizations}, }, error::MapResult, - util::format_date, }; use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId}; @@ -26,7 +24,6 @@ pub struct OrgPolicy { pub atype: i32, pub enabled: bool, pub data: String, - pub revision_date: NaiveDateTime, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs @@ -49,12 +46,9 @@ pub enum OrgPolicyType { RemoveUnlockWithPin = 14, RestrictedItemTypes = 15, UriMatchDefaults = 16, - AutotypeDefaultSetting = 17, + // AutotypeDefaultSetting = 17, // Not supported yet // AutoConfirm = 18, // Not supported (not implemented yet) // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) - OrganizationUserNotification = 20, - // SendControls = 21, // Not supported yet - FillAssist = 22, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5 @@ -73,14 +67,6 @@ pub struct ResetPasswordDataModel { pub auto_enroll_enabled: bool, } -// https://github.com/bitwarden/server/blob/13bc813de8e715a71d3410f1e584e87ccdf76025/src/Core/AdminConsole/Models/Data/Organizations/Policies/FillAssistPolicyData.cs -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FillAssistPolicyData { - #[serde(alias = "RulesUrl")] - pub rules_url: Option, -} - /// Local methods impl OrgPolicy { pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self { @@ -90,7 +76,6 @@ impl OrgPolicy { atype: atype as i32, enabled, data, - revision_date: Utc::now().naive_utc(), } } @@ -106,7 +91,7 @@ impl OrgPolicy { "type": self.atype, "data": data_json, "enabled": self.enabled, - "revisionDate": format_date(&self.revision_date), + "revisionDate": null, "object": "policy", }); @@ -124,13 +109,11 @@ impl OrgPolicy { /// Database methods impl OrgPolicy { - pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { - self.revision_date = Utc::now().naive_utc(); - + pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { match diesel::replace_into(org_policies::table) - .values(&*self) + .values(self) .execute(conn) { Ok(_) => Ok(()), @@ -138,7 +121,7 @@ impl OrgPolicy { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(org_policies::table) .filter(org_policies::uuid.eq(&self.uuid)) - .set(&*self) + .set(self) .execute(conn) .map_res("Error saving org_policy") } @@ -158,10 +141,10 @@ impl OrgPolicy { .map_res("Error deleting org_policy for insert")?; diesel::insert_into(org_policies::table) - .values(&*self) + .values(self) .on_conflict(org_policies::uuid) .do_update() - .set(&*self) + .set(self) .execute(conn) .map_res("Error saving org_policy") } @@ -203,26 +186,6 @@ impl OrgPolicy { .await } - pub async fn find_accepted_and_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> 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 c6f70f89..29016865 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -44,7 +44,6 @@ pub struct Organization { #[diesel(table_name = users_organizations)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] -#[expect(clippy::struct_excessive_bools, reason = "Each Custom role permission is a column of its own")] pub struct Membership { pub uuid: MembershipId, pub user_uuid: UserId, @@ -58,20 +57,6 @@ pub struct Membership { pub atype: i32, pub reset_password_key: Option, pub external_id: Option, - - // The permissions of a Custom role, only accessed through `CustomPermission`. - manage_users: bool, - manage_groups: bool, - manage_policies: bool, - create_new_collections: bool, - edit_any_collection: bool, - delete_any_collection: bool, - access_event_logs: bool, - access_import_export: bool, - access_reports: bool, - - /// The user's v2 upgrade token, for the org to unwrap the v2 user key after an upgrade. Opaque to us. - pub v2_upgrade_token: Option, } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -107,68 +92,42 @@ impl MembershipStatus { } } -#[derive(Copy, Clone, Debug, PartialEq, Eq, num_derive::FromPrimitive)] +#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] pub enum MembershipType { Owner = 0, Admin = 1, User = 2, - // The legacy Manager role. The Bitwarden clients no longer know it and are shown a Custom role instead. Manager = 3, - // Only granted while EXPERIMENTAL_CUSTOM_ROLES is enabled. - Custom = 4, } impl MembershipType { pub fn from_str(s: &str) -> Option { + #[expect( + clippy::match_same_arms, + reason = "Specifically define `4|Custom` since this is a hack, not a default" + )] match s { "0" | "Owner" => Some(MembershipType::Owner), "1" | "Admin" => Some(MembershipType::Admin), "2" | "User" => Some(MembershipType::User), "3" | "Manager" => Some(MembershipType::Manager), - "4" | "Custom" => Some(MembershipType::Custom), + // HACK: We convert the custom role to a manager role + "4" | "Custom" => Some(MembershipType::Manager), _ => None, } } - - /// The role a member invite or edit from the Bitwarden clients asks for, given the member's current - /// role (`None` for an invite). - /// - /// The clients no longer know the legacy Manager role and are shown a Custom role in its place, so a - /// requested Custom role is the legacy Manager unless EXPERIMENTAL_CUSTOM_ROLES is enabled. An existing - /// Manager or Custom member keeps their role when the request repeats it, so an unrelated edit never - /// turns one into the other. - pub fn requested( - requested: MembershipType, - current: Option<&Membership>, - custom_roles_enabled: bool, - ) -> MembershipType { - if requested != MembershipType::Custom { - return requested; - } - match current { - Some(member) if member.has_type(MembershipType::Custom) => MembershipType::Custom, - Some(member) if member.has_type(MembershipType::Manager) => MembershipType::Manager, - _ if custom_roles_enabled => MembershipType::Custom, - _ => MembershipType::Manager, - } - } - - /// The authority of a role, which is what roles are compared by. A Custom member gets its authority from - /// its permissions, never from its role, so the role itself ranks right above User and below Manager. - const fn rank(self) -> u8 { - match self { - MembershipType::User => 0, - MembershipType::Custom => 1, - MembershipType::Manager => 2, - MembershipType::Admin => 3, - MembershipType::Owner => 4, - } - } } impl Ord for MembershipType { fn cmp(&self, other: &MembershipType) -> Ordering { - self.rank().cmp(&other.rank()) + // For easy comparison, map each variant to an access level (where 0 is lowest). + const ACCESS_LEVEL: [i32; 4] = [ + 3, // Owner + 2, // Admin + 0, // User + 1, // Manager && Custom + ]; + ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize]) } } @@ -224,91 +183,6 @@ impl PartialOrd for i32 { } } -/// The granular permissions of the Custom role. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum CustomPermission { - ManageUsers, - ManageGroups, - ManagePolicies, - CreateNewCollections, - EditAnyCollection, - DeleteAnyCollection, - AccessEventLogs, - AccessImportExport, - AccessReports, -} - -/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored -/// grant of any other one grants nothing. They are implemented one at a time, so this grows with them. -const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[]; - -impl CustomPermission { - pub const ALL: [CustomPermission; 9] = [ - CustomPermission::ManageUsers, - CustomPermission::ManageGroups, - CustomPermission::ManagePolicies, - CustomPermission::CreateNewCollections, - CustomPermission::EditAnyCollection, - CustomPermission::DeleteAnyCollection, - CustomPermission::AccessEventLogs, - CustomPermission::AccessImportExport, - CustomPermission::AccessReports, - ]; - - /// The key of the permission in the `permissions` object of the Bitwarden clients. - pub const fn json_key(self) -> &'static str { - match self { - CustomPermission::ManageUsers => "manageUsers", - CustomPermission::ManageGroups => "manageGroups", - CustomPermission::ManagePolicies => "managePolicies", - CustomPermission::CreateNewCollections => "createNewCollections", - CustomPermission::EditAnyCollection => "editAnyCollection", - CustomPermission::DeleteAnyCollection => "deleteAnyCollection", - CustomPermission::AccessEventLogs => "accessEventLogs", - CustomPermission::AccessImportExport => "accessImportExport", - CustomPermission::AccessReports => "accessReports", - } - } - - pub fn is_supported(self) -> bool { - SUPPORTED_CUSTOM_PERMISSIONS.contains(&self) - } - - /// The permissions a member invite or edit grants a Custom role. - /// - /// A permission is only granted when it is set to `true`, and anything but a boolean is rejected: this - /// replaces the member's permissions, so a malformed value must not silently remove one. Granting a - /// permission this version does not enforce is rejected as well. The clients also send permissions - /// Vaultwarden does not know at all, like `manageSso`, those are ignored. - pub fn requested(permissions: &HashMap) -> Result, crate::Error> { - let mut granted = HashSet::new(); - for permission in CustomPermission::ALL { - match permissions.get(permission.json_key()) { - None | Some(Value::Bool(false)) => {} - Some(Value::Bool(true)) if permission.is_supported() => { - granted.insert(permission); - } - Some(Value::Bool(true)) => { - err!(format!("The Custom role permission '{}' is not supported yet", permission.json_key())) - } - Some(_) => { - err!(format!("The Custom role permission '{}' must be true or false", permission.json_key())) - } - } - } - Ok(granted) - } -} - -/// The stored roles with authority over the whole organization. Queries match them by value, comparing -/// `atype <= Admin` would also match any unknown value below it. -pub(super) const ORG_ADMIN_ATYPES: [i32; 2] = [MembershipType::Owner as i32, MembershipType::Admin as i32]; - -/// Whether the experimental Custom role is enabled. Nothing is ever granted through a Custom role otherwise. -pub fn custom_roles_enabled() -> bool { - CONFIG.experimental_custom_roles() -} - /// Local methods impl Organization { pub fn new(name: String, billing_email: &str, private_key: Option, public_key: Option) -> Self { @@ -400,94 +274,9 @@ impl Membership { atype: MembershipType::User as i32, reset_password_key: None, external_id: None, - - manage_users: false, - manage_groups: false, - manage_policies: false, - create_new_collections: false, - edit_any_collection: false, - delete_any_collection: false, - access_event_logs: false, - access_import_export: false, - access_reports: false, - - v2_upgrade_token: None, } } - fn custom_permission_mut(&mut self, permission: CustomPermission) -> &mut bool { - match permission { - CustomPermission::ManageUsers => &mut self.manage_users, - CustomPermission::ManageGroups => &mut self.manage_groups, - CustomPermission::ManagePolicies => &mut self.manage_policies, - CustomPermission::CreateNewCollections => &mut self.create_new_collections, - CustomPermission::EditAnyCollection => &mut self.edit_any_collection, - CustomPermission::DeleteAnyCollection => &mut self.delete_any_collection, - CustomPermission::AccessEventLogs => &mut self.access_event_logs, - CustomPermission::AccessImportExport => &mut self.access_import_export, - CustomPermission::AccessReports => &mut self.access_reports, - } - } - - /// Change the role. Nothing the old role carried survives a role change: the Custom role permissions are - /// dropped, so changing the role back later starts from none. A Custom role never keeps the legacy - /// `access_all` flag, its reach only comes from its permissions. - pub fn set_type(&mut self, new_type: MembershipType) { - if self.atype != new_type as i32 { - for permission in CustomPermission::ALL { - *self.custom_permission_mut(permission) = false; - } - } - if new_type == MembershipType::Custom { - self.access_all = false; - } - self.atype = new_type as i32; - } - - /// Grant this Custom member exactly `permissions` out of those this version enforces. A stored grant of a - /// permission this version does not enforce is kept as it is: it grants nothing here. - pub fn set_custom_permissions(&mut self, permissions: &HashSet) { - for permission in CustomPermission::ALL.into_iter().filter(|p| p.is_supported()) { - *self.custom_permission_mut(permission) = permissions.contains(&permission); - } - } - - /// Why this legacy Manager can't become a Custom member without losing what they may do now, if they can't. A - /// conversion must not silently change their access, and a Custom role can't keep access to all collections - /// (which also puts every collection in the Manager's own vault), nor manage any collection yet. - pub async fn custom_role_conversion_loss(&self, conn: &DbConn) -> Option<&'static str> { - if !self.has_type(MembershipType::Manager) { - return None; - } - if self.access_all { - return Some( - "This Manager has access to all collections, which a Custom member can't have: the collections they \ - aren't assigned to would disappear from their vault. Uncheck Manage all collections for them first, \ - or keep the Manager role.", - ); - } - // Whatever their status, the collections they manage directly, through a group or through a group with - // access to all collections - let mut manages = GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await - || CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) - .await - .iter() - .any(|assignment| assignment.manage); - for group_user in GroupUser::find_by_member(&self.uuid, conn).await { - if manages { - break; - } - manages = CollectionGroup::find_by_group(&group_user.groups_uuid, &self.org_uuid, conn) - .await - .iter() - .any(|assignment| assignment.manage); - } - manages.then_some( - "This Manager manages collections, with the manage permission or through a group with access to all \ - collections, which a Custom member can't do yet. Remove that first, or keep the Manager role.", - ) - } - pub fn restore(&mut self) -> bool { if self.status < MembershipStatus::Invited as i32 { self.status += ACTIVATE_REVOKE_DIFF; @@ -556,7 +345,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.escape_debug())) + err!(format!("BillingEmail {} is not a valid email address", self.billing_email)) } for member in &Membership::find_by_org(&self.uuid, conn).await { @@ -729,7 +518,8 @@ impl Membership { "limitCollectionDeletion": true, "limitItemDeletion": false, "allowAdminAccessToAllCollectionItems": true, - "userIsClaimedByOrganization": false, // Means not managed via the Members UI, like SSO + "userIsManagedByOrganization": false, // Means not managed via the Members UI, like SSO + "userIsClaimedByOrganization": false, // The new key instead of the obsolete userIsManagedByOrganization "permissions": permissions, @@ -737,8 +527,7 @@ impl Membership { // These are per user "userId": self.user_uuid, - // Accepted (not yet confirmed) members have no key yet; send null like upstream, not "" - "key": (!self.akey.is_empty()).then_some(&self.akey), + "key": self.akey, "status": self.status, "type": membership_type, "enabled": true, @@ -747,13 +536,7 @@ impl Membership { }) } - // 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 { + pub async fn to_json_user_details(&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. @@ -776,23 +559,52 @@ 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() && GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await; + CONFIG.org_groups_enabled() && Group::is_in_full_access_group(&self.user_uuid, &self.org_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) { - CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) + // 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(|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), - }) + .map(|cg| cg.collections_uuid) + .collect(); + + Collection::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, + })) }) .collect() } else { @@ -840,6 +652,7 @@ 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(), @@ -847,6 +660,7 @@ 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) @@ -897,6 +711,7 @@ impl Membership { "status": status, "type": self.atype, + "accessAll": self.access_all, "collections": coll_uuids, "object": "organizationUserDetails", @@ -926,12 +741,6 @@ impl Membership { } pub async fn save(&self, conn: &DbConn) -> EmptyResult { - // The legacy `access_all` flag reaches every collection, which a Custom role may only do through its - // permissions, so it must never be stored on one. `set_type` takes care of this. - if self.has_type(MembershipType::Custom) && self.access_all { - err!("A Custom role membership can't have access to all collections") - } - User::update_uuid_revision(&self.user_uuid, conn).await; db_run! { conn: @@ -1035,21 +844,6 @@ 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 @@ -1223,6 +1017,17 @@ 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 @@ -1245,7 +1050,6 @@ 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 @@ -1272,7 +1076,6 @@ 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)), ) @@ -1308,7 +1111,6 @@ 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]), ) @@ -1448,93 +1250,13 @@ pub struct OrgApiKeyId(String); #[cfg(test)] mod tests { use super::*; - use MembershipType::{Admin, Custom, Manager, Owner, User}; - - fn member(role: MembershipType) -> Membership { - let mut member = Membership::new("user".into(), "org".into(), None); - member.atype = role as i32; - member.status = MembershipStatus::Confirmed as i32; - member - } #[test] - fn membership_type_authority() { - assert!(Owner > Admin && Admin > Manager && Manager > Custom && Custom > User); - assert!((Custom as i32) >= User && (Custom as i32) < Manager && (Custom as i32) < Admin); - for raw in [99, -1] { - assert_eq!((raw >= User, raw >= Manager, raw >= Admin, raw < Admin), (false, false, false, true)); - } - for (value, expected) in [ - ("3", Some(Manager)), - ("Manager", Some(Manager)), - ("4", Some(Custom)), - ("Custom", Some(Custom)), - ("5", None), - ] { - assert_eq!(MembershipType::from_str(value), expected); - } - } - - #[test] - fn requested_membership_type() { - for (current, disabled, enabled) in [ - (None, Manager, Custom), - (Some(User), Manager, Custom), - (Some(Manager), Manager, Manager), - (Some(Custom), Custom, Custom), - ] { - let current = current.map(member); - for (flag, expected) in [(false, disabled), (true, enabled)] { - assert_eq!(MembershipType::requested(Custom, current.as_ref(), flag), expected); - } - } - assert_eq!(MembershipType::requested(User, Some(&member(Custom)), true), User); - assert_eq!(MembershipType::requested(User, Some(&member(Manager)), true), User); - for requested in [Owner, Admin, Manager] { - assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), true), requested); - } - } - - #[test] - fn custom_permission_requests() { - let ignored = serde_json::from_value(json!({"manageUsers":false,"manageSso":true})).unwrap(); - assert!(CustomPermission::requested(&ignored).unwrap().is_empty()); - for permission in CustomPermission::ALL { - for value in [json!(true), json!(null), json!("true"), json!(1), json!([true])] { - let request = HashMap::from([(permission.json_key().to_owned(), value.clone())]); - let result = CustomPermission::requested(&request); - if value == json!(true) && SUPPORTED_CUSTOM_PERMISSIONS.contains(&permission) { - assert_eq!(result.unwrap(), HashSet::from([permission])); - } else { - assert!(result.is_err(), "{permission:?}: {value}"); - } - } - } - assert_eq!(SUPPORTED_CUSTOM_PERMISSIONS, []); - } - - #[test] - fn role_changes_drop_custom_permissions() { - for (from, to) in [ - (Custom, Owner), - (Custom, Admin), - (Custom, Manager), - (Custom, User), - (User, Custom), - (Manager, Custom), - (Custom, Custom), - (Admin, User), - ] { - let mut member = member(from); - member.access_all = true; - for permission in CustomPermission::ALL { - *member.custom_permission_mut(permission) = true; - } - member.set_type(to); - for permission in CustomPermission::ALL { - assert_eq!(*member.custom_permission_mut(permission), from == to, "{from:?} -> {to:?}"); - } - assert_eq!(member.access_all, to != Custom); - } + #[allow(non_snake_case)] + fn partial_cmp_MembershipType() { + assert!(MembershipType::Owner > MembershipType::Admin); + assert!(MembershipType::Admin > MembershipType::Manager); + assert!(MembershipType::Manager > MembershipType::User); + assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); } } diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 118f947f..d7de7749 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -140,10 +140,6 @@ 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(); @@ -154,7 +150,7 @@ impl Send { json!({ "id": self.uuid, - "accessId": self.access_id(), + "accessId": BASE64URL_NOPAD.encode(Uuid::parse_str(&self.uuid).unwrap_or_default().as_bytes()), "type": self.atype, "name": self.name, @@ -186,7 +182,7 @@ impl Send { } json!({ - "id": self.access_id(), + "id": self.uuid, "type": self.atype, "name": self.name, @@ -260,7 +256,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` - /// (counted on each access) or the password. + /// (consumed at token issuance) or the password. pub fn is_accessible(&self) -> bool { let now = Utc::now().naive_utc(); if self.disabled { @@ -313,6 +309,19 @@ 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 } @@ -334,6 +343,7 @@ impl Send { pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option { #[derive(serde::Deserialize)] struct FileData { + #[serde(rename = "size", alias = "Size")] size: NumberOrString, } @@ -351,6 +361,13 @@ 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| { diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 051ea4df..5f57635e 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: TwoFactorType, conn: &DbConn) -> Option { + pub async fn find_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Option { conn.run(move |conn| { twofactor::table .filter(twofactor::user_uuid.eq(user_uuid)) - .filter(twofactor::atype.eq(atype as i32)) + .filter(twofactor::atype.eq(atype)) .first::(conn) .ok() }) diff --git a/src/db/models/two_factor_incomplete.rs b/src/db/models/two_factor_incomplete.rs index e4c7e308..4b8cadcb 100644 --- a/src/db/models/two_factor_incomplete.rs +++ b/src/db/models/two_factor_incomplete.rs @@ -37,7 +37,7 @@ impl TwoFactorIncomplete { ip: &ClientIp, conn: &DbConn, ) -> EmptyResult { - if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() { + if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { return Ok(()); } @@ -66,7 +66,7 @@ impl TwoFactorIncomplete { } pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult { - if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() { + if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { return Ok(()); } diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 20126001..81cb8d84 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -6,7 +6,6 @@ use serde_json::Value; use crate::{ CONFIG, api::EmptyResult, - auth::DEFAULT_ACCESS_VALIDITY, crypto, db::{ DbConn, @@ -20,8 +19,7 @@ use crate::{ use macros::UuidFromParam; use super::{ - AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, - TwoFactorIncomplete, UserSignatureKeyPair, + Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] @@ -71,15 +69,6 @@ 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, - - // The v2 state: all set, with a `user_signature_key_pairs` row, or none, see `User::is_v2` - pub signed_public_key: Option, - pub security_state: Option, - pub security_version: Option, - /// JSON `{"wrappedUserKey1", "wrappedUserKey2"}` for sessions that still hold the v1 user key. Opaque to us. - pub v2_upgrade_token: Option, } #[derive(Identifiable, Queryable, Insertable)] @@ -108,28 +97,13 @@ enum UserStatus { _Disabled = 2, } -/// A previous security stamp that is still accepted, until `expire`. #[derive(Serialize, Deserialize)] pub struct UserStampException { - /// The routes the stamp is still accepted on, or `None` for any route. - pub routes: Option>, + pub routes: Vec, pub security_stamp: String, pub expire: i64, } -impl UserStampException { - pub fn is_expired(&self) -> bool { - Utc::now().timestamp() > self.expire - } - - /// Whether this exception lets a token carrying `security_stamp` through on `route`. - pub fn allows(&self, security_stamp: &str, route: &str) -> bool { - !self.is_expired() - && self.security_stamp == security_stamp - && self.routes.as_ref().is_none_or(|routes| routes.iter().any(|r| r == route)) - } -} - /// Local methods impl User { pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32; @@ -180,33 +154,9 @@ impl User { avatar_color: None, external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. - - key_id: None, - - signed_public_key: None, - security_state: None, - security_version: None, - v2_upgrade_token: None, } } - pub fn is_v2(&self) -> bool { - self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some() - } - - /// Upstream's `HasV2Encryption`: a private key wrapped as `CoseEncrypt0B64` (type 7) and security version 2. - /// Upstream only uses it to decide which accounts need a v2-capable client. - /// - /// Ref: - pub fn has_v2_encryption(&self) -> bool { - self.security_version == Some(2) - && self - .private_key - .as_deref() - .and_then(|private_key| private_key.split_once('.')) - .is_some_and(|(enc_type, _)| enc_type.parse::() == Ok(7)) - } - pub fn check_valid_password(&self, password: &str) -> bool { crypto::verify_password_hash( password.as_bytes(), @@ -216,11 +166,6 @@ impl User { ) } - /// Upstream's `GetMasterPasswordSalt`: there is no separate salt, so the normalized email - 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()) @@ -271,33 +216,10 @@ impl User { pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult { self.security_stamp = get_uuid(); - // Keeps the unexpired route exceptions, set for the stamp being replaced, and drops a rotation's grace - let mut exceptions = self.stamp_exceptions(); - exceptions.retain(|e| e.routes.is_some() && !e.is_expired()); - self.set_stamp_exceptions(&exceptions); Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?; Ok(()) } - /// Resets the stamp but lets issued access tokens run out, as upstream only checks it on refresh. - pub async fn reset_security_stamp_after_key_rotation(&mut self, conn: &DbConn) -> EmptyResult { - let previous_stamp = self.security_stamp.clone(); - let mut grace: Vec = - self.stamp_exceptions().into_iter().filter(|e| e.routes.is_none() && !e.is_expired()).collect(); - - self.reset_security_stamp(conn).await?; - - grace.push(UserStampException { - routes: None, - security_stamp: previous_stamp, - expire: (Utc::now() + *DEFAULT_ACCESS_VALIDITY).timestamp(), - }); - let mut exceptions = self.stamp_exceptions(); - exceptions.extend(grace); - self.set_stamp_exceptions(&exceptions); - Ok(()) - } - /// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp. /// /// # Arguments @@ -306,30 +228,17 @@ impl User { /// After these 2 minutes this stamp will expire. /// pub fn set_stamp_exception(&mut self, route_exception: Vec) { - self.set_stamp_exceptions(&[UserStampException { - routes: Some(route_exception), + let stamp_exception = UserStampException { + routes: route_exception, security_stamp: self.security_stamp.clone(), expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(), - }]); + }; + self.stamp_exception = Some(serde_json::to_string(&stamp_exception).unwrap_or_default()); } - /// The previous security stamps that are still accepted, expired ones included. - pub fn stamp_exceptions(&self) -> Vec { - let Some(stored) = self.stamp_exception.as_deref() else { - return Vec::new(); - }; - // Before key rotations kept sessions alive, only a single route exception was stored. - serde_json::from_str::>(stored) - .or_else(|_| serde_json::from_str::(stored).map(|e| vec![e])) - .unwrap_or_default() - } - - fn set_stamp_exceptions(&mut self, exceptions: &[UserStampException]) { - self.stamp_exception = if exceptions.is_empty() { - None - } else { - Some(serde_json::to_string(&exceptions).unwrap_or_default()) - }; + /// Resets the stamp_exception to prevent re-use of the previous security-stamp + pub fn reset_stamp_exception(&mut self) { + self.stamp_exception = None; } pub fn display_name(&self) -> &str { @@ -344,77 +253,12 @@ impl User { /// Database methods impl User { - pub fn verified(&self) -> bool { - !CONFIG.mail_enabled() || self.verified_at.is_some() - } - - async fn v2_signature_key_pair(&self, conn: &DbConn) -> Option { - if !self.is_v2() { - return None; - } - UserSignatureKeyPair::find_by_user(&self.uuid, conn).await - } - - /// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair - pub async fn account_keys_json(&self, conn: &DbConn) -> Value { - if self.private_key.is_some() { - let (signed_public_key, signature_key_pair, security_state) = match self.v2_signature_key_pair(conn).await { - Some(key_pair) => ( - json!(self.signed_public_key), - key_pair.to_json(), - json!({ - "securityState": self.security_state, - "securityVersion": self.security_version, - }), - ), - None => (Value::Null, Value::Null, Value::Null), - }; - - json!({ - "publicKeyEncryptionKeyPair": { - "wrappedPrivateKey": self.private_key, - "publicKey": self.public_key, - "signedPublicKey": signed_public_key, - "object": "publicKeyEncryptionKeyPair", - }, - "securityState": security_state, - "signatureKeyPair": signature_key_pair, - "object": "privateKeys" - }) - } else { - Value::Null - } - } - - pub async fn public_keys_json(&self, conn: &DbConn) -> Value { - let (signed_public_key, verifying_key) = match self.v2_signature_key_pair(conn).await { - Some(key_pair) => (json!(self.signed_public_key), json!(key_pair.verifying_key)), - None => (Value::Null, Value::Null), - }; - - json!({ - "publicKey": self.public_key, - "signedPublicKey": signed_public_key, - "verifyingKey": verifying_key, - "object": "publicKeys" - }) - } - - pub fn v2_upgrade_token_json(&self) -> Option { - self.v2_upgrade_token.as_ref().and_then(|token| serde_json::from_str(token).ok()) - } - 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 { 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 @@ -424,7 +268,21 @@ impl User { UserStatus::Enabled }; - let account_keys = self.account_keys_json(conn).await; + 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 + }; json!({ "_status": status as i32, @@ -432,7 +290,7 @@ impl User { "id": self.uuid, "name": self.name, "email": self.email, - "emailVerified": self.verified(), + "emailVerified": !CONFIG.mail_enabled() || self.verified_at.is_some(), "premium": true, "premiumFromOrganization": false, "culture": "en-US", @@ -441,7 +299,6 @@ impl User { "privateKey": self.private_key, "securityStamp": self.security_stamp, "organizations": orgs_json, - "organizationsNew": orgs_new_json, "providers": [], "providerOrganizations": [], "forcePasswordReset": false, @@ -454,7 +311,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.escape_debug())) + err!(format!("User email {} is not a valid email address", self.email)) } self.updated_at = Utc::now().naive_utc(); @@ -481,7 +338,7 @@ impl User { } } - pub async fn delete(&self, conn: &DbConn) -> EmptyResult { + pub async fn delete(self, conn: &DbConn) -> EmptyResult { for member in Membership::find_confirmed_by_user(&self.uuid, conn).await { if member.atype == MembershipType::Owner && Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1 @@ -497,15 +354,13 @@ impl User { Cipher::delete_all_by_user(&self.uuid, conn).await?; Favorite::delete_all_by_user(&self.uuid, conn).await?; Folder::delete_all_by_user(&self.uuid, conn).await?; - AuthRequest::delete_all_by_user(&self.uuid, conn).await?; Device::delete_all_by_user(&self.uuid, conn).await?; TwoFactor::delete_all_by_user(&self.uuid, conn).await?; TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?; - UserSignatureKeyPair::delete_all_by_user(&self.uuid, conn).await?; Invitation::take(&self.email, conn).await; // Delete invitation if any conn.run(move |conn| { - diesel::delete(users::table.filter(users::uuid.eq(&self.uuid))).execute(conn).map_res("Error deleting user") + diesel::delete(users::table.filter(users::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting user") }) .await } @@ -604,7 +459,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.escape_debug())) + err!(format!("Invitation email {} is not a valid email address", self.email)) } db_run! { conn: @@ -672,27 +527,6 @@ impl Invitation { #[from(forward)] pub struct UserId(String); -#[derive( - Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, AsRef, Deref, Display, From, UuidFromParam, -)] -#[deref(forward)] -#[from(forward)] -pub struct KeyId(String); - -/// Like upstream, a key id is 32 lowercase hex characters. Key ids are compared as strings, so one in any other -/// format would never match the id the clients send. -/// -/// Ref: -impl<'de> serde::Deserialize<'de> for KeyId { - fn deserialize>(deserializer: D) -> Result { - let key_id = String::deserialize(deserializer)?; - if key_id.len() != 32 || !key_id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { - return Err(serde::de::Error::custom("Key id must be 32 lowercase hex characters")); - } - Ok(Self(key_id)) - } -} - impl SsoUser { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: @@ -746,39 +580,3 @@ impl SsoUser { .await } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_key_id_format() { - let key_id: KeyId = serde_json::from_str(r#""0123456789abcdef0123456789abcdef""#).unwrap(); - assert_eq!(key_id.as_ref(), "0123456789abcdef0123456789abcdef"); - - for invalid in ["", "0123456789ABCDEF0123456789ABCDEF", "0123456789abcdef", "0123456789abcdef0123456789abcdeg"] - { - assert!(serde_json::from_str::(&format!("\"{invalid}\"")).is_err(), "{invalid:?}"); - } - - // An absent key id stays optional - assert_eq!(serde_json::from_str::>("null").unwrap(), None); - } - - #[test] - fn test_has_v2_encryption() { - let user = |private_key: Option<&str>, security_version: Option| { - let mut user = User::new("user@example.com", None); - user.private_key = private_key.map(String::from); - user.security_version = security_version; - user - }; - - assert!(user(Some("7.cose"), Some(2)).has_v2_encryption()); - assert!(!user(Some("7.cose"), Some(1)).has_v2_encryption()); - assert!(!user(Some("7.cose"), None).has_v2_encryption()); - assert!(!user(Some("2.iv|data|mac"), Some(2)).has_v2_encryption()); - assert!(!user(Some("7"), Some(2)).has_v2_encryption()); - assert!(!user(None, Some(2)).has_v2_encryption()); - } -} diff --git a/src/db/models/user_signature_key_pair.rs b/src/db/models/user_signature_key_pair.rs deleted file mode 100644 index 72509761..00000000 --- a/src/db/models/user_signature_key_pair.rs +++ /dev/null @@ -1,132 +0,0 @@ -use chrono::{NaiveDateTime, Utc}; -use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; -use serde_json::Value; - -use crate::{ - api::EmptyResult, - db::{DbConn, schema::user_signature_key_pairs}, - error::MapResult, - util::get_uuid, -}; - -use super::UserId; - -/// A user's signature key pair, part of the v2 state, in its own table like upstream. -/// -/// Ref: -#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] -#[diesel(table_name = user_signature_key_pairs)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid))] -pub struct UserSignatureKeyPair { - pub uuid: UserSignatureKeyPairId, - pub user_uuid: UserId, - - pub signature_algorithm: i32, - /// The signing (private) key, wrapped by the user key. - pub signing_key: String, - /// The COSE-encoded public verifying key. - pub verifying_key: String, - - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -/// Ref: -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SignatureAlgorithm { - Ed25519 = 0, - MlDsa44 = 1, -} - -impl SignatureAlgorithm { - pub fn parse(algorithm: &str) -> Option { - match algorithm { - "ed25519" => Some(Self::Ed25519), - "mldsa44" => Some(Self::MlDsa44), - _ => None, - } - } -} - -/// Local methods -impl UserSignatureKeyPair { - pub fn new( - user_uuid: UserId, - signature_algorithm: SignatureAlgorithm, - signing_key: String, - verifying_key: String, - ) -> Self { - let now = Utc::now().naive_utc(); - - Self { - uuid: UserSignatureKeyPairId(get_uuid()), - user_uuid, - signature_algorithm: signature_algorithm as i32, - signing_key, - verifying_key, - created_at: now, - updated_at: now, - } - } - - pub fn to_json(&self) -> Value { - json!({ - "wrappedSigningKey": self.signing_key, - "verifyingKey": self.verifying_key, - "object": "signatureKeyPair", - }) - } -} - -/// Database methods -impl UserSignatureKeyPair { - pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { - self.updated_at = Utc::now().naive_utc(); - - db_run! { conn: - mysql { - diesel::insert_into(user_signature_key_pairs::table) - .values(&*self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(&*self) - .execute(conn) - .map_res("Error saving user signature key pair") - } - postgresql, sqlite { - diesel::insert_into(user_signature_key_pairs::table) - .values(&*self) - .on_conflict(user_signature_key_pairs::user_uuid) - .do_update() - .set(&*self) - .execute(conn) - .map_res("Error saving user signature key pair") - } - } - } - - /// The user's key pair. There is at most one, enforced by a unique index on `user_uuid`. - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option { - conn.run(move |conn| { - user_signature_key_pairs::table - .filter(user_signature_key_pairs::user_uuid.eq(user_uuid)) - .first::(conn) - .ok() - }) - .await - } - - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { - diesel::delete(user_signature_key_pairs::table.filter(user_signature_key_pairs::user_uuid.eq(user_uuid))) - .execute(conn) - .map_res("Error deleting user signature key pairs") - }) - .await - } -} - -#[derive(Clone, Debug, AsRef, Deref, DieselNewType, Display, From, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub struct UserSignatureKeyPairId(String); diff --git a/src/db/schema.rs b/src/db/schema.rs index 98792674..af342186 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -116,7 +116,6 @@ table! { atype -> Integer, enabled -> Bool, data -> Text, - revision_date -> Timestamp, } } @@ -218,23 +217,6 @@ table! { api_key -> Nullable, avatar_color -> Nullable, external_id -> Nullable, - key_id -> Nullable, - signed_public_key -> Nullable, - security_state -> Nullable, - security_version -> Nullable, - v2_upgrade_token -> Nullable, - } -} - -table! { - user_signature_key_pairs (uuid) { - uuid -> Text, - user_uuid -> Text, - signature_algorithm -> Integer, - signing_key -> Text, - verifying_key -> Text, - created_at -> Timestamp, - updated_at -> Timestamp, } } @@ -260,16 +242,6 @@ table! { atype -> Integer, reset_password_key -> Nullable, external_id -> Nullable, - manage_users -> Bool, - manage_groups -> Bool, - manage_policies -> Bool, - create_new_collections -> Bool, - edit_any_collection -> Bool, - delete_any_collection -> Bool, - access_event_logs -> Bool, - access_import_export -> Bool, - access_reports -> Bool, - v2_upgrade_token -> Nullable, } } @@ -410,7 +382,6 @@ joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); joinable!(sso_users -> users (user_uuid)); -joinable!(user_signature_key_pairs -> users (user_uuid)); allow_tables_to_appear_in_same_query!( archives, @@ -437,5 +408,4 @@ allow_tables_to_appear_in_same_query!( collections_groups, event, auth_requests, - user_signature_key_pairs, ); diff --git a/src/error.rs b/src/error.rs index 3fee3dc4..d90c38e3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -425,12 +425,12 @@ macro_rules! err_json { macro_rules! err_handler { ($expr:expr) => {{ error!(target: "auth", "Unauthorized Error: {}", $expr); - return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, $expr)); + return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $expr)); }}; ($usr_msg:expr, $log_value:expr) => {{ let usr_msg = $usr_msg; let log_value = $log_value; error!(target: "auth", "Unauthorized Error: {usr_msg}. {log_value}"); - return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, usr_msg)); + return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, usr_msg)); }}; } diff --git a/src/http_client.rs b/src/http_client.rs index 6225e233..5ef293fc 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::LOCALHOST)); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); 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::LOCALHOST)); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); assert!(!is_global_hardcoded(ip)); } #[test] fn hex_loopback_normalizes() { let ip = parse_to_ip("0x7f000001").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); 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::LOCALHOST)); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); 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::LOCALHOST)); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); 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::LOCALHOST)); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); 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::LOCALHOST)); + assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1))); } #[test] fn get_valid_host_normalizes_hex() { let h = get_valid_host("0x7f000001").expect("valid"); - assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST)); + assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1))); } #[test] fn get_valid_host_normalizes_octal() { let h = get_valid_host("017700000001").expect("valid"); - assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::LOCALHOST)); + assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1))); } // === diff --git a/src/mail.rs b/src/mail.rs index 14294213..b20f2853 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -36,7 +36,7 @@ fn smtp_transport() -> AsyncSmtpTransport { let smtp_client = AsyncSmtpTransport::::builder_dangerous(host.as_str()) .port(CONFIG.smtp_port()) - .timeout(Some(Duration::from_secs(u64::from(CONFIG.smtp_timeout())))); + .timeout(Some(Duration::from_secs(CONFIG.smtp_timeout()))); // Determine security let smtp_client = if CONFIG.smtp_security() == *"off" { @@ -169,7 +169,7 @@ pub async fn send_password_hint(address: &str, hint: Option) -> EmptyRes } pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult { - let claims = generate_delete_claims(user_id.to_string())?; + let claims = generate_delete_claims(user_id.to_string()); let delete_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( @@ -187,7 +187,7 @@ pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult } pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { - let claims = generate_verify_email_claims(user_id)?; + let claims = generate_verify_email_claims(user_id); let verify_email_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( @@ -206,7 +206,7 @@ pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult { let mut query = url::Url::parse("https://query.builder").unwrap(); - query.query_pairs_mut().append_pair("email", email).append_pair("token", token).append_pair("fromEmail", "true"); + query.query_pairs_mut().append_pair("email", email).append_pair("token", token); let Some(query_string) = query.query() else { err!("Failed to build verify URL query parameters") }; @@ -237,7 +237,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult { } pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult { - let claims = generate_verify_email_claims(user_id)?; + let claims = generate_verify_email_claims(user_id); let verify_email_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( @@ -292,7 +292,7 @@ pub async fn send_invite( org_id.clone(), member_id.clone(), invited_by_email, - )?; + ); let invite_token = encode_jwt(&claims); let mut query = url::Url::parse("https://query.builder").unwrap(); { @@ -351,7 +351,7 @@ pub async fn send_emergency_access_invite( emer_id.clone(), String::from(grantor_name), String::from(grantor_email), - )?; + ); // Build the query here to ensure proper escaping let mut query = url::Url::parse("https://query.builder").unwrap(); @@ -531,21 +531,6 @@ 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, @@ -738,8 +723,8 @@ async fn send_email(address: &str, subject: &str, body_html: String, body_text: let smtp_from = Address::from_str(&CONFIG.smtp_from())?; let body = if CONFIG.smtp_embed_images() { - let logo_gray_body = Body::new(crate::api::STATIC_FILES["logo-gray.png"].bytes.to_vec()); - let mail_github_body = Body::new(crate::api::STATIC_FILES["mail-github.png"].bytes.to_vec()); + let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png").unwrap().1.to_vec()); + let mail_github_body = Body::new(crate::api::static_files("mail-github.png").unwrap().1.to_vec()); MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart( MultiPart::related() .singlepart(SinglePart::html(body_html)) diff --git a/src/main.rs b/src/main.rs index 76c561f2..437354af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -595,7 +595,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> .manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS)) .attach(util::AppHeaders()) .attach(util::Cors()) - .attach(util::BetterLogging::new(extra_debug, basepath)) + .attach(util::BetterLogging(extra_debug)) .ignite() .await?; @@ -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() { + if !CONFIG.duo_context_purge_schedule().is_empty() && CONFIG._enable_duo() && !CONFIG.duo_use_iframe() { sched.add(Job::new(CONFIG.duo_context_purge_schedule().parse().unwrap(), || { runtime.spawn(purge_duo_contexts(pool.clone())); })); @@ -729,7 +729,7 @@ fn schedule_jobs(pool: db::DbPool) { // Cleanup the event table of records x days old. if CONFIG.org_events_enabled() && !CONFIG.event_cleanup_schedule().is_empty() - && CONFIG.events_days_retain().is_some_and(|d| d != 0) + && CONFIG.events_days_retain().is_some() { sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || { runtime.spawn(api::event_cleanup_job(pool.clone())); @@ -754,9 +754,7 @@ fn schedule_jobs(pool: db::DbPool) { // tick, the one that was added earlier will run first. loop { sched.tick(); - runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(u64::from( - CONFIG.job_poll_interval_ms(), - )))); + runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(CONFIG.job_poll_interval_ms()))); } }) .expect("Error spawning job scheduler thread"); diff --git a/src/ratelimit.rs b/src/ratelimit.rs index 50d1f99b..70217957 100644 --- a/src/ratelimit.rs +++ b/src/ratelimit.rs @@ -7,20 +7,20 @@ use crate::{CONFIG, Error}; type Limiter = RateLimiter, DefaultClock>; static LIMITER_LOGIN: LazyLock = LazyLock::new(|| { - let seconds = Duration::from_secs(u64::from(CONFIG.login_ratelimit_seconds())); - let burst = NonZeroU32::new(u32::from(CONFIG.login_ratelimit_max_burst())).expect("Non-zero login ratelimit burst"); + let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds()); + let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst"); RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst)) }); static LIMITER_ADMIN: LazyLock = LazyLock::new(|| { - let seconds = Duration::from_secs(u64::from(CONFIG.admin_ratelimit_seconds())); - let burst = NonZeroU32::new(u32::from(CONFIG.admin_ratelimit_max_burst())).expect("Non-zero admin ratelimit burst"); + let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds()); + let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst"); RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst)) }); static LIMITER_UNAUTHENTICATED: LazyLock = LazyLock::new(|| { - let seconds = Duration::from_secs(u64::from(CONFIG.unauthenticated_ratelimit_seconds())); - let burst = NonZeroU32::new(u32::from(CONFIG.unauthenticated_ratelimit_max_burst())) + let seconds = Duration::from_secs(CONFIG.unauthenticated_ratelimit_seconds()); + let burst = NonZeroU32::new(CONFIG.unauthenticated_ratelimit_max_burst()) .expect("Non-zero unauthenticated ratelimit burst"); RateLimiter::keyed( Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst), diff --git a/src/sso.rs b/src/sso.rs index baa17492..01fbd906 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -2,6 +2,7 @@ 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::{ @@ -174,16 +175,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 {} after base64 decoding", base64_state.escape_debug())) + err!(format!("Invalid utf8 chars in {base64_state} after base64 decoding")) } } else { - err!(format!("Failed to decode {} using base64", base64_state.escape_debug())) + err!(format!("Failed to decode {base64_state} using base64")) }; Ok(state) } -// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/StaticClientStore.cs +// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs pub async fn authorize_url( state: OIDCState, client_challenge: OIDCCodeChallenge, @@ -192,21 +193,20 @@ 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" => localhost_redirect_uri.unwrap_or_else(|| "bitwarden://sso-callback".to_owned()), - "mobile" => "bitwarden://sso-callback".to_owned(), + "desktop" | "mobile" => "bitwarden://sso-callback".to_owned(), "cli" => { - let Some(uri) = localhost_redirect_uri else { - err!(format!("Unsupported redirect uri {}", raw_redirect_uri.escape_debug())) - }; - uri + 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") + } } - _ => err!(format!("Unsupported client {}", client_id.escape_debug())), + _ => err!(format!("Unsupported client {client_id}")), }; let (auth_url, sso_auth) = Client::authorize_url(state, client_challenge, redirect_uri, binding_hash).await?; @@ -242,8 +242,7 @@ 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, -// after checking the PKCE verifier again like upstream does on every redemption. +// - second time we will rely on `SsoAuth.auth_response` since the `code` has already been exchanged. // The `SsoAuth` will ensure that the user is authorized only once. pub async fn exchange_code( code: &OIDCCode, @@ -257,7 +256,6 @@ 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)); } @@ -266,8 +264,8 @@ pub async fn exchange_code( (_, Some(re)) => { let error_msg = format!( "SSO authorization failed: {}, {}", - re.error.escape_debug(), - re.error_description.as_deref().unwrap_or_default().escape_debug() + re.error, + re.error_description.as_ref().unwrap_or(&String::new()) ); sso_auth.delete(conn).await?; err!(error_msg); @@ -309,11 +307,7 @@ pub async fn exchange_code( user_name: user_name.clone(), }; - if CONFIG.sso_debug_tokens() { - debug!("Authenticated user {authenticated_user:?}"); - } else { - debug!("Authenticated user {identifier}"); - } + debug!("Authenticated user {authenticated_user:?}"); sso_auth.auth_response = Some(authenticated_user.clone()); sso_auth.updated_at = Utc::now().naive_utc(); sso_auth.save(conn).await?; diff --git a/src/sso_client.rs b/src/sso_client.rs index 246e5678..bc766586 100644 --- a/src/sso_client.rs +++ b/src/sso_client.rs @@ -29,7 +29,7 @@ static CLIENT_CACHE_KEY: LazyLock = LazyLock::new(|| "sso-client".to_own static CLIENT_CACHE: LazyLock> = LazyLock::new(|| { moka::sync::Cache::builder() .max_capacity(1) - .time_to_live(Duration::from_secs(u64::from(CONFIG.sso_client_cache_expiration()))) + .time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())) .build() }); static REFRESH_CACHE: LazyLock>> = @@ -212,16 +212,6 @@ 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, @@ -244,10 +234,15 @@ 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(PkceCodeVerifier::new(client_verifier.into())); + exchange = exchange.set_pkce_verifier(verifier); } else { - Self::check_client_verifier(&client_verifier, sso_auth)?; + 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 ? + } } match exchange.request_async(&self.http_client).await { @@ -260,7 +255,7 @@ impl Client { }; if CONFIG.sso_debug_tokens() { - debug!("Id token: {id_token}"); + debug!("Id token: {}", id_token.to_string()); debug!("Access token: {}", token_response.access_token().secret()); debug!("Refresh token: {:?}", token_response.refresh_token().map(RefreshToken::secret)); debug!("Expiration time: {:?}", token_response.expires_in()); diff --git a/src/static/scripts/admin_diagnostics.js b/src/static/scripts/admin_diagnostics.js index 1d2f6605..ae4d4235 100644 --- a/src/static/scripts/admin_diagnostics.js +++ b/src/static/scripts/admin_diagnostics.js @@ -93,9 +93,7 @@ async function generateSupportString(event, dj) { supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`; supportString += `* Database type: ${dj.db_type}\n`; supportString += `* Database version: ${dj.db_version}\n`; - supportString += `* Uses config.json: ${yn(dj.config_file_status !== "NotFound")}`; - supportString += dj.config_file_status !== "NotFound" ? ` (Valid: ${chk(dj.config_file_status === "Loaded")})\n` : "\n"; - supportString += `* Environment variables are valid: ${chk(dj.invalid_env_vars == "")}\n`; + supportString += `* Uses config.json: ${yn(dj.overrides !== "")}\n`; supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`; supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`; if (dj.ip_header_exists) { @@ -118,7 +116,7 @@ async function generateSupportString(event, dj) { } supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`; if (dj.invalid_feature_flags != "") { - supportString += "* Invalid feature flags: true :x:\n"; + supportString += "* Invalid feature flags: true\n"; } const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, { @@ -134,11 +132,6 @@ async function generateSupportString(event, dj) { supportString += "\n### Config & Details (Generated via diagnostics page)\n\n"; supportString += "
Show Config & Details\n"; - // Show invalid environment variables if they exists - if (dj.invalid_env_vars != "") { - supportString += `\n**Invalid environment variables:** ${dj.invalid_env_vars}\n`; - } - // Add overrides if they exists if (dj.overrides != "") { supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`; diff --git a/src/static/scripts/admin_users.js b/src/static/scripts/admin_users.js index 63128627..63ee2d7b 100644 --- a/src/static/scripts/admin_users.js +++ b/src/static/scripts/admin_users.js @@ -173,14 +173,10 @@ const ORG_TYPES = { "name": "User", "bg": "blue" }, - "3": { + "4": { "name": "Manager", "bg": "green" }, - "4": { - "name": "Custom", - "bg": "teal" - }, }; const userOrgTypeDialog = document.getElementById("userOrgTypeDialog"); @@ -191,19 +187,15 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function (event) { const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid; // Get org specific values const userOrgType = event.relatedTarget.dataset.vwOrgType; + const userOrgTypeName = ORG_TYPES[userOrgType]["name"]; const orgName = event.relatedTarget.dataset.vwOrgName; const orgUuid = event.relatedTarget.dataset.vwOrgUuid; - document.getElementById("userOrgTypeForm").reset(); document.getElementById("userOrgTypeDialogOrgName").textContent = orgName; document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail; document.getElementById("userOrgTypeUserUuid").value = userUuid; document.getElementById("userOrgTypeOrgUuid").value = orgUuid; - // The Custom role can only be selected while it is enabled - const userOrgTypeRadio = document.getElementById(`userOrgType${ORG_TYPES[userOrgType]?.name}`); - if (userOrgTypeRadio) { - userOrgTypeRadio.checked = true; - } + document.getElementById(`userOrgType${userOrgTypeName}`).checked = true; }, false); // Prevent accidental submission of the form with valid elements after the modal has been hidden. @@ -230,7 +222,7 @@ function updateUserOrgType(event) { function initUserTable() { // Color all the org buttons per type document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) { - const orgType = ORG_TYPES[e.dataset.vwOrgType] ?? { "name": "Unknown", "bg": "gray" }; + const orgType = ORG_TYPES[e.dataset.vwOrgType]; e.style.backgroundColor = orgType.bg; if (orgType.font !== undefined) { e.style.color = orgType.font; diff --git a/src/static/scripts/datatables.css b/src/static/scripts/datatables.css index 81827eed..48b7400c 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.1.4 + * https://datatables.net/download/#bs5/dt-3.0.3 * * Included libraries: - * DataTables 3.1.4 + * DataTables 3.0.3 */ /*! DataTables Bootstrap 5 integration @@ -636,18 +636,6 @@ 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 8dc93bd6..1ae94cd2 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.1.4 + * https://datatables.net/download/#bs5/dt-3.0.3 * * Included libraries: - * DataTables 3.1.4 + * DataTables 3.0.3 */ -/*! DataTables 3.1.4 +/*! DataTables 3.0.3 * Copyright (c) SpryMedia Ltd - datatables.net/license */ @@ -318,7 +318,7 @@ function htmlNum(d, decimalPoint, formatted, allowEmpty) { } return !html(d) ? null - : num(stripHtml(d), decimalPoint, formatted, allowEmpty) + : num$1(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(d, decimalPoint, formatted, allowEmpty) { +function num$1(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, + num: num$1, plainObject: plainObject }); @@ -453,9 +453,6 @@ function assignDeep(out, ...inputs) { continue; } for (const [key, value] of Object.entries(input)) { - if (key === '__proto__') { - continue; - } if (Array.isArray(value)) { if (!Array.isArray(out[key])) { out[key] = []; @@ -479,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 - * objects. + * arrays. * * @param out Object to extend * @param extender Object from which the properties will be applied to out @@ -488,20 +485,18 @@ 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; for (let prop in extender) { - if (prop === '__proto__') { - continue; - } if (Object.prototype.hasOwnProperty.call(extender, prop)) { val = extender[prop]; if (plainObject(val)) { if (!plainObject(out[prop])) { out[prop] = {}; } - assignDeepObjects(out[prop], val, breakRefs); + assignDeep(out[prop], val); } else if (breakRefs && prop !== 'data' && @@ -540,134 +535,12 @@ 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: '' + url: location.href }; /** * Trigger an Ajax call to the server based on the configuration parameters @@ -826,9 +699,8 @@ function convertSpaces(sendData, options) { */ function isCrossDomain(url) { // Use the current page as the base to handle relative URLs correctly - const win = external('win'); - const target = new URL(url, win.location.origin); - return target.origin !== win.location.origin; + const target = new URL(url, window.location.origin); + return target.origin !== window.location.origin; } /** * Get the HTTP method from the Ajax request options @@ -1367,6 +1239,107 @@ 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) { @@ -1676,8 +1649,7 @@ function parseEventName(original) { */ function add(el, nameFull, handler, selector, one) { let jq = external('jq'); - let doc = external('doc'); - if (jq && el.constructor !== EventTarget) { + if (jq) { let method = one ? 'one' : 'on'; if (selector) { jq(el)[method](nameFull, selector, handler); @@ -1694,8 +1666,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 === doc && eventName === 'DOMContentLoaded' && nameFull.includes('ready')) { - if (doc.readyState === 'complete') { + if (el === document && eventName === 'DOMContentLoaded' && nameFull.includes('ready')) { + if (document.readyState === 'complete') { handler(new Event('DOMContentLoaded')); return; } @@ -1766,7 +1738,7 @@ function add(el, nameFull, handler, selector, one) { */ function remove(el, nameFull, handler, selector) { let jq = external('jq'); - if (jq && el.constructor !== EventTarget) { + if (jq) { if (selector) { jq(el).off(nameFull, selector, handler); } @@ -1834,8 +1806,7 @@ function remove(el, nameFull, handler, selector) { */ function trigger(el, nameFull, bubbles = false, args = [], eventProps = null, returnEvent = false) { let jq = external('jq'); - let win = external('win'); - if (jq && el.constructor !== EventTarget) { + if (jq) { let method = bubbles ? 'trigger' : 'triggerHandler'; let ev = jq.Event(nameFull); each(eventProps, (key, val) => { @@ -1854,19 +1825,10 @@ function trigger(el, nameFull, bubbles = false, args = [], eventProps = null, re if (!eventName) { return false; } - 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 }); - } + let isMouseEvent = _mouseEvents.includes(eventName.toLowerCase()); + let event = isMouseEvent + ? new MouseEvent(eventName, { bubbles, cancelable: true }) + : new Event(eventName, { bubbles, cancelable: true }); // Set the extra properties for the event setEventProp(event, 'namespace', namespaces.join('.')); setEventProp(event, '_args', args || []); @@ -1888,7 +1850,7 @@ var win = { */ height() { var _a; - return ((_a = external('doc').querySelector('html')) === null || _a === void 0 ? void 0 : _a.clientHeight) || 0; + return ((_a = document.querySelector('html')) === null || _a === void 0 ? void 0 : _a.clientHeight) || 0; }, /** * Remove an event handler from the window @@ -1949,21 +1911,17 @@ var win = { */ width() { var _a; - return ((_a = external('doc').querySelector('html')) === null || _a === void 0 ? void 0 : _a.clientWidth) || 0; + return ((_a = document.querySelector('html')) === null || _a === void 0 ? void 0 : _a.clientWidth) || 0; } }; function create$3(name) { - let el = external('doc').createElement(name); + let el = document.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. @@ -1994,7 +1952,7 @@ class Dom { add(selector, sort = true) { if (selector) { if (typeof selector === 'string') { - let elements = Array.from(external('doc').querySelectorAll(selector)); + let elements = Array.from(document.querySelectorAll(selector)); addArray(this, elements); } else if (selector instanceof Dom) { @@ -2430,7 +2388,7 @@ class Dom { include === 'inner' || include === 'outer') { let el = this[0]; - let computed = external('win').getComputedStyle(this[0]); + let computed = window.getComputedStyle(this[0]); let rectHeight = el.getBoundingClientRect().height; if (!include || include === 'content') { // Content. Minus scrollbar if there is one. This is basically @@ -2508,7 +2466,7 @@ class Dom { if (this.count() === 0) { return false; } - return external('doc').body.contains(this[0]); + return document.body.contains(this[0]); } /** * Determine if the first element in the result set is visible or not. @@ -2618,10 +2576,10 @@ class Dom { }; } let box = this[0].getBoundingClientRect(); - let docElem = external('doc').documentElement; + let docElem = document.documentElement; return { - top: box.top + external('win').pageYOffset - docElem.clientTop, - left: box.left + external('win').pageXOffset - docElem.clientLeft + top: box.top + window.pageYOffset - docElem.clientTop, + left: box.left + window.pageXOffset - docElem.clientLeft }; } /** @@ -2632,7 +2590,7 @@ class Dom { * @returns Instance with the result set as the offset parents */ offsetParent() { - return this.map(el => el.offsetParent || external('doc').body); + return this.map(el => el.offsetParent || document.body); } on(arg1, arg2, arg3) { let { handler, names, selector } = normaliseEventParams(arg1, arg2, arg3); @@ -2700,7 +2658,7 @@ class Dom { */ prepend(content) { return this.each(el => { - if (arrayLike(content) || dom(content)) { + if (content instanceof Dom) { // Reverse the array, so if there are multiple elements, they // end up being added sequentially, just like jQuery let itemsReversed = Array.from(content).reverse(); @@ -2928,7 +2886,7 @@ class Dom { include === 'inner' || include === 'outer') { let el = this[0]; - let computed = external('win').getComputedStyle(el); + let computed = window.getComputedStyle(el); let rectWidth = el.getBoundingClientRect().width; if (!include || include === 'content') { // Content. Minus scrollbar if there is one. This is basically @@ -2981,15 +2939,6 @@ 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`) @@ -3011,20 +2960,6 @@ 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 */ @@ -3076,10 +3011,10 @@ function documentOrder(a, b) { let position = a.compareDocumentPosition(b); if (position & Node.DOCUMENT_POSITION_DISCONNECTED) { // One is disconnected - find which - if (external('doc').body.contains(a)) { + if (document.body.contains(a)) { return -1; } - else if (external('doc').body.contains(b)) { + else if (document.body.contains(b)) { return 1; } return 0; @@ -3306,3692 +3241,194 @@ var pager = { numbers_length: 7 }; -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 footer = (settings, cell, classes) => { + cell.classAdd(classes.tfoot.cell); }; -/** - * 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 - }); - 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). - * - * @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'; - } - else if (type === 'order') { - type = 'sort'; - } - var row = settings.data[rowIdx]; - if (!row) { - return undefined; - } - 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 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; - } - 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 (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 cellData; -} -/** - * 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 - }); - } -} -/** - * 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 { - cell.html(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; - } - // 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 { - // Reading from data object, update the DOM - var cells = row.cells; - var display = getDisplay(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); -} -/** - * 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; - } - } - 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); - } - } - // 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 - }; -} -/** - * Read data from a cell into the data source object - * - * @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 - */ -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); - } - column.setter(data, contents); - } -} - -/** - * 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 show Show the processing indicator (true) or not (false) - */ -function processingDisplay(ctx, show) { - // Ignore cases when we are still redrawing - if (ctx.doingDraw && show === 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); - } -} - -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); - } -} -/** - * Calculate the width of columns for the table - * - * @param settings DataTables settings object - */ -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.children('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)); - } - 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; - } -} -/** - * Get the width of the DataTables wrapper element - * - * @param settings DataTables settings object - * @returns Width - */ -function wrapperWidth(settings) { - let wrapper = Dom.s(settings.tableWrapper); - return wrapper.isVisible() ? wrapper.width() : 0; -} -/** - * 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) - * - * @param settings DataTables settings object - * @param colIdx column of interest - * @returns Array of the longest strings - */ -function getWideStrings(settings, colIdx) { - var column = settings.columns[colIdx]; - // 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, 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 + ''; - // 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; -} -/** - * 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'; - } - if (typeof s == 'number') { - return s < 0 ? '0px' : s + 'px'; - } - // 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); - } - } -} - -/** - * Scrolling setup - * - * @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'))))); - } - 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. 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('focusin.DT', () => { - let scrollLeft = scrollHead.scrollLeft(); - scrollBody.scrollLeft(scrollLeft); - scrollFoot.scrollLeft(scrollLeft); - }); - scrollFoot.on('focusin.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); -} -/** - * 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 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; - } - 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 (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'); - } - } - } - } - // 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'); - } - // 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); - } -} -/** - * 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'); - } -} - -/** - * 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); - } - 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); -} -/** - * Expand the layout items into an object for the rendering function - */ -function layoutItems(row, align, items) { - if (Array.isArray(items)) { - for (var i = 0; i < items.length; i++) { - layoutItems(row, align, items[i]); - } - 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); - } - 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 - }); - }); - } - } - else { - // Otherwise, it is a function, node or Dom / jQuery instance and can just get added - rowCell.items.push(items); - } -} -/** - * Find, or create a layout row and setup a target cell 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 - */ -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; -} -/** - * 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 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]); - } - 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'); -} -/** - * Draw the table with the legacy DOM property - * - * @param settings DT settings instance - * @param layout DOM string - * @param insert Insert point - */ -function legacyDom(settings, layout, insert) { - let parts = layout.match(/(".*?")|('.*?')|./g); - let featureNode, option, newNode, next, attr; - if (!parts) { - return; - } - 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); - } - } -} - -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; -} -/** - * Attach event listeners to a node that will trigger ordering on a column - * - * @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 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(); - } - }); - } - } - }); -} -/** - * Sort the display array to match the master's order - * - * @param settings DataTables context - * @param display The display array - */ -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]; - }); -} -/** - * Convert the API variants that can be used for defining the order into our - * internal OrderColumn array. - * - * @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 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); - } - else if (Array.isArray(sortItem)) { - // 2D array - for (var z = 0; z < sortItem.length; z++) { - push(sortItem[z]); // Object or array - } - } -} -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 = []; +const header = (settings, cell, classes) => { + cell.classAdd(classes.thead.cell); if (!settings.features.ordering) { - return aSort; + cell.classAdd(classes.order.none); } - // Build the sort array, with pre-fix and post-fix options if they have been - // specified - if (Array.isArray(fixed)) { - sortResolve(settings, nestedSort, fixed); + 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; } - if (fixedObj && fixed.pre) { - sortResolve(settings, nestedSort, fixed.pre); - } - 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]] - }); - } + // 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; } } - } - return aSort; -} -/** - * Change the order of the table - * - * @param ctx DataTables settings object - * @param col Column to perform sort on - * @param dir Direction to sort on - */ -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 (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 : '')); } - // 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; - }); - } - else if (aSort.length === 0) { - // Apply index order - displayMaster.sort(function (x, y) { - return x < y ? -1 : x > y ? 1 : 0; - }); - } - 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; -} -/** - * 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]); - } - return idx + 1 < asSorting.length ? idx + 1 : overflow ? null : 0; - }; - if (!col.orderable) { - return false; - } - // Convert to 2D array if needed - if (typeof sorting[0] === 'number') { - sorting = settings.order = [sorting]; - } - // 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; + // 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; } } - 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 (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 { - // 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; + cell.attrRemove('aria-sort'); } - } - 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; + // 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); + } } - else { - sorting.length = 1; - sorting[0][1] = asSorting[0]; - sorting[0]._idx = 0; - } - } - 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)); - } - // 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)); - } - } - 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; - } - 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; - } - } -} - -const defaults$3 = { - boundary: false, - caseInsensitive: true, - columns: null, - exact: false, - regex: false, - return: false, - search: '', - smart: true + }); }; -/** - * Create a new search options object - * - * @param parts Values to assign, otherwise the defaults will be used - * @returns New object - */ -function create$1(parts = {}) { - return util.object.assignDeep({}, defaults$3, parts); -} - -/** - * 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; +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 + ' '; } - } - else if (action == 'first') { - start = 0; - } - else if (action == 'previous') { - start = len >= 0 ? start - len : 0; - if (start < 0) { - start = 0; + if (key === 'start') { + klass += classes.start; } - } - 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; -} - -/** - * State information for a table - * - * @param settings DataTables settings object - */ -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); - } -} -/** - * 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 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; - } - // Reject old data - var duration = settings.stateDuration; - if (duration > 0 && s.time < +new Date() - duration * 1000) { - settings.loadingState = false; - callback(); - return; - } - } - // 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); + else if (key === 'end') { + klass += classes.end; } else { - settings.pageLength = s.length; - } - } - // 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); - } - } - // Search - if (s.search !== undefined) { - Object.assign(settings.searches['*'], s.search); - } - 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(); -} - -/** - * Draw the table for the first time, adding all required features - * - * @param settings DataTables settings object - */ -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]); - } - } - 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); - } + klass += classes.full; } + Dom.c('div') + .attr({ + id: val.id || null, + class: val.className + ? val.className + : classes.cell + ' ' + klass + }) + .append(val.contents) + .appendTo(row); }); -} -/** - * Draw the table for the first time, adding all required features - * - * @param settings DataTables settings object - */ -function initComplete(settings) { - if (settings.initDone) { - return; +}; +const pagingButton = (settings, buttonType, content, active, disabled) => { + var classes = settings.classes.paging; + var btnClasses = [classes.button]; + var btn; + if (active) { + btnClasses.push(classes.active); } - 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(); + if (disabled) { + btnClasses.push(classes.disabled); } - // 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 - } - } - 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); - } - // 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); + if (buttonType === 'ellipsis') { + btn = Dom.c('span').classAdd('ellipsis').html(content).get(0); } else { - // Object to extend the base settings - settings.jqXHR = util.ajax(baseAjax); + btn = Dom + .c('button') + .classAdd(btnClasses.join(' ')) + .attr('role', 'link') + .attr('type', 'button') + .html(content) + .get(0); } - // Restore for next time around - if (ajaxData) { - ajaxConfig.data = ajaxData; - } -} -/** - * Update the table using an Ajax call - * - * @param settings DataTables settings object - * @returns Block the table drawing or not - */ -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(); -} -/** - * Build up the parameters in an object needed for a server-side processing - * request. - * - * @param settings DataTables settings object - * @returns Block the table drawing 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() - } + display: btn, + clicker: btn }; -} -/** - * 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; +}; +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); } - // No data in returned object, so rather than an array, we show an empty - // table - if (!data) { - data = []; + if (items.end) { + fn('end', items.end); } - 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]); + if (items.full) { + fn('full', items.full); } - 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; - } - } - 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); -} -/** - * 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 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); - } - // 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]; -} - -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 - * - * @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); - } - // Tell the draw function we have been filtering - settings.wasFiltered = true; - callbackFire(settings, null, 'search', [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. - * - * @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); - } - } - // 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 - * - * @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; - if (!__filter_div) { - createFilterDiv(); - } - 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 wasInvalidated; -} - -function getDisplay(settings, rowIdx, colIdx = null) { - var rowModal = settings.data[rowIdx]; - var columns = settings.columns; - if (!rowModal) { - return []; - } - if (!rowModal.displayData) { - // Need to render and cache - rowModal.displayData = []; - } - 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; - } - } - } - } - // 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 - * - * @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, doc = external('doc'); - if (row && row.tr === null) { - let rowData = row.data; - tr = trIn || doc.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 - ? doc.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 = getDisplay(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); - } - 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); - } - } - callbackFire(settings, 'rowCreated', 'row-created', [ - tr, - rowData, - rowIdx, - cells - ]); - } - else if (row) { - Dom.s(row.tr).classAdd(trClass); - } -} -/** - * Add attributes to a row based on the special `DT_*` parameters in a data - * source object. - * - * @param settings DataTables settings object - * @param row Row object for the row to be modified - */ -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); - } - } -} -/** - * Create the HTML header for the table - * - * @param settings DataTable instance - * @param side If the header or footer should be used - * @returns - */ -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); - } - } - } - 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); - }); -} -/** - * Build a layout structure for a header or footer - * - * @param settings DataTables settings - * @param source Source layout array - * @param incColumns What columns should be included - * @returns Layout array in column index order - */ -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([]); - } - 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 structure; -} -/** - * Draw the header (or footer) element based on the column visibility states. - * - * @param settings DataTables settings object - * @param source Layout array from detectHeader - */ -function drawHead(settings, source) { - let layout = headerLayout(settings, source); - let tr; - if (!layout) { - 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); - } - } - } -} -/** - * Insert the required TR nodes into the table for display - * - * @param settings DataTables settings object - * @param ajaxComplete true after ajax call to complete rendering - */ -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; - } - 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 (!isServerSide) { - settings.drawCount++; - } - else if (!settings.destroying && !ajaxComplete) { - // Show loading message for server-side processing - if (settings.drawCount === 0) { - body.empty().append(_emptyRow(settings)); - } - ajaxUpdate(settings); - 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); - } - 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 { - 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 (doFilter) { - filterComplete(settings); - } - else { - // No filtering, so we want to just use the display master - settings.display = settings.displayMaster.slice(); - } - } - 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; - }); -} -/** - * 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; - } - 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); - } - // 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; - } - } - 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; - } -} -/** - * 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 - * - * @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 - * - * @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); - } - 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); - } - if (type == 'alert') { - alert(msg); - } - else if (type == 'throw') { - throw new Error(msg); - } - else if (typeof type == 'function') { - type(ctx, tn, msg); - } - } -} -/** - * 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; - } - if (mappedName === undefined) { - mappedName = name; - } - if (src[name] !== undefined) { - ret[mappedName] = src[name]; - } -} -/** - * 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); - } -} -/** - * 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); - }); - } - 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 - }); - // 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 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; - } - 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'; - } - else if (ctx.ajax) { - return 'ajax'; - } - return 'dom'; -} -/** - * Common replacement for language strings - * - * @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. - * - * @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 { - for (var i = 0; i < data.length; i++) { - arr.push(data[i]); - } - } -} -/** - * 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]); - } -} -/** - * 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 = { @@ -7342,6 +3779,426 @@ register$1('num', { } }); +/** + * 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). + * + * Note that this namespace is aliased to `jQuery.fn.dataTableExt` for legacy + * reasons + */ +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 + } + }, + /** + * 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 +}); + +/** + * 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); + } + if (type == 'alert') { + alert(msg); + } + else if (type == 'throw') { + throw new Error(msg); + } + else if (typeof type == 'function') { + type(ctx, tn, msg); + } + } +} +/** + * 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; + } + if (mappedName === undefined) { + mappedName = name; + } + if (src[name] !== undefined) { + ret[mappedName] = src[name]; + } +} +/** + * 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); + } +} +/** + * 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); + }); + } + 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 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; + } + 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'; + } + else if (ctx.ajax) { + return 'ajax'; + } + return 'dom'; +} +/** + * Common replacement for language strings + * + * @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. + * + * @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 { + for (var i = 0; i < data.length; i++) { + arr.push(data[i]); + } + } +} +/** + * 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]); + } +} +/** + * 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; +} + /* * 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 @@ -7505,29 +4362,24 @@ function __mlHelper(localeString) { }; }; } -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; - } +// 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 - } + } + catch (e) { + // noop } } /** @@ -7568,9 +4420,6 @@ 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; @@ -7632,7 +4481,7 @@ var helpers = { /** * Column options that can be given to DataTables at initialisation time. */ -const defaults$2 = { +const defaults$4 = { ariaTitle: '', cellType: 'td', className: '', @@ -7652,8 +4501,7 @@ const defaults$2 = { title: null, type: null, visible: true, - width: null, - widthCalc: 'all' + width: null }; /** @@ -7754,10 +4602,6 @@ 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" */ @@ -7765,6 +4609,26 @@ class Settings { } } +const defaults$3 = { + boundary: false, + caseInsensitive: true, + columns: null, + exact: false, + regex: false, + return: false, + search: '', + smart: true +}; +/** + * Create a new search options object + * + * @param parts Values to assign, otherwise the defaults will be used + * @returns New object + */ +function create$2(parts = {}) { + return util.object.assignDeep({}, defaults$3, parts); +} + const browser = { barWidth: -1, scrollbarLeft: false @@ -7815,9 +4679,8 @@ function compatMap(o, newKey, oldKey) { * change only. * * @param init Object to map - * @param defaults Indicate if the object is the defaults object or not */ -function compatOpts(init, defaults = false) { +function compatOpts(init) { // Convert any old style parameters to camelCase hungarianToCamel(init); // Map old parameter names to new @@ -7844,9 +4707,7 @@ function compatOpts(init, defaults = false) { : true; init.orderHandler = init.ordering.handler !== undefined ? init.ordering.handler : true; - if (!defaults) { - init.ordering = true; - } + init.ordering = true; } else if (init.ordering === false) { init.orderIndicators = false; @@ -7925,7 +4786,7 @@ function browserDetect(ctx) { .css({ position: 'fixed', top: '0', - left: -1 * external('win').pageXOffset + 'px', // allow for scrolling + left: -1 * window.pageXOffset + 'px', // allow for scrolling height: '1px', width: '1px', overflow: 'hidden' @@ -7954,6 +4815,1015 @@ function browserDetect(ctx) { 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); +} + +/** + * 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$1({ + src: tr ? 'dom' : 'data', + idx: rowIdx + }); + 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). + * + * @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'; + } + else if (type === 'order') { + type = 'sort'; + } + var row = settings.data[rowIdx]; + if (!row) { + return undefined; + } + 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 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; + } + 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 (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 cellData; +} +/** + * 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 + }); + } +} +/** + * 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 { + cell.html(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; + } + // 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 { + // 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); +} +/** + * 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; + } + } + 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); + } + } + // 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 + }; +} +/** + * Read data from a cell into the data source object + * + * @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 + */ +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); + } + column.setter(data, contents); + } +} + +/** + * 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); + } +} +/** + * Calculate the width of columns for the table + * + * @param settings DataTables settings object + */ +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)); + } + 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; + } +} +/** + * Get the width of the DataTables wrapper element + * + * @param settings DataTables settings object + * @returns Width + */ +function wrapperWidth(settings) { + let wrapper = Dom.s(settings.tableWrapper); + return wrapper.isVisible() ? wrapper.width() : 0; +} +/** + * 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) + * + * @param settings DataTables settings object + * @param colIdx column of interest + * @returns Array of the longest strings + */ +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; +} +/** + * 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'; + } + if (typeof s == 'number') { + return s < 0 ? '0px' : s + 'px'; + } + // 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); + } + } +} + +/** + * Scrolling setup + * + * @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'))))); + } + 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); +} +/** + * 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 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; + } + 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 (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'); + } + } + } + } + // 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'); + } + // 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); + } +} +/** + * 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'); + } +} + /** * Add a column to the list used for the table with default values * @@ -7962,14 +5832,16 @@ function browserDetect(ctx) { 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 + let column = util.object.assign({}, new Settings(), defaults$4, { + orderData: defaults$4.orderData + ? defaults$4.orderData : [columnIdx], - data: defaults$2.data ? defaults$2.data : columnIdx, + data: defaults$4.data ? defaults$4.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 @@ -7977,7 +5849,9 @@ 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$2(searchCols[columnIdx] + ? hungarianToCamel(searchCols[columnIdx]) + : {}); settings.searches[columnIdx].columns = [columnIdx]; } /** @@ -8457,432 +6331,2421 @@ function columnsFromHeader(cell) { return parseInt(val); }); } + /** - * Get cells from the header or footer, including a specific row and / or cell + * Generate the node required for the processing node * - * @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 + * @param ctx DataTables settings object */ -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) && - !included.includes(cell)) { - included.push(cell); - out.push({ - cell, - row: header[i].row +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 show Show the processing indicator (true) or not (false) + */ +function processingDisplay(ctx, show) { + // Ignore cases when we are still redrawing + if (ctx.doingDraw && show === 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); + } +} + +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._; +} + +/** + * 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); + } + 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); +} +/** + * Expand the layout items into an object for the rendering function + */ +function layoutItems(row, align, items) { + if (Array.isArray(items)) { + for (var i = 0; i < items.length; i++) { + layoutItems(row, align, items[i]); + } + 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); + } + 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 + }); + }); + } + } + else { + // Otherwise, it is a function, node or Dom / jQuery instance and can just get added + rowCell.items.push(items); + } +} +/** + * Find, or create a layout row and setup a target cell 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 + */ +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; +} +/** + * 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 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]); + } + 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'); +} +/** + * Draw the table with the legacy DOM property + * + * @param settings DT settings instance + * @param layout DOM string + * @param insert Insert point + */ +function legacyDom(settings, layout, insert) { + let parts = layout.match(/(".*?")|('.*?')|./g); + let featureNode, option, newNode, next, attr; + if (!parts) { + return; + } + 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); + } + } +} + +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]; + } + // else - all rows + if (settings.orderHandler) { + sortAttachListener(settings, target, target === settings.thead + ? 'tr' + + notSelector + + ' th' + + notSelector + + ', tr' + + notSelector + + ' td' + + notSelector + : 'th' + notSelector + ', td' + notSelector); + } + // 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 + * + * @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 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(); + } + }); + } + } + }); +} +/** + * Sort the display array to match the master's order + * + * @param settings DataTables context + * @param display The display array + */ +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]; + }); +} +/** + * Convert the API variants that can be used for defining the order into our + * internal OrderColumn array. + * + * @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 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); + } + else if (Array.isArray(sortItem)) { + // 2D array + for (var z = 0; z < sortItem.length; z++) { + push(sortItem[z]); // Object or array + } + } +} +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); + } + 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 out; + return aSort; } /** - * Get cells that apply for ordering handler or icons + * Change the order of the table * - * @param settings Context - * @param notSelector DOM selector to exclude elements - * @returns Array of selected elements + * @param ctx DataTables settings object + * @param col Column to perform sort on + * @param dir Direction to sort on */ -function columnOrderingCells(settings, notSelector) { - let combined = []; - let titleRow = settings.titleRow; - if (titleRow === true) { - // Top row (legacy `orderCellsTop`) - combined = columnCells(settings.header, 0); - } - else if (titleRow === false) { - // Bottom row (legacy `orderCellsTop`) - combined = columnCells(settings.header, settings.header.length - 1); - } - else if (titleRow !== null) { - // Specific row - combined = columnCells(settings.header, titleRow); +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 { - // All - combined = columnCells(settings.header); + aSort = sortFlatten(ctx); } - 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 => { - let idx = cells.indexOf(el); - if (idx >= 0) { - return Dom.s(rows[idx]).filter(notSelector).length !== 0; + 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; } - // Shouldn't be able to get here! - return true; - }); + // 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; + }); + } + else if (aSort.length === 0) { + // Apply index order + displayMaster.sort(function (x, y) { + return x < y ? -1 : x > y ? 1 : 0; + }); + } + 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; +} +/** + * 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]); + } + return idx + 1 < asSorting.length ? idx + 1 : overflow ? null : 0; + }; + if (!col.orderable) { + return false; + } + // Convert to 2D array if needed + if (typeof sorting[0] === 'number') { + sorting = settings.order = [sorting]; + } + // 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 (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; + } + } + 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; + } + } + 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)); + } + // 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)); + } + } + 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; + } + 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; + } + } } -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); +/** + * 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; } - // Conditions to not apply the ordering icons - if (!columnOrderingCells(settings, ':not([data-dt-order="disable"])') - .get() - .includes(cell[0])) { + else if (typeof action === 'number') { + start = action * len; + if (start > records) { + start = 0; + } + } + else if (action == 'first') { + start = 0; + } + else if (action == 'previous') { + start = len >= 0 ? start - len : 0; + if (start < 0) { + start = 0; + } + } + 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; } - // 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 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); - }); -}; -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); - } - if (buttonType === 'ellipsis') { - btn = Dom.c('span').classAdd('ellipsis').html(content).get(0); - } else { - btn = Dom.c('button') - .classAdd(btnClasses.join(' ')) - .attr('role', 'link') - .attr('type', 'button') - .html(content) - .get(0); + log(settings, 0, 'Unknown paging action: ' + action, 5); } - 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); + var changed = settings.displayStart !== start; + settings.displayStart = start; + callbackFire(settings, null, changed ? 'page' : 'page-nc', [settings]); + if (changed && redraw) { + draw(settings); } + return changed; } /** - * DataTables extensions + * State information for a table * - * 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). - * - * Note that this namespace is aliased to `jQuery.fn.dataTableExt` for legacy - * reasons + * @param settings DataTables settings object */ -const ext = { - /** - * DataTables build type (expanded by the download builder) - */ - builder: 'bs5/dt-3.1.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 +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); + } +} +/** + * 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 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; } - }, - /** - * 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.1.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 -}); + // Reject old data + var duration = settings.stateDuration; + if (duration > 0 && s.time < +new Date() - duration * 1000) { + settings.loadingState = false; + callback(); + return; + } + } + // 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); + } + else { + settings.pageLength = s.length; + } + } + // 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); + } + } + // Search + if (s.search !== undefined) { + Object.assign(settings.searches['*'], s.search); + } + if (s.searchGroups) { + s.searchGroups.forEach(group => { + if (group.columns) { + let index = group.columns.join(','); + settings.searches[index] = create$2(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(); +} + +/** + * Draw the table for the first time, adding all required features + * + * @param settings DataTables settings object + */ +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]); + } + } + 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); + } + } + }); +} +/** + * Draw the table for the first time, adding all required features + * + * @param settings DataTables settings object + */ +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(); + } + // 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 + } + } + 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; + } + 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); + } + // 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; + } +} +/** + * Update the table using an Ajax call + * + * @param settings DataTables settings object + * @returns Block the table drawing or not + */ +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(); +} +/** + * Build up the parameters in an object needed for a server-side processing + * request. + * + * @param settings DataTables settings object + * @returns Block the table drawing 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() + } + }; +} +/** + * 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; + } + // No data in returned object, so rather than an array, we show an empty + // table + if (!data) { + data = []; + } + 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; + } + } + 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); +} +/** + * 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 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); + } + // 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; +/** + * Filter the table using both the global filter and column based filtering + * + * @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); + } + // Tell the draw function we have been filtering + settings.wasFiltered = true; + callbackFire(settings, null, 'search', [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. + * + * @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); + } + } + // 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 + * + * @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); + } + row.searchCellCache = rowFilterData; + row.searchRowCache = rowFilterData.join(' '); + wasInvalidated = true; + } + } + 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) { + 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 rowModal.displayData; +} +/** + * Create a new TR element (and it's TD children) for a row + * + * @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); + } + 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); + } + } + callbackFire(settings, 'rowCreated', 'row-created', [ + tr, + rowData, + rowIdx, + cells + ]); + } + else if (row) { + Dom.s(row.tr).classAdd(trClass); + } +} +/** + * Add attributes to a row based on the special `DT_*` parameters in a data + * source object. + * + * @param settings DataTables settings object + * @param row Row object for the row to be modified + */ +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); + } + } +} +/** + * Create the HTML header for the table + * + * @param settings DataTable instance + * @param side If the header or footer should be used + * @returns + */ +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); + } + } + } + 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); + }); +} +/** + * Build a layout structure for a header or footer + * + * @param settings DataTables settings + * @param source Source layout array + * @param incColumns What columns should be included + * @returns Layout array in column index order + */ +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([]); + } + 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 structure; +} +/** + * Draw the header (or footer) element based on the column visibility states. + * + * @param settings DataTables settings object + * @param source Layout array from detectHeader + */ +function drawHead(settings, source) { + let layout = headerLayout(settings, source); + let tr; + if (!layout) { + 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); + } + } + } +} +/** + * Insert the required TR nodes into the table for display + * + * @param settings DataTables settings object + * @param ajaxComplete true after ajax call to complete rendering + */ +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; + } + 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 (!isServerSide) { + settings.drawCount++; + } + else if (!settings.destroying && !ajaxComplete) { + // Show loading message for server-side processing + if (settings.drawCount === 0) { + body.empty().append(_emptyRow(settings)); + } + ajaxUpdate(settings); + 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); + } + 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 { + 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 (doFilter) { + filterComplete(settings); + } + else { + // No filtering, so we want to just use the display master + settings.display = settings.displayMaster.slice(); + } + } + 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; + }); +} +/** + * 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; + } + 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); + } + // 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; + } + } + 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; + } +} +/** + * 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 + * + * @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 + * + * @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); + } + else { + return !paginate || calc > records || len === -1 ? records : calc; + } +} /** * Common run function for selector types @@ -9278,6 +9141,9 @@ 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`. @@ -9565,7 +9431,7 @@ register('destroy()', function (remove) { callbackFire(settings, 'destroy', 'destroy', [settings], true); // If not being removed from the document, make all columns visible if (!remove) { - new Api(settings).columns().visible(true); + new Api(settings).columns().visible(); } // Container width change listener if (settings.resizeObserver) { @@ -9997,7 +9863,10 @@ 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; } } @@ -10007,8 +9876,20 @@ 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; + var columns = settings.columns, names, titles, nodes = columnHeaderCells(settings.header); var run = function (s) { var selInt = intVal(s); // Selector - all @@ -10062,8 +9943,8 @@ function selectColumns(settings, selector, opts) { } // Selector if (match && match[1]) { - let columnElements = columnCells(settings.header, null, col.idx).map(c => c.cell); - return Dom.s(columnElements) + return Dom + .s(nodes[mapIdx]) .filter(match[1]) .count() > 0 ? mapIdx @@ -10099,10 +9980,11 @@ 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).map(c => c.cell)) + var result = Dom + .s(nodes) .filter(s) .mapTo(el => { - return columnsFromHeader(el); + return columnsFromHeader(el); // `nodes` is column index complete and in order }) .flat() .sort(function (a, b) { @@ -10239,7 +10121,9 @@ 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; @@ -10309,14 +10193,13 @@ 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 - let columns = this.columns(':visible'); - let row = Dom.c('tr'); - for (let i = 0; i < columns.count(); i++) { - Dom.c('td').appendTo(row); - } + var columns = this.columns(':visible'); + var row = Dom + .c('tr') + .html('' + Array(columns.count()).join('') + ''); Dom.s(this.table().body()).append(row); - let widths = []; - let indexes = columns.indexes(); + var widths = []; + var indexes = columns.indexes(); row.children().each((el, idx) => { widths[indexes[idx]] = Dom.s(el).width('outer'); }); @@ -10496,7 +10379,7 @@ register('processing()', function (show) { }); // Add the state event handler in time for the initial draw to save state -Dom.on('preInit.dt', function (e, context) { +Dom.s(document).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 @@ -10519,7 +10402,7 @@ Dom.on('preInit.dt', function (e, context) { }); }); // But initial details can wait until the end -Dom.on('plugin-init.dt', function (e, context) { +Dom.s(document).on('plugin-init.dt', function (e, context) { var api = context.api; // And the initial load state detailsStateLoad(api, api.state.loaded()); @@ -11024,7 +10907,7 @@ register('search()', function (input, regex, smart, caseInsen) { } let target = ctx.searches['*']; if (!target) { - target = create$1(); + target = create$2(); } if (typeof regex === 'object') { // New style object of options @@ -11059,7 +10942,7 @@ register('search.fixed()', function (name, search, options) { else { let target = fixed[name]; if (!target || !util.is.plainObject(target)) { - target = create$1(); + target = create$2(); } if (options) { assign(target, options); @@ -11083,7 +10966,7 @@ register(['columns().search()', 'column().search()'], function (input, regex, sm let colIdxs = columns.join(','); let target = ctx.searches[colIdxs]; if (!target) { - target = create$1(); + target = create$2(); } // Delete the search for custom grouping types if removing if ((input === '' || input === null) && columns.length > 1) { @@ -11143,7 +11026,7 @@ register(['columns().search.fixed()', 'column().search.fixed()'], function (name else { let target = fixed[name]; if (!target || !util.is.plainObject(target)) { - target = create$1(); + target = create$2(); } if (options) { assign(target, options); @@ -11332,7 +11215,7 @@ const _licenseInfo = { expires: null, valid: null }; -let _wm; +const _wm = Dom.c('div'); const _publicKey = 'BE1A9w9D9U/4s4/TogY+1sW/dLJ8IquzK1PmV70J93ZTIvXMZ0eV2NAb52ntpgwVFySSB2fOI7geLNO737rQAyo='; /** * Convert a base64 string to a binary array @@ -11361,7 +11244,7 @@ function check(releaseDate, software) { noticeDisplay(); } else if (_licenseInfo.valid === false) { - noticePrep('Invalid license key'); + noticePrep('License key invalid'); noticeDisplay(); } else if (_licenseInfo.type === 'trial') { @@ -11371,7 +11254,7 @@ function check(releaseDate, software) { : -1; if (remaining < 0) { // Trial expires - consoleMsg('Your trial has now expired. Please visit https://datatables.net/plus to purchase a license', 'warn'); + consoleMsg('Your trial has now expired - https://datatables.net/plus', 'warn'); noticePrep('Trial expired'); noticeDisplay(); return false; @@ -11446,15 +11329,7 @@ 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', @@ -11466,59 +11341,37 @@ 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': '1px 3px 5px rgba(0, 0, 0, 0.333)' + 'box-shadow': '0 2px 5px rgba(0,0,0,0.2)' }); + 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 - .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')); + .find('a') + .html('DataTables Plus: ' + text + ' - learn more »'); } } /** * Display the license notice */ function noticeDisplay() { - let doc = external('doc'); - if (!_processingKey && doc.body && !doc.body.contains(_wm[0])) { - doc.body.appendChild(_wm[0]); + if (!_processingKey && document.body && !document.body.contains(_wm[0])) { + document.body.appendChild(_wm[0]); } } /** @@ -11592,7 +11445,7 @@ function plus (DataTable) { value: function (releaseDate, software = '') { // Unsecure sites are only useful for development, so allow there // and on the site. - let host = external('win').location.hostname; + let host = window.location.hostname; let isDev = host === '192.168.234.234' || host.endsWith('.datatables.net') || host === 'datatables.net'; @@ -11616,8 +11469,7 @@ function plus (DataTable) { } function getSubtle() { // Backwards compat for old browsers - let win = external('win'); - let cryptoObj = win.crypto || win.msCrypto; + let cryptoObj = window.crypto || window.msCrypto; let subtle = cryptoObj.subtle || cryptoObj.webkitSubtle; return subtle; } @@ -11703,7 +11555,7 @@ function _divProp(el, prop, val) { } } register$2('div', function (settings, opts) { - var n = Dom.c('div').get(0); + var n = document.createElement('div'); if (opts) { _divProp(n, 'className', opts.className); _divProp(n, 'id', opts.id); @@ -11861,7 +11713,7 @@ function _pagingDraw(settings, host, opts) { buttonEls.push(btn.display); } let wrapped = renderer(settings, 'pagingContainer')(settings, buttonEls); - let activeEl = host.find(external('doc').activeElement).attr('data-dt-idx'); + let activeEl = host.find(document.activeElement).attr('data-dt-idx'); host.empty().append(wrapped); if (activeEl) { host.find('[data-dt-idx="' + activeEl + '"]').trigger('focus'); @@ -12106,7 +11958,7 @@ register$2('search', function (settings, optsIn) { let searchName = opts.columns === '*' ? '*' : indexes.join(','); let appliedSearch = settings.searches[searchName]; if (!appliedSearch) { - appliedSearch = create$1(); + appliedSearch = create$2(); settings.searches[searchName] = appliedSearch; } appliedSearch.columns = indexes; @@ -12354,8 +12206,8 @@ function create(parts = {}) { var models = { Column: Settings, - Row: create$2, - Search: create$1, + Row: create$1, + Search: create$2, Settings: create }; @@ -12368,7 +12220,7 @@ const defaults = { autoWidth: true, caption: '', classes: {}, - column: defaults$2, + column: defaults$4, columnDefs: null, columns: null, createdRow: null, @@ -12468,10 +12320,7 @@ const defaults = { stateDuration: 7200, stateLoadCallback: function (settings) { try { - const state = (settings.stateDuration === -1 ? sessionStorage : localStorage).getItem('DataTables_' + - settings.unique + - '_' + - external('win').location.pathname); + const state = (settings.stateDuration === -1 ? sessionStorage : localStorage).getItem('DataTables_' + settings.unique + '_' + location.pathname); return state ? JSON.parse(state) : {}; } catch (e) { @@ -12485,10 +12334,7 @@ const defaults = { try { (settings.stateDuration === -1 ? sessionStorage - : localStorage).setItem('DataTables_' + - settings.unique + - '_' + - external('win').location.pathname, JSON.stringify(data)); + : localStorage).setItem('DataTables_' + settings.unique + '_' + location.pathname, JSON.stringify(data)); } catch (e) { // noop @@ -12544,12 +12390,10 @@ 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); - compatCols(defaults$2); + compatOpts(defaults); + compatCols(defaults$4); // Allow data properties on the table element to be used as // initialisation options util.object.assign(init, escapeObject(table.data())); @@ -12678,7 +12522,7 @@ const DataTable = function (selector, options) { ]); map(settings.language, config, 'infoCallback'); // Setup global search - settings.searches['*'] = create$1(config.search); + settings.searches['*'] = create$2(config.search); /* Callback functions which are array driven */ callbackReg(settings, 'draw', config.drawCallback); callbackReg(settings, 'stateSaveParams', config.stateSaveParams); @@ -12940,9 +12784,7 @@ DataTable.feature = { }; // Register the libraries util.external(DataTable); -// 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) { +if (window.jQuery) { util.external(window.jQuery); } diff --git a/src/static/templates/admin/diagnostics.hbs b/src/static/templates/admin/diagnostics.hbs index 5991bc69..0c889353 100644 --- a/src/static/templates/admin/diagnostics.hbs +++ b/src/static/templates/admin/diagnostics.hbs @@ -69,26 +69,12 @@
Uses config.json
- {{#if (eq page_data.config_file_status "Loaded")}} + {{#if page_data.overrides}} Yes - {{#if page_data.overrides}} Details - {{/if}} - {{~else if (eq page_data.config_file_status "Error")}} - Errors - Details - {{~else}} - No {{/if}} -
-
Environment variables are valid
-
- {{#if page_data.invalid_env_vars}} - No - Details - {{/if}} - {{#unless page_data.invalid_env_vars}} - Yes + {{#unless page_data.overrides}} + No {{/unless}}
Uses custom templates
diff --git a/src/static/templates/admin/users.hbs b/src/static/templates/admin/users.hbs index 22d8205c..b1dfb17d 100644 --- a/src/static/templates/admin/users.hbs +++ b/src/static/templates/admin/users.hbs @@ -135,11 +135,6 @@
- {{#if custom_roles_enabled}} -
- -
- {{/if}}
diff --git a/src/static/templates/email/recover_twofactor.hbs b/src/static/templates/email/recover_twofactor.hbs deleted file mode 100644 index 92e4330a..00000000 --- a/src/static/templates/email/recover_twofactor.hbs +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 847cbae0..00000000 --- a/src/static/templates/email/recover_twofactor.html.hbs +++ /dev/null @@ -1,26 +0,0 @@ -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/static/templates/email/send_emergency_access_invite.html.hbs b/src/static/templates/email/send_emergency_access_invite.html.hbs index b5ab3a07..5318378c 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 diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index fc6d22c6..5bbe5db2 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -42,27 +42,45 @@ 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)}} @@ -98,17 +116,10 @@ app-security > app-two-factor-setup > form { } /* Hide unsupported Custom Role options */ -{{#if custom_roles_enabled}} :is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input), :is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { @extend %vw-hide; } -{{else}} -:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input), -:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { - @extend %vw-hide; -} -{{/if}} /* Change collapsed menu icon to Vaultwarden */ bit-nav-logo bit-nav-item a:before { @@ -143,10 +154,17 @@ 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 */ @@ -202,13 +220,6 @@ 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}} diff --git a/src/util.rs b/src/util.rs index 696c3360..6de2d803 100644 --- a/src/util.rs +++ b/src/util.rs @@ -210,7 +210,7 @@ impl Fairing for Cors { pub struct Cached { response: R, is_immutable: bool, - ttl: u32, + ttl: u64, } impl Cached { @@ -230,7 +230,7 @@ impl Cached { } } - pub fn ttl(response: R, ttl: u32, is_immutable: bool) -> Cached { + pub fn ttl(response: R, ttl: u64, is_immutable: bool) -> Cached { Self { response, is_immutable, @@ -251,7 +251,7 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache res.set_raw_header("Cache-Control", cache_control_header); let time_now = Local::now(); - let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.into()).unwrap(); + let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.try_into().unwrap()).unwrap(); res.set_raw_header("Expires", format_datetime_http(&expiry_time)); Ok(res) } @@ -259,16 +259,14 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache pub struct EtagCached { response: R, - content_type: ContentType, etag: String, } impl EtagCached { /// An `etag` response should always be quoted - pub fn new(response: R, content_type: ContentType, etag: &str) -> Self { + pub fn new(response: R, etag: &str) -> Self { Self { response, - content_type, etag: format!("\"{etag}\""), } } @@ -290,8 +288,7 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC self.response.respond_to(request)? }; - // Both 200 (OK) and 304 (Not Modified) need to return the Content-Type Etag and Cache-Control - res.set_header(self.content_type); + // Both 200 (OK) and 304 (Not Modified) need to return the etag and cache-control res.set_raw_header("Etag", self.etag); res.set_raw_header("Cache-Control", "public, no-cache"); Ok(res) @@ -302,69 +299,19 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC // Effectively ignores, any static file route, and the alive endpoint const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"]; -const MAX_LOGGED_QUERY_BYTES: usize = 100; - -// Query values can hold secrets, like the access code of a login request, so we don't log them -fn redact_query_values(query: &str) -> String { - query - .split('&') - .map(|param| match param.split_once('=') { - Some((name, _)) => format!("{name}=…"), - None => param.to_owned(), - }) - .collect::>() - .join("&") -} - -// When extra is true, we ignore the whitelist above and also print the mounts -pub struct BetterLogging { - extra: bool, - log_requests: bool, - log_response: bool, - domain_path: String, -} - -impl BetterLogging { - pub fn new(extra: bool, domain_path: &str) -> Self { - Self { - extra, - log_requests: log_enabled!(target: "request", log::Level::Info), - log_response: log_enabled!(target: "response", log::Level::Info), - domain_path: rocket::http::RawStr::new(&domain_path).percent_decode_lossy().into_owned(), - } - } - - fn log_path<'p>(&self, method: Method, path: &'p rocket::http::RawStr) -> Option> { - if !self.extra && method == Method::Options { - return None; - } - let path = path.percent_decode_lossy(); - if self.extra { - return Some(path); - } - let subpath = path.strip_prefix(&self.domain_path).unwrap_or(&path); - LOGGED_ROUTES.iter().any(|r| subpath.starts_with(r)).then_some(path) - } -} - +// Boolean is extra debug, when true, we ignore the whitelist above and also print the mounts +pub struct BetterLogging(pub bool); #[rocket::async_trait] impl Fairing for BetterLogging { fn info(&self) -> Info { - let mut kind = Kind::Liftoff; - if self.log_requests { - kind = kind | Kind::Request; - } - if self.log_response { - kind = kind | Kind::Response; - } Info { name: "Better Logging", - kind, + kind: Kind::Liftoff | Kind::Request | Kind::Response, } } async fn on_liftoff(&self, rocket: &Rocket) { - if self.extra { + if self.0 { info!(target: "routes", "Routes loaded:"); let mut routes: Vec<_> = rocket.routes().collect(); routes.sort_by_key(|r| r.uri.path()); @@ -388,29 +335,36 @@ impl Fairing for BetterLogging { } async fn on_request(&self, request: &mut Request<'_>, _data: &mut Data<'_>) { - let uri = request.uri(); let method = request.method(); - let Some(path) = self.log_path(method, uri.path().raw()) else { + if !self.0 && method == Method::Options { return; - }; - match uri.query() { - Some(q) => { - let query = redact_query_values(q.as_str()); - let query = &query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)]; - info!(target: "request", "{method} {}?{}", path.escape_debug(), query.escape_debug()); + } + let uri = request.uri(); + let uri_path = uri.path(); + let uri_path_str = uri_path.url_decode_lossy(); + let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str); + if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) { + match uri.query() { + Some(q) => info!(target: "request", "{method} {uri_path_str}?{}", &q[..q.len().min(30)]), + None => info!(target: "request", "{method} {uri_path_str}"), } - None => info!(target: "request", "{method} {}", path.escape_debug()), } } async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) { - if self.log_path(request.method(), request.uri().path().raw()).is_none() { + if !self.0 && request.method() == Method::Options { return; } - let status = response.status(); - match request.route() { - Some(r) => info!(target: "response", "{r} => {status}"), - None => info!(target: "response", "{status}"), + let uri_path = request.uri().path(); + let uri_path_str = uri_path.url_decode_lossy(); + let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str); + if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) { + let status = response.status(); + if let Some(route) = request.route() { + info!(target: "response", "{route} => {status}"); + } else { + info!(target: "response", "{status}"); + } } } } @@ -426,7 +380,7 @@ pub fn get_display_size(size: i64) -> String { let mut unit_counter = 0; loop { - if size > 1024. && unit_counter < UNITS.len() - 1 { + if size > 1024. { size /= 1024.; unit_counter += 1; } else { @@ -493,34 +447,20 @@ pub fn get_env_str_value(key: &str) -> Option { } } -pub static INVALID_ENV_VARS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); - -pub fn get_env(key: &'static str) -> Option +pub fn get_env(key: &str) -> Option where V: FromStr, { - let value = get_env_str_value(key); - let value_is_some = value.is_some(); - let parsed: Option = try_parse_string(value); - if parsed.is_none() && value_is_some { - println!("[ERROR] `{key}` contains an invalid value for its type and is ignored, the default is used instead."); - INVALID_ENV_VARS.lock().unwrap().push(key); - } - parsed + try_parse_string(get_env_str_value(key)) } -pub fn get_env_bool(key: &'static str) -> Option { +pub fn get_env_bool(key: &str) -> Option { const TRUE_VALUES: &[&str] = &["true", "t", "yes", "y", "1"]; const FALSE_VALUES: &[&str] = &["false", "f", "no", "n", "0"]; match get_env_str_value(key) { Some(val) if TRUE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(true), Some(val) if FALSE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(false), - Some(_) => { - println!("[ERROR] `{key}` contains an invalid boolean value and is ignored, the default is used instead."); - INVALID_ENV_VARS.lock().unwrap().push(key); - None - } _ => None, } } @@ -582,8 +522,8 @@ pub fn format_datetime_http(dt: &DateTime) -> String { expiry_time.to_rfc2822().replace("+0000", "GMT") } -pub fn parse_date(date: &str) -> Option { - DateTime::parse_from_rfc3339(date).ok().map(|dt| dt.naive_utc()) +pub fn parse_date(date: &str) -> NaiveDateTime { + DateTime::parse_from_rfc3339(date).unwrap().naive_utc() } /// Returns true or false if an email address is valid or not @@ -899,12 +839,6 @@ pub fn parse_experimental_client_feature_flags( .collect() } -/// Whether the admin enabled this supported client feature flag -pub fn is_client_feature_flag_enabled(flag: &str) -> bool { - parse_experimental_client_feature_flags(&CONFIG.experimental_client_feature_flags(), &FeatureFlagFilter::ValidOnly) - .contains_key(flag) -} - /// TODO: This is extracted from IpAddr::is_global, which is unstable: /// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global /// Remove once https://github.com/rust-lang/rust/issues/27709 is merged @@ -1009,15 +943,15 @@ mod tests { use std::net::IpAddr; #[test] - #[ignore = "exhaustive IPv4 check is too slow for the regular test suite"] + #[ignore] fn test_ipv4_global() { for a in 0..u8::MAX { - println!("Iter: {a}/255"); + println!("Iter: {}/255", a); 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) } } } @@ -1025,15 +959,15 @@ mod tests { } #[test] - #[ignore = "random IPv6 check is too slow for the regular test suite"] + #[ignore] fn test_ipv6_global() { - use rand::RngExt; + use rand::Rng; 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::rng(); + let mut rng = rand::thread_rng(); for i in 0..20 { println!("Thread {t} Iter: {i}/50"); @@ -1048,28 +982,3 @@ mod tests { }); } } - -#[cfg(test)] -mod query_log_tests { - use super::{MAX_LOGGED_QUERY_BYTES, redact_query_values}; - - #[test] - fn test_redact_query_values() { - assert_eq!(redact_query_values("code=SECRET"), "code=…"); - assert_eq!(redact_query_values("code=SECRET&other=SECRET"), "code=…&other=…"); - assert_eq!(redact_query_values("a=1&b=2&flag"), "a=…&b=…&flag"); - assert_eq!(redact_query_values("a=b=SECRET"), "a=…"); - assert_eq!(redact_query_values("a=1&&=SECRET"), "a=…&&=…"); - assert_eq!(redact_query_values("a=x%26b%3DSECRET"), "a=…"); - assert_eq!(redact_query_values("a=1;b=SECRET"), "a=…"); - assert_eq!(redact_query_values("a=1&a=SECRET"), "a=…&a=…"); - assert_eq!(redact_query_values(""), ""); - } - - #[test] - fn test_redacted_query_truncates_on_a_char_boundary() { - let name = "a".repeat(MAX_LOGGED_QUERY_BYTES - 1); - let query = redact_query_values(&format!("{name}=SECRET")); - assert_eq!(&query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)], format!("{name}=")); - } -}