diff --git a/.dockerignore b/.dockerignore index a9a358a3..05c2a8e3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,7 +5,6 @@ !.git !docker/healthcheck.sh !docker/start.sh -!macros !migrations !src diff --git a/.env.template b/.env.template index 03990820..5a8686d5 100644 --- a/.env.template +++ b/.env.template @@ -15,14 +15,6 @@ #################### ## Main data folder -## This can be a path to local folder or a path to an external location -## depending on features enabled at build time. Possible external locations: -## -## - AWS S3 Bucket (via `s3` feature): s3://bucket-name/path/to/folder -## -## When using an external location, make sure to set TMP_FOLDER, -## TEMPLATES_FOLDER, and DATABASE_URL to local paths and/or a remote database -## location. # DATA_FOLDER=data ## Individual folders, these override %DATA_FOLDER% @@ -30,13 +22,10 @@ # ICON_CACHE_FOLDER=data/icon_cache # ATTACHMENTS_FOLDER=data/attachments # SENDS_FOLDER=data/sends - -## Temporary folder used for storing temporary file uploads -## Must be a local path. # TMP_FOLDER=data/tmp -## HTML template overrides data folder -## Must be a local path. +## Templates data folder, by default uses embedded templates +## Check source code to see the format # TEMPLATES_FOLDER=data/templates ## Automatically reload the templates for every request, slow, use only for development # RELOAD_TEMPLATES=false @@ -50,9 +39,7 @@ ######################### ## Database URL -## When using SQLite, this is the path to the DB file, and it defaults to -## %DATA_FOLDER%/db.sqlite3. If DATA_FOLDER is set to an external location, this -## must be set to a local sqlite3 file path. +## When using SQLite, this is the path to the DB file, default to %DATA_FOLDER%/db.sqlite3 # DATABASE_URL=data/db.sqlite3 ## When using MySQL, specify an appropriate connection URI. ## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html @@ -80,16 +67,8 @@ ## Timeout when acquiring database connection # DATABASE_TIMEOUT=30 -## Database idle timeout -## Timeout in seconds before idle connections to the database are closed. -# DATABASE_IDLE_TIMEOUT=600 - -## Database min connections -## Define the minimum size of the connection pool used for connecting to the database. -# DATABASE_MIN_CONNS=2 - ## Database max connections -## Define the maximum size of the connection pool used for connecting to the database. +## Define the size of the connection pool used for connecting to the database. # DATABASE_MAX_CONNS=10 ## Database connection initialization @@ -138,7 +117,7 @@ ## and are always in terms of UTC time (regardless of your local time zone settings). ## ## The schedule format is a bit different from crontab as crontab does not contains seconds. -## You can test the format here: https://crontab.guru, but remove the first digit! +## You can test the the format here: https://crontab.guru, but remove the first digit! ## SEC MIN HOUR DAY OF MONTH MONTH DAY OF WEEK ## "0 30 9,12,15 1,15 May-Aug Mon,Wed,Fri" ## "0 30 * * * * " @@ -182,10 +161,6 @@ ## 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 * * * * *" -# -## Cron schedule of the job that cleans sso auth from incomplete flow -## Defaults to daily (20 minutes after midnight). Set blank to disable this job. -# PURGE_INCOMPLETE_SSO_AUTH="0 20 0 * * *" ######################## ### General settings ### @@ -254,8 +229,7 @@ # SIGNUPS_ALLOWED=true ## Controls if new users need to verify their email address upon registration -## On new client versions, this will require the user to verify their email at signup time. -## On older clients, it will require the user to verify their email before they can log in. +## Note that setting this option to true prevents logins until the email address has been verified! ## The welcome email will include a verification link, and login attempts will periodically ## trigger another verification email to be sent. # SIGNUPS_VERIFY=false @@ -285,7 +259,7 @@ ## A comma-separated list means only those users can create orgs: # ORG_CREATION_USERS=admin1@example.com,admin2@example.com -## Allows org admins to invite users, even when signups are disabled +## Invitations org admins to invite users, even when signups are disabled # INVITATIONS_ALLOWED=true ## Name shown in the invitation emails that don't come from a specific organization # INVITATION_ORG_NAME=Vaultwarden @@ -306,13 +280,12 @@ ## The default for new users. If changed, it will be updated during login for existing users. # PASSWORD_ITERATIONS=600000 -## Controls whether users can set or show password hints. This setting applies globally to all users. +## Controls whether users can set password hints. This setting applies globally to all users. # PASSWORD_HINTS_ALLOWED=true ## Controls whether a password hint should be shown directly in the web page if -## SMTP service is not configured and password hints are allowed. -## Not recommended for publicly-accessible instances because this provides -## unauthenticated access to potentially sensitive data. +## SMTP service is not configured. Not recommended for publicly-accessible instances +## as this provides unauthenticated access to potentially sensitive data. # SHOW_PASSWORD_HINT=false ######################### @@ -348,46 +321,35 @@ ## Default: 2592000 (30 days) # ICON_CACHE_TTL=2592000 ## Cache time-to-live for icons which weren't available, in seconds (0 is "forever") -## Default: 259200 (3 days) +## Default: 2592000 (3 days) # ICON_CACHE_NEGTTL=259200 ## Icon download timeout ## Configure the timeout value when downloading the favicons. -## The default is 10 seconds, but this could be too low on slower network connections +## The default is 10 seconds, but this could be to low on slower network connections # ICON_DOWNLOAD_TIMEOUT=10 ## Block HTTP domains/IPs by Regex ## Any domains or IPs that match this regex won't be fetched by the internal HTTP client. ## Useful to hide other servers in the local network. Check the WIKI for more details -## NOTE: Always enclose this regex within single quotes! +## NOTE: Always enclose this regex withing single quotes! # HTTP_REQUEST_BLOCK_REGEX='^(192\.168\.0\.[0-9]+|192\.168\.1\.[0-9]+)$' -## Enabling this will cause the internal HTTP client to refuse to connect to any non-global IP address. +## Enabling this will cause the internal HTTP client to refuse to connect to any non global IP address. ## Useful to secure your internal environment: See https://en.wikipedia.org/wiki/Reserved_IP_addresses for a list of IPs which it will block # HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true ## Client Settings ## 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: -## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0) -## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0) -## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) -## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0) -## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0) -## - "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= +## - "autofill-overlay": Add an overlay menu to form fields for quick access to credentials. +## - "autofill-v2": Use the new autofill implementation. +## - "browser-fileless-import": Directly import credentials from other providers without a file. +## - "extension-refresh": Temporarily enable the new extension design until general availability (should be used with the beta Chrome extension) +## - "fido2-vault-credentials": Enable the use of FIDO2 security keys as second factor. +# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=fido2-vault-credentials ## Require new device emails. When a user logs in an email is required to be sent. ## If sending the email fails the login attempt will fail!! @@ -445,14 +407,6 @@ ## Multiple values must be separated with a whitespace. # ALLOWED_IFRAME_ANCESTORS= -## Allowed connect-src (Know the risks!) -## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src -## 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" -# ALLOWED_CONNECT_SRC="" - ## Number of seconds, on average, between login requests from the same IP address before rate limiting kicks in. # LOGIN_RATELIMIT_SECONDS=60 ## Allow a burst of requests of up to this size, while maintaining the average indicated by `LOGIN_RATELIMIT_SECONDS`. @@ -478,60 +432,6 @@ ## Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy. # ENFORCE_SINGLE_ORG_WITH_RESET_PW_POLICY=false -## Prefer IPv6 (AAAA) resolving -## This settings configures the DNS resolver to resolve IPv6 first, and if not available try IPv4 -## This could be useful in IPv6 only environments. -# DNS_PREFER_IPV6=false - -##################################### -### SSO settings (OpenID Connect) ### -##################################### - -## Controls whether users can login using an OpenID Connect identity provider -# SSO_ENABLED=false - -## Prevent users from logging in directly without going through SSO -# SSO_ONLY=false - -## On SSO Signup if a user with a matching email already exists make the association -# SSO_SIGNUPS_MATCH_EMAIL=true - -## Allow unknown email verification status. Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover. -# SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION=false - -## Base URL of the OIDC server (auto-discovery is used) -## - Should not include the `/.well-known/openid-configuration` part and no trailing `/` -## - ${SSO_AUTHORITY}/.well-known/openid-configuration should return a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse -# SSO_AUTHORITY=https://auth.example.com - -## Authorization request scopes. Optional SSO scopes, override if email and profile are not enough (`openid` is implicit). -# SSO_SCOPES="email profile" - -## Additional authorization url parameters (ex: to obtain a `refresh_token` with Google Auth). -# SSO_AUTHORIZE_EXTRA_PARAMS="access_type=offline&prompt=consent" - -## Activate PKCE for the Auth Code flow. -# SSO_PKCE=true - -## Regex for additional trusted Id token audience (by default only the client_id is trusted). -# SSO_AUDIENCE_TRUSTED='^$' - -## Set your Client ID and Client Key -# SSO_CLIENT_ID=11111 -# SSO_CLIENT_SECRET=AAAAAAAAAAAAAAAAAAAAAAAA - -## Optional Master password policy (minComplexity=[0-4]), `enforceOnLogin` is not supported at the moment. -# SSO_MASTER_PASSWORD_POLICY='{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' - -## Use sso only for authentication not the session lifecycle -# SSO_AUTH_ONLY_NOT_SESSION=false - -## Client cache for discovery endpoint. Duration in seconds (0 to disable). -# SSO_CLIENT_CACHE_EXPIRATION=0 - -## Log all the tokens, LOG_LEVEL=debug is required -# SSO_DEBUG_TOKENS=false - ######################## ### MFA/2FA settings ### ######################## @@ -574,7 +474,7 @@ ## Maximum attempts before an email token is reset and a new email will need to be sent. # EMAIL_ATTEMPTS_LIMIT=3 ## -## Setup email 2FA on registration regardless of any organization policy +## Setup email 2FA regardless of any organization policy # EMAIL_2FA_ENFORCE_ON_VERIFIED_INVITE=false ## Automatically setup email 2FA as fallback provider when needed # EMAIL_2FA_AUTO_FALLBACK=false @@ -591,7 +491,7 @@ ## ## According to the RFC6238 (https://tools.ietf.org/html/rfc6238), ## 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. +## This can however allow attackers to be a bit more lucky with there 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 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. @@ -631,7 +531,7 @@ # SMTP_AUTH_MECHANISM= ## Server name sent during the SMTP HELO -## By default this value should be the machine's hostname, +## By default this value should be is on the machine's hostname, ## but might need to be changed in case it trips some anti-spam filters # HELO_NAME= diff --git a/.gitattributes b/.gitattributes index 4d7cadd3..b33a6211 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ # Ignore vendored scripts in GitHub stats src/static/scripts/* linguist-vendored + diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3a336541..3d036b84 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,3 @@ /.github @dani-garcia @BlackDex -/.github/** @dani-garcia @BlackDex /.github/CODEOWNERS @dani-garcia @BlackDex -/.github/ISSUE_TEMPLATE/** @dani-garcia @BlackDex /.github/workflows/** @dani-garcia @BlackDex -/SECURITY.md @dani-garcia @BlackDex diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7d382d7a..7168e8ea 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -8,30 +8,15 @@ body: value: | Thanks for taking the time to fill out this bug report! - Please **do not** submit feature requests or ask for help on how to configure Vaultwarden here! + Please *do not* submit feature requests or ask for help on how to configure Vaultwarden here. The [GitHub Discussions](https://github.com/dani-garcia/vaultwarden/discussions/) has sections for Questions and Ideas. - Our [Wiki](https://github.com/dani-garcia/vaultwarden/wiki/) has topics on how to configure Vaultwarden. - Also, make sure you are running [![GitHub Release](https://img.shields.io/github/release/dani-garcia/vaultwarden.svg)](https://github.com/dani-garcia/vaultwarden/releases/latest) of Vaultwarden! + And search for existing open or closed issues or discussions regarding your topic before posting. Be sure to check and validate the Vaultwarden Admin Diagnostics (`/admin/diagnostics`) page for any errors! See here [how to enable the admin page](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page). - - > [!IMPORTANT] - > ## :bangbang: Search for existing **Closed _AND_ Open** [Issues](https://github.com/dani-garcia/vaultwarden/issues?q=is%3Aissue%20) **_AND_** [Discussions](https://github.com/dani-garcia/vaultwarden/discussions?discussions_q=) regarding your topic before posting! :bangbang: - # - - type: checkboxes - id: checklist - attributes: - label: Prerequisites - description: Please confirm you have completed the following before submitting an issue! - options: - - label: I have searched the existing **Closed _AND_ Open** [Issues](https://github.com/dani-garcia/vaultwarden/issues?q=is%3Aissue%20) **_AND_** [Discussions](https://github.com/dani-garcia/vaultwarden/discussions?discussions_q=) - required: true - - label: I have searched and read the [documentation](https://github.com/dani-garcia/vaultwarden/wiki/) - required: true # - id: support-string type: textarea @@ -51,7 +36,7 @@ body: attributes: label: Vaultwarden Build Version description: What version of Vaultwarden are you running? - placeholder: ex. v1.34.0 or v1.34.1-53f58b14 + placeholder: ex. v1.31.0 or v1.32.0-3466a804 validations: required: true # @@ -82,7 +67,7 @@ body: attributes: label: Reverse Proxy description: Are you using a reverse proxy, if so which and what version? - placeholder: ex. nginx 1.29.0, caddy 2.10.0, traefik 3.4.4, haproxy 3.2 + placeholder: ex. nginx 1.26.2, caddy 2.8.4, traefik 3.1.2, haproxy 3.0 validations: required: true # @@ -130,7 +115,7 @@ body: attributes: label: Client Version description: What version(s) of the client(s) are you seeing the problem on? - placeholder: ex. CLI v2025.7.0, Firefox 140 - v2025.6.1 + placeholder: ex. CLI v2024.7.2, Firefox 130 - v2024.7.0 # - id: reproduce type: textarea diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6269e595..a025041f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,9 +1,4 @@ name: Build -permissions: {} - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true on: push: @@ -18,8 +13,6 @@ on: - "diesel.toml" - "docker/Dockerfile.j2" - "docker/DockerSettings.yaml" - - "macros/**" - pull_request: paths: - ".github/workflows/build.yml" @@ -32,21 +25,16 @@ on: - "diesel.toml" - "docker/Dockerfile.j2" - "docker/DockerSettings.yaml" - - "macros/**" - -defaults: - run: - shell: bash jobs: build: - name: Build and Test ${{ matrix.channel }} - runs-on: ubuntu-24.04 + # We use Ubuntu 22.04 here because this matches the library versions used within the Debian docker containers + runs-on: ubuntu-22.04 timeout-minutes: 120 # Make warnings errors, this is to prevent warnings slipping through. # This is done globally to prevent rebuilds when the RUSTFLAGS env variable changes. env: - RUSTFLAGS: "-Dwarnings" + RUSTFLAGS: "-D warnings" strategy: fail-fast: false matrix: @@ -54,55 +42,62 @@ jobs: - "rust-toolchain" # The version defined in rust-toolchain - "msrv" # The supported MSRV + name: Build and Test ${{ matrix.channel }} + steps: + # Checkout the repo + - name: "Checkout" + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 #v4.2.1 + # End Checkout the repo + + # Install dependencies - name: "Install dependencies Ubuntu" run: sudo apt-get update && sudo apt-get install -y --no-install-recommends openssl build-essential libmariadb-dev-compat libpq-dev libssl-dev pkg-config # End Install dependencies - # Checkout the repo - - name: "Checkout" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - fetch-depth: 0 - # End Checkout the repo # Determine rust-toolchain version - name: Init Variables id: toolchain - env: - CHANNEL: ${{ matrix.channel }} + shell: bash run: | - if [[ "${CHANNEL}" == 'rust-toolchain' ]]; then - RUST_TOOLCHAIN="$(grep -m1 -oP 'channel.*"(\K.*?)(?=")' rust-toolchain.toml)" - elif [[ "${CHANNEL}" == 'msrv' ]]; then - RUST_TOOLCHAIN="$(grep -m1 -oP 'rust-version\s.*"(\K.*?)(?=")' Cargo.toml)" + if [[ "${{ matrix.channel }}" == 'rust-toolchain' ]]; then + RUST_TOOLCHAIN="$(grep -oP 'channel.*"(\K.*?)(?=")' rust-toolchain.toml)" + elif [[ "${{ matrix.channel }}" == 'msrv' ]]; then + RUST_TOOLCHAIN="$(grep -oP 'rust-version.*"(\K.*?)(?=")' Cargo.toml)" else - RUST_TOOLCHAIN="${CHANNEL}" + RUST_TOOLCHAIN="${{ matrix.channel }}" fi echo "RUST_TOOLCHAIN=${RUST_TOOLCHAIN}" | tee -a "${GITHUB_OUTPUT}" # End Determine rust-toolchain version - - name: "Install toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default" - env: - CHANNEL: ${{ matrix.channel }} - RUST_TOOLCHAIN: ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} + # Only install the clippy and rustfmt components on the default rust-toolchain + - name: "Install rust-toolchain version" + uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a # master @ Aug 8, 2024, 7:36 PM GMT+2 + if: ${{ matrix.channel == 'rust-toolchain' }} + with: + toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}" + components: clippy, rustfmt + # End Uses the rust-toolchain file to determine version + + + # Install the any other channel to be used for which we do not execute clippy and rustfmt + - name: "Install MSRV version" + uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a # master @ Aug 8, 2024, 7:36 PM GMT+2 + if: ${{ matrix.channel != 'rust-toolchain' }} + with: + toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}" + # End Install the MSRV channel to be used + + # Set the current matrix toolchain version as default + - name: "Set toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default" run: | # Remove the rust-toolchain.toml rm rust-toolchain.toml - - # Install the correct toolchain version - rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal --no-self-update - - # If this matrix is the `rust-toolchain` flow, also install rustfmt and clippy - if [[ "${CHANNEL}" == 'rust-toolchain' ]]; then - rustup component add --toolchain "${RUST_TOOLCHAIN}" rustfmt clippy - fi - - # Set as the default toolchain - rustup default "${RUST_TOOLCHAIN}" + # Set the default + rustup default ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} # Show environment - name: "Show environment" @@ -112,68 +107,61 @@ jobs: # End Show environment # Enable Rust Caching - - name: Rust Caching - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: Swatinem/rust-cache@23bce251a8cd2ffc3c1075eaa2367cf899916d84 # v2.7.3 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. # Only update when really needed! Use a .[.] format. - prefix-key: "v2025.09-rust" + prefix-key: "v2023.07-rust" # End Enable Rust Caching # Run cargo tests # First test all features together, afterwards test them separately. - - name: "test features: sqlite,mysql,postgresql,enable_mimalloc,s3" - id: test_sqlite_mysql_postgresql_mimalloc_s3 - if: ${{ !cancelled() }} - run: | - cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3 - - name: "test features: sqlite,mysql,postgresql,enable_mimalloc" id: test_sqlite_mysql_postgresql_mimalloc - if: ${{ !cancelled() }} + if: $${{ always() }} run: | - cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc + cargo test --features sqlite,mysql,postgresql,enable_mimalloc - name: "test features: sqlite,mysql,postgresql" id: test_sqlite_mysql_postgresql - if: ${{ !cancelled() }} + if: $${{ always() }} run: | - cargo test --profile ci --features sqlite,mysql,postgresql + cargo test --features sqlite,mysql,postgresql - name: "test features: sqlite" id: test_sqlite - if: ${{ !cancelled() }} + if: $${{ always() }} run: | - cargo test --profile ci --features sqlite + cargo test --features sqlite - name: "test features: mysql" id: test_mysql - if: ${{ !cancelled() }} + if: $${{ always() }} run: | - cargo test --profile ci --features mysql + cargo test --features mysql - name: "test features: postgresql" id: test_postgresql - if: ${{ !cancelled() }} + if: $${{ always() }} run: | - cargo test --profile ci --features postgresql + cargo test --features postgresql # End Run cargo tests # Run cargo clippy, and fail on warnings - - name: "clippy features: sqlite,mysql,postgresql,enable_mimalloc,s3" + - name: "clippy features: sqlite,mysql,postgresql,enable_mimalloc" id: clippy - if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }} + if: ${{ always() && matrix.channel == 'rust-toolchain' }} run: | - cargo clippy --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3 + cargo clippy --features sqlite,mysql,postgresql,enable_mimalloc -- -D warnings # End Run cargo clippy # Run cargo fmt (Only run on rust-toolchain defined version) - name: "check formatting" id: formatting - if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }} + if: ${{ always() && matrix.channel == 'rust-toolchain' }} run: | cargo fmt --all -- --check # End Run cargo fmt @@ -183,31 +171,21 @@ jobs: # This is useful so all test/clippy/fmt actions are done, and they can all be addressed - name: "Some checks failed" if: ${{ failure() }} - env: - TEST_DB_M_S3: ${{ steps.test_sqlite_mysql_postgresql_mimalloc_s3.outcome }} - TEST_DB_M: ${{ steps.test_sqlite_mysql_postgresql_mimalloc.outcome }} - TEST_DB: ${{ steps.test_sqlite_mysql_postgresql.outcome }} - TEST_SQLITE: ${{ steps.test_sqlite.outcome }} - TEST_MYSQL: ${{ steps.test_mysql.outcome }} - TEST_POSTGRESQL: ${{ steps.test_postgresql.outcome }} - CLIPPY: ${{ steps.clippy.outcome }} - FMT: ${{ steps.formatting.outcome }} run: | - echo "### :x: Checks Failed!" >> "${GITHUB_STEP_SUMMARY}" - echo "" >> "${GITHUB_STEP_SUMMARY}" - echo "|Job|Status|" >> "${GITHUB_STEP_SUMMARY}" - echo "|---|------|" >> "${GITHUB_STEP_SUMMARY}" - echo "|test (sqlite,mysql,postgresql,enable_mimalloc,s3)|${TEST_DB_M_S3}|" >> "${GITHUB_STEP_SUMMARY}" - echo "|test (sqlite,mysql,postgresql,enable_mimalloc)|${TEST_DB_M}|" >> "${GITHUB_STEP_SUMMARY}" - echo "|test (sqlite,mysql,postgresql)|${TEST_DB}|" >> "${GITHUB_STEP_SUMMARY}" - echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}" - echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}" - echo "|test (postgresql)|${TEST_POSTGRESQL}|" >> "${GITHUB_STEP_SUMMARY}" - echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc,s3)|${CLIPPY}|" >> "${GITHUB_STEP_SUMMARY}" - echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}" - echo "" >> "${GITHUB_STEP_SUMMARY}" - echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}" - echo "" >> "${GITHUB_STEP_SUMMARY}" + echo "### :x: Checks Failed!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "|Job|Status|" >> $GITHUB_STEP_SUMMARY + echo "|---|------|" >> $GITHUB_STEP_SUMMARY + echo "|test (sqlite,mysql,postgresql,enable_mimalloc)|${{ steps.test_sqlite_mysql_postgresql_mimalloc.outcome }}|" >> $GITHUB_STEP_SUMMARY + echo "|test (sqlite,mysql,postgresql)|${{ steps.test_sqlite_mysql_postgresql.outcome }}|" >> $GITHUB_STEP_SUMMARY + echo "|test (sqlite)|${{ steps.test_sqlite.outcome }}|" >> $GITHUB_STEP_SUMMARY + echo "|test (mysql)|${{ steps.test_mysql.outcome }}|" >> $GITHUB_STEP_SUMMARY + echo "|test (postgresql)|${{ steps.test_postgresql.outcome }}|" >> $GITHUB_STEP_SUMMARY + echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc)|${{ steps.clippy.outcome }}|" >> $GITHUB_STEP_SUMMARY + echo "|fmt|${{ steps.formatting.outcome }}|" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Please check the failed jobs and fix where needed." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY exit 1 @@ -216,5 +194,5 @@ jobs: - name: "All checks passed" if: ${{ success() }} run: | - echo "### :tada: Checks Passed!" >> "${GITHUB_STEP_SUMMARY}" - echo "" >> "${GITHUB_STEP_SUMMARY}" + echo "### :tada: Checks Passed!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/check-templates.yml b/.github/workflows/check-templates.yml deleted file mode 100644 index 57b53bf4..00000000 --- a/.github/workflows/check-templates.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Check templates -permissions: {} - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -on: [ push, pull_request ] - -defaults: - run: - shell: bash - -jobs: - docker-templates: - name: Validate docker templates - runs-on: ubuntu-24.04 - timeout-minutes: 30 - - steps: - # Checkout the repo - - name: "Checkout" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - # End Checkout the repo - - - name: Run make to rebuild templates - working-directory: docker - run: make - - - name: Check for unstaged changes - working-directory: docker - run: git diff --exit-code - continue-on-error: false diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml index 2b476904..a671f936 100644 --- a/.github/workflows/hadolint.yml +++ b/.github/workflows/hadolint.yml @@ -1,26 +1,24 @@ name: Hadolint -permissions: {} -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -on: [ push, pull_request ] - -defaults: - run: - shell: bash +on: [ + push, + pull_request + ] jobs: hadolint: name: Validate Dockerfile syntax runs-on: ubuntu-24.04 timeout-minutes: 30 - steps: + # Checkout the repo + - name: Checkout + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 #v4.2.1 + # End Checkout the repo + # Start Docker Buildx - name: Setup Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1 # https://github.com/moby/buildkit/issues/3969 # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills with: @@ -32,26 +30,23 @@ jobs: # Download hadolint - https://github.com/hadolint/hadolint/releases - name: Download hadolint + shell: bash run: | sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \ sudo chmod +x /usr/local/bin/hadolint env: - HADOLINT_VERSION: 2.14.0 + HADOLINT_VERSION: 2.12.0 # End Download hadolint - # Checkout the repo - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - # End Checkout the repo # Test Dockerfiles with hadolint - name: Run hadolint + shell: bash run: hadolint docker/Dockerfile.{debian,alpine} # End Test Dockerfiles with hadolint # Test Dockerfiles with docker build checks - name: Run docker build check + shell: bash run: | echo "Checking docker/Dockerfile.debian" docker build --check . -f docker/Dockerfile.debian diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8db56c38..22fc4e28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,4 @@ name: Release -permissions: {} - -concurrency: - # Apply concurrency control only on the upstream repo - group: ${{ github.repository == 'dani-garcia/vaultwarden' && format('{0}-{1}', github.workflow, github.ref) || github.run_id }} - # Don't cancel other runs when creating a tag - cancel-in-progress: ${{ github.ref_type == 'branch' }} on: push: @@ -13,88 +6,90 @@ on: - main tags: - # https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet - - '[1-2].[0-9]+.[0-9]+' - -defaults: - run: - shell: bash - -# A "release" environment must be created in the repository settings -# (Settings > Environments > New environment) with the following -# variables and secrets configured as needed. -# -# Variables (only set the ones for registries you want to push to): -# DOCKERHUB_REPO: 'index.docker.io//' -# QUAY_REPO: 'quay.io//' -# GHCR_REPO: 'ghcr.io//' -# -# Secrets (only required when the corresponding *_REPO variable is set): -# DOCKERHUB_REPO => DOCKERHUB_USERNAME, DOCKERHUB_TOKEN -# QUAY_REPO => QUAY_USERNAME, QUAY_TOKEN -# GITHUB_TOKEN is provided automatically + - '*' jobs: - docker-build: - name: Build Vaultwarden containers + # https://github.com/marketplace/actions/skip-duplicate-actions + # Some checks to determine if we need to continue with building a new docker. + # We will skip this check if we are creating a tag, because that has the same hash as a previous run already. + skip_check: + runs-on: ubuntu-24.04 if: ${{ github.repository == 'dani-garcia/vaultwarden' }} - environment: - name: release - deployment: false - permissions: - packages: write # Needed to upload packages and artifacts - contents: read - attestations: write # Needed to generate an artifact attestation for a build - id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate - runs-on: ${{ contains(matrix.arch, 'arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + outputs: + should_skip: ${{ steps.skip_check.outputs.should_skip }} + steps: + - name: Skip Duplicates Actions + id: skip_check + uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5.3.1 + with: + cancel_others: 'true' + # Only run this when not creating a tag + if: ${{ github.ref_type == 'branch' }} + + docker-build: + runs-on: ubuntu-24.04 timeout-minutes: 120 + needs: skip_check + if: ${{ needs.skip_check.outputs.should_skip != 'true' && github.repository == 'dani-garcia/vaultwarden' }} + # Start a local docker registry to extract the final Alpine static build binaries + services: + registry: + image: registry:2 + ports: + - 5000:5000 env: SOURCE_COMMIT: ${{ github.sha }} SOURCE_REPOSITORY_URL: "https://github.com/${{ github.repository }}" + # The *_REPO variables need to be configured as repository variables + # Append `/settings/variables/actions` to your repo url + # DOCKERHUB_REPO needs to be 'index.docker.io//' + # Check for Docker hub credentials in secrets + HAVE_DOCKERHUB_LOGIN: ${{ vars.DOCKERHUB_REPO != '' && secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} + # GHCR_REPO needs to be 'ghcr.io//' + # Check for Github credentials in secrets + HAVE_GHCR_LOGIN: ${{ vars.GHCR_REPO != '' && github.repository_owner != '' && secrets.GITHUB_TOKEN != '' }} + # QUAY_REPO needs to be 'quay.io//' + # Check for Quay.io credentials in secrets + HAVE_QUAY_LOGIN: ${{ vars.QUAY_REPO != '' && secrets.QUAY_USERNAME != '' && secrets.QUAY_TOKEN != '' }} strategy: matrix: - arch: ["amd64", "arm64", "arm/v7", "arm/v6"] base_image: ["debian","alpine"] steps: + # Checkout the repo + - name: Checkout + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 #v4.2.1 + with: + fetch-depth: 0 + - name: Initialize QEMU binfmt support - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3.2.0 with: platforms: "arm64,arm" # Start Docker Buildx - name: Setup Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1 # https://github.com/moby/buildkit/issues/3969 # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills with: - cache-binary: false buildkitd-config-inline: | [worker.oci] max-parallelism = 2 driver-opts: | network=host - # Checkout the repo - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - # We need fetch-depth of 0 so we also get all the tag metadata - with: - persist-credentials: false - fetch-depth: 0 - - # Normalize the architecture string for use in paths and cache keys - - name: Normalize architecture string - env: - MATRIX_ARCH: ${{ matrix.arch }} + # Determine Base Tags and Source Version + - name: Determine Base Tags and Source Version + shell: bash run: | - # Replace slashes with nothing to create a safe string for paths/cache keys - NORMALIZED_ARCH="${MATRIX_ARCH//\/}" - echo "NORMALIZED_ARCH=${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}" + # Check which main tag we are going to build determined by github.ref_type + if [[ "${{ github.ref_type }}" == "tag" ]]; then + echo "BASE_TAGS=latest,${GITHUB_REF#refs/*/}" | tee -a "${GITHUB_ENV}" + elif [[ "${{ github.ref_type }}" == "branch" ]]; then + echo "BASE_TAGS=testing" | tee -a "${GITHUB_ENV}" + fi - # Determine Source Version - - name: Determine Source Version - run: | # Get the Source Version for this release GIT_EXACT_TAG="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null || true)" if [[ -n "${GIT_EXACT_TAG}" ]]; then @@ -103,286 +98,155 @@ jobs: GIT_LAST_TAG="$(git describe --tags --abbrev=0)" echo "SOURCE_VERSION=${GIT_LAST_TAG}-${SOURCE_COMMIT:0:8}" | tee -a "${GITHUB_ENV}" fi + # End Determine Base Tags # Login to Docker Hub - name: Login to Docker Hub - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - if: ${{ vars.DOCKERHUB_REPO != '' }} + if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }} - name: Add registry for DockerHub - if: ${{ vars.DOCKERHUB_REPO != '' }} - env: - DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }} + if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }} + shell: bash run: | - echo "CONTAINER_REGISTRIES=${DOCKERHUB_REPO}" | tee -a "${GITHUB_ENV}" + echo "CONTAINER_REGISTRIES=${{ vars.DOCKERHUB_REPO }}" | tee -a "${GITHUB_ENV}" # Login to GitHub Container Registry - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - if: ${{ vars.GHCR_REPO != '' }} + if: ${{ env.HAVE_GHCR_LOGIN == 'true' }} - name: Add registry for ghcr.io - if: ${{ vars.GHCR_REPO != '' }} - env: - GHCR_REPO: ${{ vars.GHCR_REPO }} + if: ${{ env.HAVE_GHCR_LOGIN == 'true' }} + shell: bash run: | - echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${GHCR_REPO}" | tee -a "${GITHUB_ENV}" + echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${{ vars.GHCR_REPO }}" | tee -a "${GITHUB_ENV}" # Login to Quay.io - name: Login to Quay.io - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_TOKEN }} - if: ${{ vars.QUAY_REPO != '' }} + if: ${{ env.HAVE_QUAY_LOGIN == 'true' }} - name: Add registry for Quay.io - if: ${{ vars.QUAY_REPO != '' }} - env: - QUAY_REPO: ${{ vars.QUAY_REPO }} + if: ${{ env.HAVE_QUAY_LOGIN == 'true' }} + shell: bash run: | - echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}" + echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${{ vars.QUAY_REPO }}" | tee -a "${GITHUB_ENV}" - name: Configure build cache from/to - env: - GHCR_REPO: ${{ vars.GHCR_REPO }} - BASE_IMAGE: ${{ matrix.base_image }} - NORMALIZED_ARCH: ${{ env.NORMALIZED_ARCH }} + shell: bash run: | # # Check if there is a GitHub Container Registry Login and use it for caching - if [[ -n "${GHCR_REPO}" ]]; then - echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}" - echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}" + if [[ -n "${HAVE_GHCR_LOGIN}" ]]; then + echo "BAKE_CACHE_FROM=type=registry,ref=${{ vars.GHCR_REPO }}-buildcache:${{ matrix.base_image }}" | tee -a "${GITHUB_ENV}" + echo "BAKE_CACHE_TO=type=registry,ref=${{ vars.GHCR_REPO }}-buildcache:${{ matrix.base_image }},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}" else echo "BAKE_CACHE_FROM=" echo "BAKE_CACHE_TO=" fi # - - name: Generate tags - id: tags - env: - CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}" + - name: Add localhost registry + if: ${{ matrix.base_image == 'alpine' }} + shell: bash run: | - # Convert comma-separated list to newline-separated set commands - TAGS=$(echo "${CONTAINER_REGISTRIES}" | tr ',' '\n' | sed "s|.*|*.tags=&|") - - # Output for use in next step - { - echo "TAGS<> "$GITHUB_ENV" + echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}localhost:5000/vaultwarden/server" | tee -a "${GITHUB_ENV}" - name: Bake ${{ matrix.base_image }} containers - id: bake_vw - uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0 + uses: docker/bake-action@2e3d19baedb14545e5d41222653874f25d5b4dfb # v5.10.0 env: - BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}" + BASE_TAGS: "${{ env.BASE_TAGS }}" SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}" SOURCE_VERSION: "${{ env.SOURCE_VERSION }}" SOURCE_REPOSITORY_URL: "${{ env.SOURCE_REPOSITORY_URL }}" + CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}" with: pull: true - source: . + push: true files: docker/docker-bake.hcl targets: "${{ matrix.base_image }}-multi" set: | *.cache-from=${{ env.BAKE_CACHE_FROM }} *.cache-to=${{ env.BAKE_CACHE_TO }} - *.platform=linux/${{ matrix.arch }} - ${{ env.TAGS }} - *.output=type=local,dest=./output - *.output=type=image,push-by-digest=true,name-canonical=true,push=true - - name: Extract digest SHA - env: - BAKE_METADATA: ${{ steps.bake_vw.outputs.metadata }} - BASE_IMAGE: ${{ matrix.base_image }} + + # Extract the Alpine binaries from the containers + - name: Extract binaries + if: ${{ matrix.base_image == 'alpine' }} + shell: bash run: | - GET_DIGEST_SHA="$(jq -r --arg base "$BASE_IMAGE" '.[$base + "-multi"]."containerimage.digest"' <<< "${BAKE_METADATA}")" - echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}" - - - name: Export digest - env: - DIGEST_SHA: ${{ env.DIGEST_SHA }} - RUNNER_TEMP: ${{ runner.temp }} - run: | - mkdir -p "${RUNNER_TEMP}"/digests - digest="${DIGEST_SHA}" - touch "${RUNNER_TEMP}/digests/${digest#sha256:}" - - - name: Upload digest - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }} - path: ${{ runner.temp }}/digests/* - if-no-files-found: error - retention-days: 1 - - - name: Rename binaries to match target platform - env: - NORMALIZED_ARCH: ${{ env.NORMALIZED_ARCH }} - run: | - mv ./output/vaultwarden vaultwarden-"${NORMALIZED_ARCH}" - - # Upload artifacts to Github Actions and Attest the binaries - - name: Attest binaries - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 - with: - subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }} - - - name: Upload binaries as artifacts - 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 }} - - merge-manifests: - name: Merge manifests - runs-on: ubuntu-latest - needs: docker-build - environment: - name: release - deployment: false - permissions: - packages: write # Needed to upload packages and artifacts - attestations: write # Needed to generate an artifact attestation for a build - id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate - strategy: - matrix: - base_image: ["debian","alpine"] - - steps: - - name: Download digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - path: ${{ runner.temp }}/digests - pattern: digests-*-${{ matrix.base_image }} - merge-multiple: true - - # Login to Docker Hub - - name: Login to Docker Hub - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - if: ${{ vars.DOCKERHUB_REPO != '' }} - - - name: Add registry for DockerHub - if: ${{ vars.DOCKERHUB_REPO != '' }} - env: - DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }} - run: | - echo "CONTAINER_REGISTRIES=${DOCKERHUB_REPO}" | tee -a "${GITHUB_ENV}" - - # Login to GitHub Container Registry - - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - if: ${{ vars.GHCR_REPO != '' }} - - - name: Add registry for ghcr.io - if: ${{ vars.GHCR_REPO != '' }} - env: - GHCR_REPO: ${{ vars.GHCR_REPO }} - run: | - echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${GHCR_REPO}" | tee -a "${GITHUB_ENV}" - - # Login to Quay.io - - name: Login to Quay.io - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 - with: - registry: quay.io - username: ${{ secrets.QUAY_USERNAME }} - password: ${{ secrets.QUAY_TOKEN }} - if: ${{ vars.QUAY_REPO != '' }} - - - name: Add registry for Quay.io - if: ${{ vars.QUAY_REPO != '' }} - env: - QUAY_REPO: ${{ vars.QUAY_REPO }} - run: | - echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}" - - # Determine Base Tags - - name: Determine Base Tags - env: - BASE_IMAGE_TAG: "${{ matrix.base_image != 'debian' && format('-{0}', matrix.base_image) || '' }}" - REF_TYPE: ${{ github.ref_type }} - run: | - # Check which main tag we are going to build determined by ref_type - if [[ "${REF_TYPE}" == "tag" ]]; then - echo "BASE_TAGS=latest${BASE_IMAGE_TAG},${GITHUB_REF#refs/*/}${BASE_IMAGE_TAG}${BASE_IMAGE_TAG//-/,}" | tee -a "${GITHUB_ENV}" - elif [[ "${REF_TYPE}" == "branch" ]]; then - echo "BASE_TAGS=testing${BASE_IMAGE_TAG}" | tee -a "${GITHUB_ENV}" + # Check which main tag we are going to build determined by github.ref_type + if [[ "${{ github.ref_type }}" == "tag" ]]; then + EXTRACT_TAG="latest" + elif [[ "${{ github.ref_type }}" == "branch" ]]; then + EXTRACT_TAG="testing" fi - - name: Create manifest list, push it and extract digest SHA - working-directory: ${{ runner.temp }}/digests - env: - BASE_TAGS: "${{ env.BASE_TAGS }}" - CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}" - run: | - IFS=',' read -ra IMAGES <<< "${CONTAINER_REGISTRIES}" - IFS=',' read -ra TAGS <<< "${BASE_TAGS}" + # After each extraction the image is removed. + # This is needed because using different platforms doesn't trigger a new pull/download - TAG_ARGS=() - for img in "${IMAGES[@]}"; do - for tag in "${TAGS[@]}"; do - TAG_ARGS+=("-t" "${img}:${tag}") - done - done + # Extract amd64 binary + docker create --name amd64 --platform=linux/amd64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine" + docker cp amd64:/vaultwarden vaultwarden-amd64 + docker rm --force amd64 + docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine" - echo "Creating manifest" - if ! OUTPUT=$(docker buildx imagetools create \ - "${TAG_ARGS[@]}" \ - $(printf "${IMAGES[0]}@sha256:%s " *) 2>&1); then - echo "Manifest creation failed" - echo "${OUTPUT}" - exit 1 - fi + # Extract arm64 binary + docker create --name arm64 --platform=linux/arm64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine" + docker cp arm64:/vaultwarden vaultwarden-arm64 + docker rm --force arm64 + docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine" - echo "Manifest created successfully" - echo "${OUTPUT}" + # Extract armv7 binary + docker create --name armv7 --platform=linux/arm/v7 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine" + docker cp armv7:/vaultwarden vaultwarden-armv7 + docker rm --force armv7 + docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine" - # Extract digest SHA for subsequent steps - GET_DIGEST_SHA="$(echo "${OUTPUT}" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)" - echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}" + # Extract armv6 binary + docker create --name armv6 --platform=linux/arm/v6 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine" + docker cp armv6:/vaultwarden vaultwarden-armv6 + docker rm --force armv6 + docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine" - # Attest container images - - name: Attest - docker.io - ${{ matrix.base_image }} - if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}} - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + # Upload artifacts to Github Actions + - name: "Upload amd64 artifact" + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + if: ${{ matrix.base_image == 'alpine' }} with: - subject-name: ${{ vars.DOCKERHUB_REPO }} - subject-digest: ${{ env.DIGEST_SHA }} - push-to-registry: true + name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-amd64 + path: vaultwarden-amd64 - - name: Attest - ghcr.io - ${{ matrix.base_image }} - if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}} - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + - name: "Upload arm64 artifact" + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + if: ${{ matrix.base_image == 'alpine' }} with: - subject-name: ${{ vars.GHCR_REPO }} - subject-digest: ${{ env.DIGEST_SHA }} - push-to-registry: true + name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-arm64 + path: vaultwarden-arm64 - - name: Attest - quay.io - ${{ matrix.base_image }} - if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}} - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + - name: "Upload armv7 artifact" + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + if: ${{ matrix.base_image == 'alpine' }} with: - subject-name: ${{ vars.QUAY_REPO }} - subject-digest: ${{ env.DIGEST_SHA }} - push-to-registry: true + name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv7 + path: vaultwarden-armv7 + + - name: "Upload armv6 artifact" + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + if: ${{ matrix.base_image == 'alpine' }} + with: + name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv6 + path: vaultwarden-armv6 + # End Upload artifacts to Github Actions diff --git a/.github/workflows/releasecache-cleanup.yml b/.github/workflows/releasecache-cleanup.yml index 66bdf228..6fd880bb 100644 --- a/.github/workflows/releasecache-cleanup.yml +++ b/.github/workflows/releasecache-cleanup.yml @@ -1,10 +1,3 @@ -name: Cleanup -permissions: {} - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - on: workflow_dispatch: inputs: @@ -16,11 +9,10 @@ on: schedule: - cron: '0 1 * * FRI' +name: Cleanup jobs: releasecache-cleanup: name: Releasecache Cleanup - permissions: - packages: write # To be able to cleanup old caches runs-on: ubuntu-24.04 continue-on-error: true timeout-minutes: 30 diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 26f64aed..48a8bc1e 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -1,47 +1,37 @@ -name: Trivy -permissions: {} - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true +name: trivy on: push: branches: - main - tags: - '*' - pull_request: - branches: - - main - + branches: [ "main" ] schedule: - cron: '08 11 * * *' +permissions: + contents: read + jobs: trivy-scan: - # Only run this in the upstream repo and not on forks + # Only run this in the master repo and not on forks # When all forks run this at the same time, it is causing `Too Many Requests` issues if: ${{ github.repository == 'dani-garcia/vaultwarden' }} - name: Trivy Scan - permissions: - security-events: write # To write the security report + name: Check runs-on: ubuntu-24.04 timeout-minutes: 30 - + permissions: + contents: read + security-events: write + actions: read steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 #v4.2.1 - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - env: - TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2 - TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1 + uses: aquasecurity/trivy-action@5681af892cd0f4997658e2bacc62bd0a894cf564 # v0.27.0 with: scan-type: repo ignore-unfixed: true @@ -50,6 +40,6 @@ jobs: severity: CRITICAL,HIGH - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/upload-sarif@2bbafcdd7fbf96243689e764c2f15d9735164f33 # v3.26.6 with: sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml deleted file mode 100644 index 375600ed..00000000 --- a/.github/workflows/typos.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Code Spell Checking -permissions: {} - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -on: [ push, pull_request ] - -jobs: - typos: - name: Run typos spell checking - runs-on: ubuntu-24.04 - timeout-minutes: 30 - - steps: - # Checkout the repo - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - # End Checkout the repo - - # 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@7c572958218557a3272c2d6719629443b5cc26fd # v1.45.2 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml deleted file mode 100644 index 2350ec61..00000000 --- a/.github/workflows/zizmor.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Security Analysis with zizmor -permissions: {} - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -on: - push: - branches: ["main"] - pull_request: - branches: ["**"] - -jobs: - zizmor: - name: Run zizmor - runs-on: ubuntu-latest - permissions: - security-events: write # To write the security report - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Run zizmor - uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 - with: - # intentionally not scanning the entire repository, - # since it contains integration tests. - inputs: ./.github/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f10cef65..1061e8d4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,60 +1,44 @@ --- repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 hooks: - - id: check-yaml - - id: check-json - - id: check-toml - - id: mixed-line-ending - args: [ "--fix=no" ] - - id: end-of-file-fixer - exclude: "(.*js$|.*css$)" - - id: check-case-conflict - - id: check-merge-conflict - - id: detect-private-key - - id: check-symlinks - - id: forbid-submodules - - # 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: 7c572958218557a3272c2d6719629443b5cc26fd # v1.45.2 + - id: check-yaml + - id: check-json + - id: check-toml + - id: mixed-line-ending + args: ["--fix=no"] + - id: end-of-file-fixer + exclude: "(.*js$|.*css$)" + - id: check-case-conflict + - id: check-merge-conflict + - id: detect-private-key + - id: check-symlinks + - id: forbid-submodules +- repo: local hooks: - - id: typos - - - repo: local - hooks: - - id: fmt - name: fmt - description: Format files with cargo fmt. - entry: cargo fmt - language: system - always_run: true - pass_filenames: false - args: [ "--", "--check" ] - - id: cargo-test - name: cargo test - description: Test the package for errors. - entry: cargo test - language: system - args: [ "--features", "sqlite,mysql,postgresql", "--" ] - types_or: [ rust, file ] - files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$) - pass_filenames: false - - id: cargo-clippy - name: cargo clippy - description: Lint Rust sources - entry: cargo clippy - language: system - args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ] - types_or: [ rust, file ] - files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$) - pass_filenames: false - - id: check-docker-templates - name: check docker templates - description: Check if the Docker templates are updated - language: system - entry: sh - args: - - "-c" - - "cd docker && make" + - id: fmt + name: fmt + description: Format files with cargo fmt. + entry: cargo fmt + language: system + types: [rust] + args: ["--", "--check"] + - id: cargo-test + name: cargo test + description: Test the package for errors. + entry: cargo test + language: system + args: ["--features", "sqlite,mysql,postgresql,enable_mimalloc", "--"] + types_or: [rust, file] + files: (Cargo.toml|Cargo.lock|rust-toolchain|.*\.rs$) + pass_filenames: false + - id: cargo-clippy + name: cargo clippy + description: Lint Rust sources + entry: cargo clippy + language: system + args: ["--features", "sqlite,mysql,postgresql,enable_mimalloc", "--", "-D", "warnings"] + types_or: [rust, file] + files: (Cargo.toml|Cargo.lock|rust-toolchain|clippy.toml|.*\.rs$) + pass_filenames: false diff --git a/.typos.toml b/.typos.toml deleted file mode 100644 index 87c0c4a6..00000000 --- a/.typos.toml +++ /dev/null @@ -1,28 +0,0 @@ -[files] -extend-exclude = [ - ".git/", - "playwright/", - "*.js", # Ignore all JavaScript files - "!admin*.js", # Except our own JavaScript files -] -ignore-hidden = false - -[default] -extend-ignore-re = [ - # We use this in place of the reserved type identifier at some places - "typ", - # 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, ignore these specific items - "COSEKey", - "COSEAlgorithm", - # Ignore this specific string as it's valid - "Ensure they are valid OTPs", - # This word is misspelled upstream - # https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86 - # https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45 - "AuthRequestResponseRecieved", - # Ignore Punycode/IDN tests - "xn--.+" -] diff --git a/Cargo.lock b/Cargo.lock index ac84b501..4d59bee1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,29 +1,27 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 +version = 3 [[package]] -name = "adler2" -version = "2.0.1" +name = "addr2line" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", + "gimli", ] [[package]] -name = "ahash" -version = "0.8.12" +name = "adler2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", "once_cell", @@ -33,9 +31,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] @@ -57,9 +55,15 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.21" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" [[package]] name = "android_system_properties" @@ -70,12 +74,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - [[package]] name = "argon2" version = "0.5.3" @@ -84,49 +82,10 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures", "password-hash", ] -[[package]] -name = "asn1-rs" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom 7.1.3", - "num-traits", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "async-channel" version = "1.9.0" @@ -140,9 +99,9 @@ dependencies = [ [[package]] name = "async-channel" -version = "2.5.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" dependencies = [ "concurrent-queue", "event-listener-strategy", @@ -152,27 +111,28 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "0cb8f1d480b0ea3783ab015936d2a55c87e219676f0c0b7dec61494043f21857" dependencies = [ - "compression-codecs", - "compression-core", + "brotli", + "flate2", + "futures-core", + "memchr", "pin-project-lite", "tokio", ] [[package]] name = "async-executor" -version = "1.14.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" dependencies = [ "async-task", "concurrent-queue", "fastrand", "futures-lite", - "pin-project-lite", "slab", ] @@ -182,7 +142,7 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" dependencies = [ - "async-channel 2.5.0", + "async-channel 2.3.1", "async-executor", "async-io", "async-lock", @@ -193,11 +153,11 @@ dependencies = [ [[package]] name = "async-io" -version = "2.6.0" +version = "2.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +checksum = "444b0228950ee6501b3568d3c93bf1176a1fdbc3b758dcd9475046d30f4dc7e8" dependencies = [ - "autocfg", + "async-lock", "cfg-if", "concurrent-queue", "futures-io", @@ -206,43 +166,45 @@ dependencies = [ "polling", "rustix", "slab", - "windows-sys 0.61.2", + "tracing", + "windows-sys 0.59.0", ] [[package]] name = "async-lock" -version = "3.4.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" dependencies = [ - "event-listener 5.4.1", + "event-listener 5.3.1", "event-listener-strategy", "pin-project-lite", ] [[package]] name = "async-process" -version = "2.5.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" dependencies = [ - "async-channel 2.5.0", + "async-channel 2.3.1", "async-io", "async-lock", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener 5.4.1", + "event-listener 5.3.1", "futures-lite", "rustix", + "tracing", ] [[package]] name = "async-signal" -version = "0.2.14" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" dependencies = [ "async-io", "async-lock", @@ -253,14 +215,14 @@ dependencies = [ "rustix", "signal-hook-registry", "slab", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] name = "async-std" -version = "1.13.2" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +checksum = "c634475f29802fde2b8f0b505b1bd00dfe4df7d4a000f0b36f7671197d5c3615" dependencies = [ "async-channel 1.9.0", "async-global-executor", @@ -313,9 +275,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", @@ -330,9 +292,9 @@ checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" [[package]] name = "atomic" -version = "0.6.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" dependencies = [ "bytemuck", ] @@ -345,347 +307,30 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] -name = "aws-config" -version = "1.8.16" +name = "backtrace" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-sdk-sso", - "aws-sdk-ssooidc", - "aws-sdk-sts", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "hex", - "http 1.4.0", - "sha1", - "time", - "tokio", - "tracing", - "url", - "zeroize", + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", ] [[package]] -name = "aws-credential-types" -version = "1.2.14" +name = "base64" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "zeroize", -] - -[[package]] -name = "aws-runtime" -version = "1.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" -dependencies = [ - "aws-credential-types", - "aws-sigv4", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "bytes-utils", - "fastrand", - "http 1.4.0", - "http-body 1.0.1", - "percent-encoding", - "pin-project-lite", - "tracing", - "uuid", -] - -[[package]] -name = "aws-sdk-sso" -version = "1.98.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d69c77aafa20460c68b6b3213c84f6423b6e76dbf89accd3e1789a686ffd9489" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "http 0.2.12", - "http 1.4.0", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sdk-ssooidc" -version = "1.100.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7e7b09346d5ca22a2a08267555843a6a0127fb20d8964cb6ecfb8fdb190225" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "http 0.2.12", - "http 1.4.0", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sdk-sts" -version = "1.103.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-query", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-smithy-xml", - "aws-types", - "fastrand", - "http 0.2.12", - "http 1.4.0", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sigv4" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" -dependencies = [ - "aws-credential-types", - "aws-smithy-http", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "form_urlencoded", - "hex", - "hmac 0.13.0", - "http 0.2.12", - "http 1.4.0", - "percent-encoding", - "sha2 0.11.0", - "time", - "tracing", -] - -[[package]] -name = "aws-smithy-async" -version = "1.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" -dependencies = [ - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "aws-smithy-http" -version = "0.63.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" -dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "bytes-utils", - "futures-core", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "percent-encoding", - "pin-project-lite", - "pin-utils", - "tracing", -] - -[[package]] -name = "aws-smithy-json" -version = "0.62.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" -dependencies = [ - "aws-smithy-types", -] - -[[package]] -name = "aws-smithy-observability" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" -dependencies = [ - "aws-smithy-runtime-api", -] - -[[package]] -name = "aws-smithy-query" -version = "0.60.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" -dependencies = [ - "aws-smithy-types", - "urlencoding", -] - -[[package]] -name = "aws-smithy-runtime" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" -dependencies = [ - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-observability", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "fastrand", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "http-body 1.0.1", - "http-body-util", - "pin-project-lite", - "pin-utils", - "tokio", - "tracing", -] - -[[package]] -name = "aws-smithy-runtime-api" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api-macros", - "aws-smithy-types", - "bytes", - "http 0.2.12", - "http 1.4.0", - "pin-project-lite", - "tokio", - "tracing", - "zeroize", -] - -[[package]] -name = "aws-smithy-runtime-api-macros" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "aws-smithy-types" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" -dependencies = [ - "base64-simd", - "bytes", - "bytes-utils", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "http-body 1.0.1", - "http-body-util", - "itoa", - "num-integer", - "pin-project-lite", - "pin-utils", - "ryu", - "serde", - "time", -] - -[[package]] -name = "aws-smithy-xml" -version = "0.60.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" -dependencies = [ - "xmlparser", -] - -[[package]] -name = "aws-types" -version = "1.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" -dependencies = [ - "aws-credential-types", - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "rustc_version", - "tracing", -] - -[[package]] -name = "backon" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" -dependencies = [ - "fastrand", - "gloo-timers", - "tokio", -] - -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" @@ -699,38 +344,17 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" -dependencies = [ - "outref", - "vsimd", -] - [[package]] name = "base64ct" -version = "1.8.3" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "base64urlsafedata" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" -dependencies = [ - "base64 0.21.7", - "pastey 0.1.1", - "serde", -] +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bigdecimal" -version = "0.4.10" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +checksum = "51d712318a27c7150326677b321a5fa91b55f6d9034ffd67f20319e147d40cee" dependencies = [ "autocfg", "libm", @@ -747,9 +371,15 @@ checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" [[package]] name = "bitflags" -version = "2.11.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "blake2" @@ -757,7 +387,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -769,31 +399,13 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - [[package]] name = "blocking" -version = "1.6.2" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" dependencies = [ - "async-channel 2.5.0", + "async-channel 2.3.1", "async-task", "futures-io", "futures-lite", @@ -802,9 +414,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -813,9 +425,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -823,15 +435,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" [[package]] name = "byteorder" @@ -841,46 +453,35 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "bytes-utils" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" -dependencies = [ - "bytes", - "either", -] +checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" [[package]] name = "cached" -version = "0.59.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b6f5d101f0f6322c8646a45b7c581a673e476329040d97565815c2461dd0c4" +checksum = "b4d73155ae6b28cf5de4cfc29aeb02b8a1c6dab883cb015d15cd514e42766846" dependencies = [ "ahash", "async-trait", "cached_proc_macro", "cached_proc_macro_types", "futures", - "hashbrown 0.16.1", + "hashbrown 0.14.5", "once_cell", - "parking_lot", - "thiserror 2.0.18", + "thiserror", "tokio", "web-time", ] [[package]] name = "cached_proc_macro" -version = "0.27.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ebcf9c75f17a17d55d11afc98e46167d4790a263f428891b8705ab2f793eca3" +checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" dependencies = [ - "darling 0.20.11", + "darling", "proc-macro2", "quote", "syn", @@ -892,126 +493,65 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - [[package]] name = "cc" -version = "1.2.61" +version = "1.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", "shlex", ] [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ + "android-tzdata", "iana-time-zone", - "js-sys", "num-traits", "serde", - "wasm-bindgen", - "windows-link", + "windows-targets 0.52.6", ] [[package]] name = "chrono-tz" -version = "0.10.4" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +checksum = "cd6dd8046d00723a59a2f8c5f295c515b9bb9a331ee4f8f3d4dd49e428acd3b6" dependencies = [ "chrono", - "phf 0.12.1", + "chrono-tz-build", + "phf", ] [[package]] -name = "cipher" -version = "0.4.4" +name = "chrono-tz-build" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e94fea34d77a245229e7746bd2beb786cd2a896f306ff491fb8cecb3074b10a7" dependencies = [ - "crypto-common 0.1.6", - "inout", + "parse-zoneinfo", + "phf_codegen", ] [[package]] -name = "cmov" -version = "0.5.3" +name = "chumsky" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" - -[[package]] -name = "codemap" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" dependencies = [ - "bytes", - "memchr", + "hashbrown 0.14.5", + "stacker", ] -[[package]] -name = "compression-codecs" -version = "0.4.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" -dependencies = [ - "brotli", - "compression-core", - "flate2", - "memchr", - "zstd", - "zstd-safe", -] - -[[package]] -name = "compression-core" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1021,47 +561,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "cookie" version = "0.18.1" @@ -1075,13 +574,12 @@ dependencies = [ [[package]] name = "cookie_store" -version = "0.22.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +checksum = "4934e6b7e8419148b6ef56950d277af8561060b56afd59e2aadf98b59fce6baa" dependencies = [ "cookie", - "document-features", - "idna", + "idna 0.5.0", "log", "publicsuffix", "serde", @@ -1101,16 +599,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1119,98 +607,38 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" dependencies = [ "libc", ] -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32c" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" -dependencies = [ - "rustc_version", -] - [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ "cfg-if", ] -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - [[package]] name = "cron" -version = "0.15.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740" +checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07" dependencies = [ "chrono", + "nom", "once_cell", - "winnow 0.6.26", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "crypto-common" @@ -1222,86 +650,21 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "darling" -version = "0.20.11" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core", + "darling_macro", ] [[package]] name = "darling_core" -version = "0.20.11" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" dependencies = [ "fnv", "ident_case", @@ -1311,62 +674,13 @@ dependencies = [ "syn", ] -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core 0.20.11", - "quote", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn", ] @@ -1387,103 +701,23 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" [[package]] name = "data-url" -version = "0.3.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "9.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom 7.1.3", - "num-bigint", - "num-traits", - "rusticata-macros", -] +checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" [[package]] name = "deranged" -version = "0.5.8" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", - "serde_core", -] - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", - "unicode-xid", ] [[package]] @@ -1512,7 +746,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" dependencies = [ - "bitflags", + "bitflags 2.6.0", "proc-macro2", "proc-macro2-diagnostics", "quote", @@ -1521,16 +755,15 @@ dependencies = [ [[package]] name = "diesel" -version = "2.3.9" +version = "2.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9940fb8467a0a06312218ed384185cb8536aa10d8ec017d0ce7fad2c1bd882d5" +checksum = "158fe8e2e68695bd615d7e4f3227c0727b151330d3e253b525086c348d055d5e" dependencies = [ "bigdecimal", - "bitflags", + "bitflags 2.6.0", "byteorder", "chrono", "diesel_derives", - "downcast-rs", "itoa", "libsqlite3-sys", "mysqlclient-sys", @@ -1540,27 +773,15 @@ dependencies = [ "percent-encoding", "pq-sys", "r2d2", - "sqlite-wasm-rs", "time", "url", ] -[[package]] -name = "diesel-derive-newtype" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5adf688c584fe33726ce0e2898f608a2a92578ac94a4a92fcecf73214fe0716" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "diesel_derives" -version = "2.3.9" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" +checksum = "e7f2c3de51e2ba6bf2a648285696137aaf0f5f487bcbea93972fe8a364e131a4" dependencies = [ "diesel_table_macro_syntax", "dsl_auto_type", @@ -1570,10 +791,20 @@ dependencies = [ ] [[package]] -name = "diesel_migrations" -version = "2.3.2" +name = "diesel_logger" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" +checksum = "23010b507517129dc9b11fb35f36d76fd2d3dd4c85232733697622e345375f2f" +dependencies = [ + "diesel", + "log", +] + +[[package]] +name = "diesel_migrations" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a73ce704bad4231f001bff3314d91dce4aba0770cee8b233991859abc15c1f6" dependencies = [ "diesel", "migrations_internals", @@ -1582,9 +813,9 @@ dependencies = [ [[package]] name = "diesel_table_macro_syntax" -version = "0.3.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" dependencies = [ "syn", ] @@ -1595,24 +826,11 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.6", + "block-buffer", + "crypto-common", "subtle", ] -[[package]] -name = "digest" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" -dependencies = [ - "block-buffer 0.12.0", - "const-oid 0.10.2", - "crypto-common 0.2.1", - "ctutils", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -1624,43 +842,19 @@ dependencies = [ "syn", ] -[[package]] -name = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - [[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "downcast-rs" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" - [[package]] name = "dsl_auto_type" -version = "0.2.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +checksum = "c5d9abe6314103864cc2d8901b7ae224e0ab1a103a0a416661b4097b0779b607" dependencies = [ - "darling 0.21.3", + "darling", "either", "heck", "proc-macro2", @@ -1668,82 +862,17 @@ dependencies = [ "syn", ] -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2 0.10.9", - "subtle", - "zeroize", -] - [[package]] name = "either" -version = "1.15.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "hkdf", - "pem-rfc7468", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "email-encoding" -version = "0.4.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +checksum = "60d1d33cdaede7e24091f039632eb5d3c7469fe5b066a985281a34fc70fa317f" dependencies = [ "base64 0.22.1", "memchr", @@ -1768,19 +897,40 @@ dependencies = [ ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "enum-as-inner" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.14" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", +] + +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", ] [[package]] @@ -1791,9 +941,9 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" dependencies = [ "concurrent-queue", "parking", @@ -1802,25 +952,25 @@ dependencies = [ [[package]] name = "event-listener-strategy" -version = "0.5.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" dependencies = [ - "event-listener 5.4.1", + "event-listener 5.3.1", "pin-project-lite", ] [[package]] name = "fastrand" -version = "2.4.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" [[package]] name = "fern" -version = "0.7.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +checksum = "69ff9c9d5fb3e6da8ac2f77ab76fe7e8087d512ce095200f8f29ac5b656cf6dc" dependencies = [ "libc", "log", @@ -1828,47 +978,25 @@ dependencies = [ "syslog", ] -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "figment" version = "0.10.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ - "atomic 0.6.1", + "atomic 0.6.0", "pear", "serde", - "toml 0.8.23", + "toml", "uncased", "version_check", ] -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - [[package]] name = "flate2" -version = "1.1.9" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", "miniz_oxide", @@ -1880,18 +1008,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "foreign-types" version = "0.3.2" @@ -1909,18 +1025,18 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] [[package]] name = "futures" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -1933,9 +1049,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -1943,15 +1059,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -1960,15 +1076,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" -version = "2.6.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" dependencies = [ "fastrand", "futures-core", @@ -1979,9 +1095,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", @@ -1990,15 +1106,15 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-timer" @@ -2008,9 +1124,9 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -2020,6 +1136,7 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", + "pin-utils", "slab", ] @@ -2033,25 +1150,24 @@ dependencies = [ "libc", "log", "rustversion", - "windows", + "windows 0.48.0", ] [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] name = "getrandom" -version = "0.2.17" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "js-sys", @@ -2061,38 +1177,16 @@ dependencies = [ ] [[package]] -name = "getrandom" -version = "0.3.4" +name = "gimli" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasip2", - "wasip3", -] +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" -version = "0.3.3" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "gloo-timers" @@ -2108,64 +1202,57 @@ dependencies = [ [[package]] name = "governor" -version = "0.10.4" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +checksum = "0746aa765db78b521451ef74221663b57ba595bf83f75d0ce23cc09447c8139f" dependencies = [ "cfg-if", "dashmap", "futures-sink", "futures-timer", "futures-util", - "getrandom 0.3.4", - "hashbrown 0.16.1", + "no-std-compat", "nonzero_ext", "parking_lot", "portable-atomic", "quanta", - "rand 0.9.4", + "rand", "smallvec", "spinning_top", - "web-time", -] - -[[package]] -name = "grass_compiler" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d9e3df7f0222ce5184154973d247c591d9aadc28ce7a73c6cd31100c9facff6" -dependencies = [ - "codemap", - "indexmap 2.14.0", - "lasso", - "once_cell", - "phf 0.11.3", -] - -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", ] [[package]] name = "h2" -version = "0.4.13" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.0", - "indexmap 2.14.0", + "http 1.1.0", + "indexmap", "slab", "tokio", "tokio-util", @@ -2174,38 +1261,25 @@ dependencies = [ [[package]] name = "half" -version = "2.7.1" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" [[package]] name = "handlebars" -version = "6.4.0" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b3f9296c208515b87bd915a2f5d1163d4b3f863ba83337d7713cf478055948e" +checksum = "ce25b617d1375ef96eeb920ae717e3da34a02fc979fe632c75128350f9e1f74a" dependencies = [ - "derive_builder", "log", - "num-order", "pest", "pest_derive", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "walkdir", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - [[package]] name = "hashbrown" version = "0.14.5" @@ -2218,29 +1292,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" [[package]] name = "heck" @@ -2250,138 +1304,106 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] -name = "hex" -version = "0.4.3" +name = "hermit-abi" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" [[package]] -name = "hickory-net" -version = "0.26.1" +name = "hickory-proto" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +checksum = "07698b8420e2f0d6447a436ba999ec85d8fbf2a398bbd737b82cac4a2e96e512" dependencies = [ "async-trait", "cfg-if", "data-encoding", + "enum-as-inner", "futures-channel", "futures-io", "futures-util", - "hickory-proto", - "idna", + "idna 0.4.0", "ipnet", - "jni", - "rand 0.10.1", - "thiserror 2.0.18", + "once_cell", + "rand", + "thiserror", "tinyvec", "tokio", "tracing", "url", ] -[[package]] -name = "hickory-proto" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" -dependencies = [ - "data-encoding", - "idna", - "ipnet", - "jni", - "once_cell", - "prefix-trie", - "rand 0.10.1", - "ring", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "url", -] - [[package]] name = "hickory-resolver" -version = "0.26.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +checksum = "28757f23aa75c98f254cf0405e6d8c25b831b32921b050a66692427679b1f243" dependencies = [ "cfg-if", "futures-util", - "hickory-net", "hickory-proto", "ipconfig", - "ipnet", - "jni", - "moka", - "ndk-context", + "lru-cache", "once_cell", "parking_lot", - "rand 0.10.1", + "rand", "resolv-conf", "smallvec", - "system-configuration", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", ] -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac 0.12.1", -] - [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.2", + "digest", ] [[package]] name = "home" -version = "0.5.12" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "hostname" -version = "0.4.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + +[[package]] +name = "hostname" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" dependencies = [ "cfg-if", "libc", - "windows-link", + "windows 0.52.0", ] [[package]] name = "html5gum" -version = "0.8.3" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12d29324a6ba370667998f63c6dd2b2511e2297f07e827f69026684907adc3b5" +checksum = "4c4e556171a058ba117bbe88b059fb37b6289023e007d2903ea6dca3a3cbff14" dependencies = [ "jetscii", ] @@ -2399,11 +1421,12 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" dependencies = [ "bytes", + "fnv", "itoa", ] @@ -2425,27 +1448,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http 1.1.0", ] [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", - "futures-core", - "http 1.4.0", + "futures-util", + "http 1.1.0", "http-body 1.0.1", "pin-project-lite", ] [[package]] name = "httparse" -version = "1.10.1" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" [[package]] name = "httpdate" @@ -2453,32 +1476,24 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hybrid-array" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" -dependencies = [ - "typenum", -] - [[package]] name = "hyper" -version = "0.14.32" +version = "0.14.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85" dependencies = [ "bytes", "futures-channel", "futures-core", "futures-util", + "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "httparse", "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.10", + "socket2", "tokio", "tower-service", "tracing", @@ -2487,16 +1502,15 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a" dependencies = [ - "atomic-waker", "bytes", "futures-channel", - "futures-core", - "h2", - "http 1.4.0", + "futures-util", + "h2 0.4.6", + "http 1.1.0", "http-body 1.0.1", "httparse", "itoa", @@ -2508,57 +1522,79 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ - "http 1.4.0", - "hyper 1.9.0", + "futures-util", + "http 1.1.0", + "hyper 1.5.0", "hyper-util", - "rustls 0.23.40", - "rustls-native-certs", + "rustls 0.23.15", + "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls 0.26.0", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.31", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.5.0", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", "tower-service", - "webpki-roots", ] [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b" dependencies = [ - "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http 1.1.0", "http-body 1.0.1", - "hyper 1.9.0", - "ipnet", - "libc", - "percent-encoding", + "hyper 1.5.0", "pin-project-lite", - "socket2 0.6.3", - "system-configuration", + "socket2", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] name = "iana-time-zone" -version = "0.1.65" +version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", - "log", "wasm-bindgen", "windows-core", ] @@ -2574,23 +1610,21 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ "displaydoc", - "potential_utf", - "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "icu_locid" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" dependencies = [ "displaydoc", "litemap", @@ -2600,65 +1634,97 @@ dependencies = [ ] [[package]] -name = "icu_normalizer" -version = "2.2.0" +name = "icu_locid_transform" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" [[package]] name = "icu_properties" -version = "2.2.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" dependencies = [ + "displaydoc", "icu_collections", - "icu_locale_core", + "icu_locid_transform", "icu_properties_data", "icu_provider", - "zerotrie", + "tinystr", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" [[package]] name = "icu_provider" -version = "2.2.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" dependencies = [ "displaydoc", - "icu_locale_core", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", "writeable", "yoke", "zerofrom", - "zerotrie", "zerovec", ] [[package]] -name = "id-arena" -version = "2.3.0" +name = "icu_provider_macros" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "ident_case" @@ -2668,46 +1734,55 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.1.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" dependencies = [ - "idna_adapter", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd69211b9b519e98303c015e21a007e293db403b6c85b9b124e133d25e242cdd" +dependencies = [ + "icu_normalizer", + "icu_properties", "smallvec", "utf8_iter", ] -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indexmap" -version = "1.9.3" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.15.0", "serde", - "serde_core", ] [[package]] @@ -2716,73 +1791,40 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - [[package]] name = "ipconfig" -version = "0.3.4" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" dependencies = [ - "socket2 0.6.3", + "socket2", "widestring", - "windows-registry", - "windows-result", - "windows-sys 0.61.2", + "windows-sys 0.48.0", + "winreg", ] [[package]] name = "ipnet" -version = "2.12.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -dependencies = [ - "serde", -] - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] +checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" [[package]] name = "is-terminal" -version = "0.4.17" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" dependencies = [ - "hermit-abi", + "hermit-abi 0.4.0", "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", + "windows-sys 0.52.0", ] [[package]] name = "itoa" -version = "1.0.18" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "jetscii" @@ -2790,136 +1832,33 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" -[[package]] -name = "jiff" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" -dependencies = [ - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-sys 0.61.2", -] - -[[package]] -name = "jiff-static" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror 2.0.18", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - [[package]] name = "job_scheduler_ng" -version = "2.4.0" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217723d58ee473953675d15f11e56898a611aca8ea044d5a34eabeade99ef613" +checksum = "87c252207f323e2996d087759ebdcff8f608cd3eaa9896909a0c2dd3050a3c6a" dependencies = [ "chrono", "cron", "uuid", ] -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" dependencies = [ - "cfg-if", - "futures-util", - "once_cell", "wasm-bindgen", ] [[package]] name = "jsonwebtoken" -version = "9.3.1" +version = "9.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "js-sys", "pem", "ring", @@ -2928,29 +1867,6 @@ dependencies = [ "simple_asn1", ] -[[package]] -name = "jsonwebtoken" -version = "10.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" -dependencies = [ - "base64 0.22.1", - "ed25519-dalek", - "getrandom 0.2.17", - "hmac 0.12.1", - "js-sys", - "p256", - "p384", - "pem", - "rand 0.8.6", - "rsa", - "serde", - "serde_json", - "sha2 0.10.9", - "signature", - "simple_asn1", -] - [[package]] name = "kv-log-macro" version = "1.0.7" @@ -2960,87 +1876,70 @@ dependencies = [ "log", ] -[[package]] -name = "lasso" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e14eda50a3494b3bf7b9ce51c52434a761e383d7238ce1dd5dcec2fbc13e9fb" -dependencies = [ - "hashbrown 0.14.5", -] - [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lettre" -version = "0.11.21" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" +checksum = "0161e452348e399deb685ba05e55ee116cae9410f4f51fe42d597361444521d9" dependencies = [ "async-std", "async-trait", "base64 0.22.1", + "chumsky", "email-encoding", "email_address", "fastrand", "futures-io", "futures-util", - "hostname", + "hostname 0.4.0", "httpdate", - "idna", + "idna 1.0.2", "mime", - "nom 8.0.0", + "native-tls", + "nom", "percent-encoding", "quoted_printable", - "rustls 0.23.40", - "rustls-native-certs", "serde", - "socket2 0.6.3", + "socket2", "tokio", - "tokio-rustls 0.26.4", + "tokio-native-tls", "tracing", "url", ] [[package]] name = "libc" -version = "0.2.186" +version = "0.2.161" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" [[package]] name = "libm" -version = "0.2.16" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" [[package]] name = "libmimalloc-sys" -version = "0.1.47" +version = "0.1.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6" +checksum = "23aa6811d3bd4deb8a84dde645f943476d13b248d818edcf8ce0b2f37f036b44" dependencies = [ "cc", + "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "cc", "pkg-config", @@ -3048,37 +1947,38 @@ dependencies = [ ] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "linked-hash-map" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "litemap" -version = "0.8.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" [[package]] name = "lock_api" -version = "0.4.14" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ + "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.29" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" dependencies = [ "value-bag", ] @@ -3099,59 +1999,50 @@ dependencies = [ ] [[package]] -name = "lru-slab" +name = "lru-cache" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] [[package]] -name = "macros" +name = "match_cfg" version = "0.1.0" -dependencies = [ - "quote", - "syn", -] +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" [[package]] name = "matchers" -version = "0.2.0" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" dependencies = [ - "regex-automata", -] - -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest 0.10.7", + "regex-automata 0.1.10", ] [[package]] name = "memchr" -version = "2.8.0" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "migrations_internals" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +checksum = "fd01039851e82f8799046eabbb354056283fb265c8ec0996af940f4e85a380ff" dependencies = [ "serde", - "toml 0.9.12+spec-1.1.0", + "toml", ] [[package]] name = "migrations_macros" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +checksum = "ffb161cc72176cb37aa47f1fc520d3ef02263d67d661f44f05d05a079e1237fd" dependencies = [ "migrations_internals", "proc-macro2", @@ -3160,9 +2051,9 @@ dependencies = [ [[package]] name = "mimalloc" -version = "0.1.50" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640" +checksum = "68914350ae34959d83f732418d51e2427a794055d0b9529f48259ac07af65633" dependencies = [ "libmimalloc-sys", ] @@ -3181,43 +2072,23 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" dependencies = [ "adler2", - "simd-adler32", ] [[package]] name = "mio" -version = "1.2.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ + "hermit-abi 0.3.9", "libc", "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "moka" -version = "0.12.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" -dependencies = [ - "async-lock", - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "event-listener 5.4.1", - "futures-util", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", + "windows-sys 0.52.0", ] [[package]] @@ -3229,7 +2100,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.0", + "http 1.1.0", "httparse", "memchr", "mime", @@ -3241,20 +2112,36 @@ dependencies = [ [[package]] name = "mysqlclient-sys" -version = "0.5.1" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "822bc60a9459abe384dd85d81ac59167ed2da99fba6eb810000e6ab64d9404b2" +checksum = "478e2040dbc35c73927b77a2be91a496de19deab376a6982ed61e89592434619" dependencies = [ "pkg-config", - "semver", "vcpkg", ] [[package]] -name = "ndk-context" -version = "0.1.1" +name = "native-tls" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" [[package]] name = "nom" @@ -3266,15 +2153,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - [[package]] name = "nonzero_ext" version = "0.3.0" @@ -3283,11 +2161,12 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" [[package]] name = "nu-ansi-term" -version = "0.50.3" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" dependencies = [ - "windows-sys 0.61.2", + "overload", + "winapi", ] [[package]] @@ -3300,27 +2179,11 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", -] - [[package]] name = "num-conv" -version = "0.2.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-derive" @@ -3342,32 +2205,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-modular" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" - -[[package]] -name = "num-order" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" -dependencies = [ - "num-modular", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -3375,16 +2212,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] name = "num_cpus" -version = "1.17.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.9", "libc", ] @@ -3398,111 +2234,27 @@ dependencies = [ ] [[package]] -name = "oauth2" -version = "5.0.0" +name = "object" +version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ - "base64 0.22.1", - "chrono", - "getrandom 0.2.17", - "http 1.4.0", - "rand 0.8.6", - "reqwest", - "serde", - "serde_json", - "serde_path_to_error", - "sha2 0.10.9", - "thiserror 1.0.69", - "url", -] - -[[package]] -name = "oid-registry" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" -dependencies = [ - "asn1-rs", + "memchr", ] [[package]] name = "once_cell" -version = "1.21.4" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] - -[[package]] -name = "opendal" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d075ab8a203a6ab4bc1bce0a4b9fe486a72bf8b939037f4b78d95386384bc80a" -dependencies = [ - "anyhow", - "backon", - "base64 0.22.1", - "bytes", - "crc32c", - "futures", - "getrandom 0.2.17", - "http 1.4.0", - "http-body 1.0.1", - "jiff", - "log", - "md-5", - "percent-encoding", - "quick-xml 0.38.4", - "reqsign", - "reqwest", - "serde", - "serde_json", - "tokio", - "url", - "uuid", -] - -[[package]] -name = "openidconnect" -version = "4.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" -dependencies = [ - "base64 0.21.7", - "chrono", - "dyn-clone", - "ed25519-dalek", - "hmac 0.12.1", - "http 1.4.0", - "itertools", - "log", - "oauth2", - "p256", - "p384", - "rand 0.8.6", - "rsa", - "serde", - "serde-value", - "serde_json", - "serde_path_to_error", - "serde_plain", - "serde_with", - "sha2 0.10.9", - "subtle", - "thiserror 1.0.69", - "url", -] +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "openssl" -version = "0.10.78" +version = "0.10.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" dependencies = [ - "bitflags", + "bitflags 2.6.0", "cfg-if", "foreign-types", "libc", @@ -3524,24 +2276,24 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.2.1" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-src" -version = "300.6.0+3.6.2" +version = "300.4.0+3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +checksum = "a709e02f2b4aca747929cca5ed248880847c650233cf8b8cdc48f40aaf4898a6" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.114" +version = "0.9.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" dependencies = [ "cc", "libc", @@ -3551,53 +2303,10 @@ dependencies = [ ] [[package]] -name = "ordered-float" -version = "2.10.1" +name = "overload" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown 0.14.5", -] - -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "parking" @@ -3607,9 +2316,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.5" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", @@ -3617,15 +2326,24 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.12" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-targets 0.52.6", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", ] [[package]] @@ -3635,31 +2353,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core 0.6.4", + "rand_core", "subtle", ] [[package]] -name = "pastey" -version = "0.1.1" +name = "paste" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - -[[package]] -name = "pastey" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", -] +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pear" @@ -3686,44 +2388,36 @@ dependencies = [ [[package]] name = "pem" -version = "3.0.6" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" dependencies = [ "base64 0.22.1", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", + "serde", ] [[package]] name = "percent-encoding" -version = "2.3.2" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pest" -version = "2.8.6" +version = "2.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "879952a81a83930934cbf1786752d6dedc3b1f29e8f8fb2ad1d0a36f377cf442" dependencies = [ "memchr", + "thiserror", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "d214365f632b123a47fd913301e14c946c61d1c183ee245fa76eb752e59a02dd" dependencies = [ "pest", "pest_generator", @@ -3731,9 +2425,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "eb55586734301717aea2ac313f50b2eb8f60d2fc3dc01d190eefa2e625f60c4e" dependencies = [ "pest", "pest_meta", @@ -3744,70 +2438,49 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "b75da2a70cf4d9cb76833c990ac9cd3923c9a8905a8929789ce347c84564d03d" dependencies = [ + "once_cell", "pest", - "sha2 0.10.9", + "sha2", ] [[package]] name = "phf" -version = "0.11.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ - "phf_macros", - "phf_shared 0.11.3", + "phf_shared", ] [[package]] -name = "phf" -version = "0.12.1" +name = "phf_codegen" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" dependencies = [ - "phf_shared 0.12.1", + "phf_generator", + "phf_shared", ] [[package]] name = "phf_generator" -version = "0.11.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.6", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn", + "phf_shared", + "rand", ] [[package]] name = "phf_shared" -version = "0.11.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "phf_shared" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" dependencies = [ "siphasher", ] @@ -3820,9 +2493,9 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pin-project-lite" -version = "0.2.17" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "pin-utils" @@ -3832,96 +2505,41 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" dependencies = [ "atomic-waker", "fastrand", "futures-io", ] -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs5" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" -dependencies = [ - "aes", - "cbc", - "der", - "pbkdf2", - "scrypt", - "sha2 0.10.9", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "pkcs5", - "rand_core 0.6.4", - "spki", -] - [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "polling" -version = "3.11.0" +version = "3.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +checksum = "cc2790cd301dec6cd3b7a025e4815cf825724a51c98dccfe6a3e55f05ffb6511" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.4.0", "pin-project-lite", "rustix", - "windows-sys 0.61.2", + "tracing", + "windows-sys 0.59.0", ] [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] +checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" [[package]] name = "powerfmt" @@ -3931,59 +2549,27 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.21" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ "zerocopy", ] [[package]] name = "pq-sys" -version = "0.7.5" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "574ddd6a267294433f140b02a726b0640c43cf7c6f717084684aaa3b285aba61" +checksum = "f6cc05d7ea95200187117196eee9edd0644424911821aeb28a18ce60ea0b8793" dependencies = [ - "libc", - "pkg-config", "vcpkg", ] -[[package]] -name = "prefix-trie" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23370be78b7e5bcbb0cab4a02047eb040279a693c78daad04c2c5f1c24a83503" -dependencies = [ - "either", - "ipnet", - "num-traits", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" dependencies = [ "unicode-ident", ] @@ -4008,20 +2594,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" [[package]] -name = "publicsuffix" -version = "2.3.0" +name = "psm" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +checksum = "aa37f80ca58604976033fae9515a8a2989fc13797d953f7c04fb8fa36a11f205" dependencies = [ - "idna", + "cc", +] + +[[package]] +name = "publicsuffix" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a8c1bda5ae1af7f99a2962e49df150414a43d62404644d98dd5c3a93d07457" +dependencies = [ + "idna 0.3.0", "psl-types", ] [[package]] name = "quanta" -version = "0.12.6" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +checksum = "8e5167a477619228a0b284fac2674e3c388cba90631d7b7de620e6f1fcd08da5" dependencies = [ "crossbeam-utils", "libc", @@ -4034,111 +2629,24 @@ dependencies = [ [[package]] name = "quick-error" -version = "2.0.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls 0.23.40", - "socket2 0.6.3", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash", - "rustls 0.23.40", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.3", - "tracing", - "windows-sys 0.60.2", -] +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.45" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] [[package]] name = "quoted_printable" -version = "0.5.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" [[package]] name = "r2d2" @@ -4153,34 +2661,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.6" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", + "rand_chacha", + "rand_core", ] [[package]] @@ -4190,17 +2677,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -4209,56 +2686,41 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.17", + "getrandom", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "raw-cpuid" -version = "11.6.0" +version = "11.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +checksum = "1ab240315c661615f2ee9f0f2cd32d5a7343a84d5ebcccb99d46e6637565e7b0" dependencies = [ - "bitflags", + "bitflags 2.6.0", ] [[package]] name = "redox_syscall" -version = "0.5.18" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ - "bitflags", + "bitflags 2.6.0", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "ccf0a6f84d5f1d581da8b41b47ec8600871962f2a528115b542b362d4b744931" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "bcc303e793d3734489387d205e9b186fac9c6cfacedd98cbb2e8a5943595f3e6" dependencies = [ "proc-macro2", "quote", @@ -4267,38 +2729,47 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.8", + "regex-syntax 0.8.5", ] [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "regex-syntax 0.6.29", ] [[package]] -name = "regex-lite" -version = "0.1.9" +name = "regex-automata" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.6.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reopen" @@ -4312,131 +2783,137 @@ dependencies = [ ] [[package]] -name = "reqsign" -version = "0.16.5" +name = "reqwest" +version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43451dbf3590a7590684c25fb8d12ecdcc90ed3ac123433e500447c7d77ed701" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "anyhow", - "async-trait", - "base64 0.22.1", - "chrono", - "form_urlencoded", - "getrandom 0.2.17", - "hex", - "hmac 0.12.1", - "home", - "http 1.4.0", - "jsonwebtoken 9.3.1", + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.31", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", "log", + "mime", + "native-tls", "once_cell", "percent-encoding", - "quick-xml 0.37.5", - "rand 0.8.6", - "reqwest", - "rsa", - "rust-ini", + "pin-project-lite", + "rustls-pemfile 1.0.4", "serde", "serde_json", - "sha1", - "sha2 0.10.9", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", "tokio", - "toml 0.8.23", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", ] [[package]] name = "reqwest" -version = "0.12.28" +version = "0.12.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b" dependencies = [ + "async-compression", "base64 0.22.1", "bytes", "cookie", "cookie_store", "encoding_rs", - "futures-channel", "futures-core", "futures-util", - "h2", - "http 1.4.0", + "h2 0.4.6", + "http 1.1.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.5.0", "hyper-rustls", + "hyper-tls 0.6.0", "hyper-util", + "ipnet", "js-sys", "log", "mime", + "native-tls", + "once_cell", "percent-encoding", "pin-project-lite", - "quinn", - "rustls 0.23.40", - "rustls-native-certs", - "rustls-pki-types", + "rustls-pemfile 2.2.0", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.1", + "system-configuration 0.6.1", "tokio", - "tokio-rustls 0.26.4", + "tokio-native-tls", + "tokio-socks", "tokio-util", - "tower", - "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "windows-registry", ] [[package]] name = "resolv-conf" -version = "0.7.6" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00" dependencies = [ - "hmac 0.12.1", - "subtle", + "hostname 0.3.1", + "quick-error", ] [[package]] name = "ring" -version = "0.17.14" +version = "0.17.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.17", + "getrandom", "libc", + "spin", "untrusted", "windows-sys 0.52.0", ] [[package]] name = "rmp" -version = "0.8.15" +version = "0.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" dependencies = [ + "byteorder", "num-traits", + "paste", ] [[package]] name = "rmpv" -version = "1.3.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +checksum = "58450723cd9ee93273ce44a20b6ec4efe17f8ed2e3631474387bfdecf18bb2a9" dependencies = [ + "num-traits", "rmp", ] @@ -4454,14 +2931,14 @@ dependencies = [ "either", "figment", "futures", - "indexmap 2.14.0", + "indexmap", "log", "memchr", "multer", "num_cpus", "parking_lot", "pin-project-lite", - "rand 0.8.6", + "rand", "ref-cast", "rocket_codegen", "rocket_http", @@ -4486,7 +2963,7 @@ checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" dependencies = [ "devise", "glob", - "indexmap 2.14.0", + "indexmap", "proc-macro2", "quote", "rocket_http", @@ -4505,8 +2982,8 @@ dependencies = [ "either", "futures", "http 0.2.12", - "hyper 0.14.32", - "indexmap 2.14.0", + "hyper 0.14.31", + "indexmap", "log", "memchr", "pear", @@ -4514,7 +2991,7 @@ dependencies = [ "pin-project-lite", "ref-cast", "rustls 0.21.12", - "rustls-pemfile", + "rustls-pemfile 1.0.4", "serde", "smallvec", "stable-pattern", @@ -4537,101 +3014,42 @@ dependencies = [ [[package]] name = "rpassword" -version = "7.5.1" +version = "7.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2501c67132bd19c3005b0111fba298907ef002c8c1cf68e25634707e38bf66fe" +checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" dependencies = [ "libc", "rtoolbox", - "windows-sys 0.61.2", -] - -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid 0.9.6", - "digest 0.10.7", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "sha2 0.10.9", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rsqlite-vfs" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.18", + "windows-sys 0.48.0", ] [[package]] name = "rtoolbox" -version = "0.0.5" +version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] -name = "rust-ini" -version = "0.21.3" +name = "rustc-demangle" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom 7.1.3", -] +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "1.1.4" +version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ - "bitflags", + "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4648,31 +3066,17 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "5fbb44d7acc4e873d613422379f69f237a1b141928c02f6bc6ccfddddc2d7993" dependencies = [ - "log", "once_cell", - "ring", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki 0.102.8", "subtle", "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -4683,15 +3087,20 @@ dependencies = [ ] [[package]] -name = "rustls-pki-types" -version = "1.14.1" +name = "rustls-pemfile" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" dependencies = [ - "web-time", - "zeroize", + "rustls-pki-types", ] +[[package]] +name = "rustls-pki-types" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -4704,9 +3113,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ "ring", "rustls-pki-types", @@ -4715,24 +3124,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" [[package]] name = "ryu" -version = "1.0.23" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "salsa20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" -dependencies = [ - "cipher", -] +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "same-file" @@ -4745,11 +3145,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "01227be5826fa0690321a2ba6c5cd57a19cf3f6a09e76973b58e61de6ab9d1c1" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4761,30 +3161,6 @@ dependencies = [ "parking_lot", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "scoped-tls" version = "1.0.1" @@ -4797,17 +3173,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scrypt" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "pbkdf2", - "salsa20", - "sha2 0.10.9", -] - [[package]] name = "sct" version = "0.7.1" @@ -4818,28 +3183,14 @@ dependencies = [ "untrusted", ] -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "security-framework" -version = "3.7.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", - "core-foundation 0.10.1", + "bitflags 2.6.0", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -4847,9 +3198,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.17.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" dependencies = [ "core-foundation-sys", "libc", @@ -4857,54 +3208,34 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.28" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.213" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1" dependencies = [ - "serde_core", "serde_derive", ] [[package]] -name = "serde-value" -version = "0.7.0" +name = "serde_cbor" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" -dependencies = [ - "ordered-float", - "serde", -] - -[[package]] -name = "serde_cbor_2" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" dependencies = [ "half", "serde", ] -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.213" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5" dependencies = [ "proc-macro2", "quote", @@ -4913,55 +3244,25 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" dependencies = [ "itoa", "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_plain" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" -dependencies = [ + "ryu", "serde", ] [[package]] name = "serde_spanned" -version = "0.6.9" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" dependencies = [ "serde", ] -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4974,37 +3275,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_with" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "sha1" version = "0.10.6" @@ -5012,30 +3282,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", + "cpufeatures", + "digest", ] [[package]] name = "sha2" -version = "0.10.9" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.2", + "cpufeatures", + "digest", ] [[package]] @@ -5055,9 +3314,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook" -version = "0.3.18" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" dependencies = [ "libc", "signal-hook-registry", @@ -5065,94 +3324,54 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.8" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ - "errno", "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "simple_asn1" -version = "0.6.4" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.18", + "thiserror", "time", ] [[package]] name = "siphasher" -version = "1.0.2" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "slab" -version = "0.4.12" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" dependencies = [ - "libc", - "windows-sys 0.52.0", + "autocfg", ] [[package]] -name = "socket2" -version = "0.6.3" +name = "smallvec" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5170,28 +3389,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlite-wasm-rs" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - [[package]] name = "stable-pattern" version = "0.1.0" @@ -5203,9 +3400,22 @@ dependencies = [ [[package]] name = "stable_deref_trait" -version = "1.2.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "stacker" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799c883d55abdb5e98af1a7b3f23b9b6de8ecada0ecac058672d7635eb48ca7b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] [[package]] name = "state" @@ -5228,24 +3438,11 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "svg-hush" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "929223e80cdcec0482207576ea09692dd71b2b559057fc172e292ecec9a97559" -dependencies = [ - "base64 0.22.1", - "data-url", - "quick-error", - "url", - "xml", -] - [[package]] name = "syn" -version = "2.0.117" +version = "2.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" dependencies = [ "proc-macro2", "quote", @@ -5254,18 +3451,24 @@ dependencies = [ [[package]] name = "sync_wrapper" -version = "1.0.2" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" dependencies = [ "futures-core", ] [[package]] name = "synstructure" -version = "0.13.2" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", @@ -5274,11 +3477,12 @@ dependencies = [ [[package]] name = "syslog" -version = "7.0.0" +version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "019f1500a13379b7d051455df397c75770de6311a7a188a699499502704d9f10" +checksum = "dfc7e95b5b795122fafe6519e27629b5ab4232c73ebb2428f568e82b1a457ad3" dependencies = [ - "hostname", + "error-chain", + "hostname 0.3.1", "libc", "log", "time", @@ -5286,13 +3490,34 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.7.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.6.0", + "core-foundation", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -5305,59 +3530,33 @@ dependencies = [ "libc", ] -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - [[package]] name = "tempfile" -version = "3.27.0" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" dependencies = [ + "cfg-if", "fastrand", - "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] name = "thiserror" -version = "1.0.69" +version = "1.0.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "1.0.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" dependencies = [ "proc-macro2", "quote", @@ -5366,11 +3565,12 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" dependencies = [ "cfg-if", + "once_cell", ] [[package]] @@ -5384,9 +3584,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", @@ -5394,41 +3594,32 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde_core", + "serde", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ "num-conv", "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" -version = "0.8.3" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ "displaydoc", "zerovec", @@ -5436,9 +3627,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" dependencies = [ "tinyvec_macros", ] @@ -5451,32 +3642,43 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" dependencies = [ + "backtrace", "bytes", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -5489,19 +3691,32 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls 0.23.40", + "rustls 0.23.15", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" dependencies = [ "futures-core", "pin-project-lite", @@ -5522,13 +3737,12 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" dependencies = [ "bytes", "futures-core", - "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -5536,132 +3750,50 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" dependencies = [ "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_spanned", + "toml_datetime", "toml_edit", ] -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "winnow 0.7.15", -] - [[package]] name = "toml_datetime" -version = "0.6.11" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ - "indexmap 2.14.0", + "indexmap", "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", + "serde_spanned", + "toml_datetime", + "winnow", ] -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.2", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "totp-lite" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8e43134db17199f7f721803383ac5854edd0d3d523cc34dba321d6acfbe76c3" dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", + "digest", + "hmac", "sha1", - "sha2 0.10.9", + "sha2", ] -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "async-compression", - "bitflags", - "bytes", - "futures-core", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "iri-string", - "pin-project-lite", - "tokio", - "tokio-util", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - [[package]] name = "tower-service" version = "0.3.3" @@ -5670,9 +3802,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.44" +version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ "log", "pin-project-lite", @@ -5682,9 +3814,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.31" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", @@ -5693,9 +3825,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.36" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", "valuable", @@ -5714,14 +3846,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.23" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex-automata", + "regex", "sharded-slab", "smallvec", "thread_local", @@ -5745,21 +3877,21 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.4.0", + "http 1.1.0", "httparse", "log", - "rand 0.8.6", + "rand", "sha1", - "thiserror 1.0.69", + "thiserror", "url", "utf-8", ] [[package]] name = "typenum" -version = "1.20.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "ubyte" @@ -5787,16 +3919,25 @@ dependencies = [ ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "unicode-bidi" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" [[package]] -name = "unicode-segmentation" -version = "1.13.2" +name = "unicode-ident" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] [[package]] name = "unicode-xid" @@ -5812,29 +3953,28 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.8" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", "serde", - "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5843,37 +3983,30 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", + "getrandom", ] [[package]] name = "valuable" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" [[package]] name = "value-bag" -version = "1.12.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2" [[package]] name = "vaultwarden" version = "1.0.0" dependencies = [ - "anyhow", "argon2", - "aws-config", - "aws-credential-types", - "aws-smithy-runtime-api", "bigdecimal", "bytes", "cached", @@ -5884,40 +4017,33 @@ dependencies = [ "dashmap", "data-encoding", "data-url", - "derive_more", "diesel", - "diesel-derive-newtype", + "diesel_logger", "diesel_migrations", "dotenvy", "email_address", "fern", "futures", "governor", - "grass_compiler", "handlebars", "hickory-resolver", "html5gum", - "http 1.4.0", "job_scheduler_ng", - "jsonwebtoken 10.3.0", + "jsonwebtoken", "lettre", "libsqlite3-sys", "log", - "macros", "mimalloc", - "moka", "num-derive", "num-traits", - "opendal", - "openidconnect", + "once_cell", "openssl", - "pastey 0.2.2", + "paste", "percent-encoding", "pico-args", - "rand 0.10.1", + "rand", "regex", - "reqsign", - "reqwest", + "reqwest 0.12.8", "ring", "rmpv", "rocket", @@ -5926,21 +4052,16 @@ dependencies = [ "semver", "serde", "serde_json", - "subtle", - "svg-hush", "syslog", "time", "tokio", - "tokio-util", "totp-lite", "tracing", "url", "uuid", "webauthn-rs", - "webauthn-rs-core", - "webauthn-rs-proto", "which", - "yubico_ng", + "yubico", ] [[package]] @@ -5955,12 +4076,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - [[package]] name = "walkdir" version = "2.5.0" @@ -5982,110 +4097,82 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" dependencies = [ "cfg-if", "once_cell", - "rustversion", "wasm-bindgen-macro", - "wasm-bindgen-shared", ] [[package]] -name = "wasm-bindgen-futures" -version = "0.4.70" +name = "wasm-bindgen-backend" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.120" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.120" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" dependencies = [ "bumpalo", + "log", + "once_cell", "proc-macro2", "quote", "syn", "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "4e072d4e72f700fb3443d8fe94a39315df013eef1104903cdb0a2abd322bbecd" dependencies = [ "futures-util", "js-sys", @@ -6094,23 +4181,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" dependencies = [ "js-sys", "wasm-bindgen", @@ -6126,97 +4201,42 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webauthn-attestation-ca" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" -dependencies = [ - "base64urlsafedata", - "openssl", - "openssl-sys", - "serde", - "tracing", - "uuid", -] - [[package]] name = "webauthn-rs" -version = "0.5.5" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" +checksum = "90b266eccb4b32595876f5c73ea443b0516da0b1df72ca07bc08ed9ba7f96ec1" dependencies = [ - "base64urlsafedata", - "serde", - "tracing", - "url", - "uuid", - "webauthn-rs-core", -] - -[[package]] -name = "webauthn-rs-core" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" -dependencies = [ - "base64 0.21.7", - "base64urlsafedata", - "der-parser", - "hex", - "nom 7.1.3", + "base64 0.13.1", + "nom", "openssl", - "openssl-sys", - "rand 0.9.4", - "rand_chacha 0.9.0", + "rand", "serde", - "serde_cbor_2", + "serde_cbor", + "serde_derive", "serde_json", - "thiserror 1.0.69", + "thiserror", "tracing", "url", - "uuid", - "webauthn-attestation-ca", - "webauthn-rs-proto", - "x509-parser", -] - -[[package]] -name = "webauthn-rs-proto" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" -dependencies = [ - "base64 0.21.7", - "base64urlsafedata", - "serde", - "serde_json", - "url", -] - -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" -dependencies = [ - "rustls-pki-types", ] [[package]] name = "which" -version = "8.0.2" +version = "6.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" dependencies = [ - "libc", + "either", + "home", + "rustix", + "winsafe", ] [[package]] name = "widestring" -version = "1.2.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" [[package]] name = "winapi" @@ -6236,11 +4256,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.11" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6258,74 +4278,62 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" -version = "0.62.2" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-targets 0.52.6", ] -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - [[package]] name = "windows-registry" -version = "0.6.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ - "windows-link", "windows-result", "windows-strings", + "windows-targets 0.52.6", ] [[package]] name = "windows-result" -version = "0.4.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" dependencies = [ - "windows-link", + "windows-targets 0.52.6", ] [[package]] name = "windows-strings" -version = "0.5.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-link", + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", ] [[package]] @@ -6346,24 +4354,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -6388,30 +4378,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -6424,12 +4397,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -6442,12 +4409,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -6460,24 +4421,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -6490,12 +4439,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -6508,12 +4451,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -6526,12 +4463,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -6544,164 +4475,42 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" -version = "0.6.26" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] [[package]] -name = "winnow" -version = "0.7.15" +name = "winreg" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ - "memchr", + "cfg-if", + "windows-sys 0.48.0", ] [[package]] -name = "winnow" -version = "1.0.2" +name = "winsafe" +version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] -name = "wit-bindgen" -version = "0.51.0" +name = "write16" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" [[package]] name = "writeable" -version = "0.6.3" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "x509-parser" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom 7.1.3", - "oid-registry", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "xml" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8aa498d22c9bbaf482329839bc5620c46be275a19a812e9a22a2b07529a642a" - -[[package]] -name = "xmlparser" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "yansi" @@ -6714,10 +4523,11 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" dependencies = [ + "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -6725,9 +4535,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" dependencies = [ "proc-macro2", "quote", @@ -6736,35 +4546,36 @@ dependencies = [ ] [[package]] -name = "yubico_ng" -version = "0.14.1" +name = "yubico" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "929981f5b46b8fb8ee54b144de6b55c3a94fbe26635ee25b0e126e184250867c" +checksum = "173f75d2c4010429a2d74ae3a114a69930c59e2b1a4c97b1c75d259a4960d5fb" dependencies = [ - "base64 0.22.1", + "base64 0.13.1", "form_urlencoded", "futures", - "hmac 0.12.1", - "rand 0.9.4", - "reqwest", + "hmac", + "rand", + "reqwest 0.11.27", "sha1", "threadpool", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ + "byteorder", "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", @@ -6773,18 +4584,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" dependencies = [ "proc-macro2", "quote", @@ -6794,26 +4605,15 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" [[package]] name = "zerovec" -version = "0.11.6" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" dependencies = [ "yoke", "zerofrom", @@ -6822,45 +4622,11 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", "syn", ] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/Cargo.toml b/Cargo.toml index e7fd5ade..934e0321 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,49 +1,34 @@ -[workspace.package] -edition = "2021" -rust-version = "1.93.0" -license = "AGPL-3.0-only" -repository = "https://github.com/dani-garcia/vaultwarden" -publish = false - -[workspace] -members = ["macros"] - [package] name = "vaultwarden" version = "1.0.0" authors = ["Daniel García "] -readme = "README.md" -build = "build.rs" +edition = "2021" +rust-version = "1.80.0" resolver = "2" -repository.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -publish.workspace = true + +repository = "https://github.com/dani-garcia/vaultwarden" +readme = "README.md" +license = "AGPL-3.0-only" +publish = false +build = "build.rs" [features] -default = [ - # "sqlite" or "sqlite_system", - # "mysql", - # "postgresql", -] +# default = ["sqlite"] # Empty to keep compatibility, prefer to set USE_SYSLOG=true enable_syslog = [] -# Please enable at least one of these DB backends. mysql = ["diesel/mysql", "diesel_migrations/mysql"] postgresql = ["diesel/postgres", "diesel_migrations/postgres"] -sqlite_system = ["diesel/sqlite", "diesel_migrations/sqlite"] -sqlite = ["sqlite_system", "libsqlite3-sys/bundled"] # Alternative to the above, statically linked SQLite into the binary instead of dynamically. +sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "dep:libsqlite3-sys"] # Enable to use a vendored and statically linked openssl vendored_openssl = ["openssl/vendored"] # Enable MiMalloc memory allocator to replace the default malloc # This can improve performance for Alpine builds enable_mimalloc = ["dep:mimalloc"] -s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:anyhow", "dep:http", "dep:reqsign"] - -# OIDC specific features -oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"] -oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"] +# This is a development dependency, and should only be used during development! +# It enables the usage of the diesel_logger crate, which is able to output the generated queries. +# You also need to set an env variable `QUERY_LOGGER=1` to fully activate this so you do not have to re-compile +# if you want to turn off the logging for a specific run. +query_logger = ["dep:diesel_logger"] # Enable unstable features, requires nightly # Currently only used to enable rusts official ip support @@ -51,175 +36,157 @@ unstable = [] [target."cfg(unix)".dependencies] # Logging -syslog = "7.0.0" +syslog = "6.1.1" [dependencies] -macros = { path = "./macros" } - # Logging -log = "0.4.29" -fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] } -tracing = { version = "0.1.44", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work +log = "0.4.22" +fern = { version = "0.7.0", features = ["syslog-6", "reopen-1"] } +tracing = { version = "0.1.40", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work # A `dotenv` implementation for Rust dotenvy = { version = "0.15.7", default-features = false } +# Lazy initialization +once_cell = "1.20.2" + # Numerical libraries num-traits = "0.2.19" num-derive = "0.4.2" -bigdecimal = "0.4.10" +bigdecimal = "0.4.5" # Web framework rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false } rocket_ws = { version ="0.1.1" } # WebSockets libraries -rmpv = "1.3.1" # MessagePack library +rmpv = "1.3.0" # MessagePack library # Concurrent HashMap used for WebSocket messaging and favicons dashmap = "6.1.0" # Async futures -futures = "0.3.32" -tokio = { version = "1.52.1", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] } -tokio-util = { version = "0.7.18", features = ["compat"]} +futures = "0.3.31" +tokio = { version = "1.41.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] } # A generic serialization/deserialization framework -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" +serde = { version = "1.0.213", features = ["derive"] } +serde_json = "1.0.132" # A safe, extensible ORM and Query builder -# Currently pinned diesel to v2.3.3 as newer version break MySQL/MariaDB compatibility -diesel = { version = "2.3.9", features = ["chrono", "r2d2", "numeric"] } -diesel_migrations = "2.3.2" +diesel = { version = "2.2.4", features = ["chrono", "r2d2", "numeric"] } +diesel_migrations = "2.2.0" +diesel_logger = { version = "0.3.0", optional = true } -derive_more = { version = "2.1.1", features = ["from", "into", "as_ref", "deref", "display"] } -diesel-derive-newtype = "2.1.2" - -# SQLite, statically bundled unless the `sqlite_system` feature is enabled -libsqlite3-sys = { version = "0.37.0", optional = true } +# Bundled/Static SQLite +libsqlite3-sys = { version = "0.30.1", features = ["bundled"], optional = true } # Crypto-related libraries -rand = "0.10.1" -ring = "0.17.14" -subtle = "2.6.1" +rand = { version = "0.8.5", features = ["small_rng"] } +ring = "0.17.8" # UUID generation -uuid = { version = "1.23.1", features = ["v4"] } +uuid = { version = "1.11.0", features = ["v4"] } # Date and time libraries -chrono = { version = "0.4.44", features = ["clock", "serde"], default-features = false } -chrono-tz = "0.10.4" -time = "0.3.47" +chrono = { version = "0.4.38", features = ["clock", "serde"], default-features = false } +chrono-tz = "0.10.0" +time = "0.3.36" # Job scheduler -job_scheduler_ng = "2.4.0" +job_scheduler_ng = "2.0.5" # Data encoding library Hex/Base32/Base64 -data-encoding = "2.11.0" +data-encoding = "2.6.0" # JWT library -jsonwebtoken = { version = "10.3.0", features = ["use_pem", "rust_crypto"], default-features = false } +jsonwebtoken = "9.3.0" # TOTP library totp-lite = "2.0.1" # Yubico Library -yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"], default-features = false } +yubico = { version = "0.11.0", features = ["online-tokio"], default-features = false } # WebAuthn libraries -# danger-allow-state-serialisation is needed to save the state in the db -# danger-credential-internals is needed to support U2F to Webauthn migration -webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } -webauthn-rs-proto = "0.5.5" -webauthn-rs-core = "0.5.5" +webauthn-rs = "0.3.2" # Handling of URL's for WebAuthn and favicons -url = "2.5.8" +url = "2.5.2" # Email libraries -lettre = { version = "0.11.21", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false } -percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails +lettre = { version = "0.11.10", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "tokio1-native-tls", "hostname", "tracing", "tokio1"], default-features = false } +percent-encoding = "2.3.1" # URL encoding library used for URL's in the emails email_address = "0.2.9" # HTML Template library -handlebars = { version = "6.4.0", features = ["dir_source"] } +handlebars = { version = "6.1.0", features = ["dir_source"] } # HTTP client (Used for favicons, version check, DUO and HIBP API) -reqwest = { version = "0.12.28", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false} -hickory-resolver = "0.26.1" +reqwest = { version = "0.12.8", features = ["native-tls-alpn", "stream", "json", "gzip", "brotli", "socks", "cookies"] } +hickory-resolver = "0.24.1" # Favicon extraction libraries -html5gum = "0.8.3" -regex = { version = "1.12.3", features = ["std", "perf", "unicode-perl"], default-features = false } -data-url = "0.3.2" -bytes = "1.11.1" -svg-hush = "0.9.6" +html5gum = "0.5.7" +regex = { version = "1.11.0", features = ["std", "perf", "unicode-perl"], default-features = false } +data-url = "0.3.1" +bytes = "1.8.0" # Cache function results (Used for version check and favicon fetching) -cached = { version = "0.59.0", features = ["async"] } +cached = { version = "0.53.1", features = ["async"] } # Used for custom short lived cookie jar during favicon extraction cookie = "0.18.1" -cookie_store = "0.22.1" +cookie_store = "0.21.0" # Used by U2F, JWT and PostgreSQL -openssl = "0.10.78" +openssl = "0.10.68" # CLI argument parsing pico-args = "0.5.0" # Macro ident concatenation -pastey = "0.2.2" -governor = "0.10.4" - -# OIDC for SSO -openidconnect = { version = "4.0.1", features = ["reqwest", "rustls-tls"] } -moka = { version = "0.12.15", features = ["future"] } +paste = "1.0.15" +governor = "0.7.0" # Check client versions for specific features. -semver = "1.0.28" +semver = "1.0.23" # Allow overriding the default memory allocator # Mainly used for the musl builds, since the default musl malloc is very slow -mimalloc = { version = "0.1.50", features = ["secure"], default-features = false, optional = true } - -which = "8.0.2" +mimalloc = { version = "0.1.43", features = ["secure"], default-features = false, optional = true } +which = "6.0.3" # Argon2 library with support for the PHC format argon2 = "0.5.3" # Reading a password from the cli for generating the Argon2id ADMIN_TOKEN -rpassword = "7.5.1" - -# Loading a dynamic CSS Stylesheet -grass_compiler = { version = "0.13.4", default-features = false } - -# File are accessed through Apache OpenDAL -opendal = { version = "0.55.0", features = ["services-fs"], default-features = false } - -# For retrieving AWS credentials, including temporary SSO credentials -anyhow = { version = "1.0.102", optional = true } -aws-config = { version = "1.8.16", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true } -aws-credential-types = { version = "1.2.14", optional = true } -aws-smithy-runtime-api = { version = "1.12.0", optional = true } -http = { version = "1.4.0", optional = true } -reqsign = { version = "0.16.5", optional = true } +rpassword = "7.3.1" # Strip debuginfo from the release builds -# The debug symbols are to provide better panic traces +# The symbols are the provide better panic traces # Also enable fat LTO and use 1 codegen unit for optimizations [profile.release] strip = "debuginfo" lto = "fat" codegen-units = 1 -debug = false + +# A little bit of a speedup +[profile.dev] +split-debuginfo = "unpacked" + +# Always build argon2 using opt-level 3 +# This is a huge speed improvement during testing +[profile.dev.package.argon2] +opt-level = 3 # Optimize for size [profile.release-micro] inherits = "release" -strip = "symbols" opt-level = "z" +strip = "symbols" +lto = "fat" +codegen-units = 1 panic = "abort" # Profile for systems with low resources @@ -230,51 +197,23 @@ strip = "symbols" lto = "thin" codegen-units = 16 -# Used for profiling and debugging like valgrind or heaptrack -# Inherits release to be sure all optimizations have been done -[profile.dbg] -inherits = "release" -strip = "none" -split-debuginfo = "off" -debug = "full" - -# A little bit of a speedup for generic building -[profile.dev] -split-debuginfo = "unpacked" -debug = "line-tables-only" - -# Used for CI builds to improve compile time -[profile.ci] -inherits = "dev" -debug = false -debug-assertions = false -strip = "symbols" -panic = "abort" - -# Always build argon2 using opt-level 3 -# This is a huge speed improvement during testing -[profile.dev.package.argon2] -opt-level = 3 - # Linting config # https://doc.rust-lang.org/rustc/lints/groups.html -[workspace.lints.rust] +[lints.rust] # Forbid unsafe_code = "forbid" non_ascii_idents = "forbid" # Deny deprecated_in_future = "deny" -deprecated_safe = { level = "deny", priority = -1 } future_incompatible = { level = "deny", priority = -1 } keyword_idents = { level = "deny", priority = -1 } let_underscore = { level = "deny", priority = -1 } -nonstandard_style = { level = "deny", priority = -1 } noop_method_call = "deny" refining_impl_trait = { level = "deny", priority = -1 } rust_2018_idioms = { level = "deny", priority = -1 } rust_2021_compatibility = { level = "deny", priority = -1 } -rust_2024_compatibility = { level = "deny", priority = -1 } +# rust_2024_compatibility = { level = "deny", priority = -1 } # Enable once we are at MSRV 1.81.0 single_use_lifetimes = "deny" trivial_casts = "deny" trivial_numeric_casts = "deny" @@ -283,32 +222,23 @@ unused_import_braces = "deny" unused_lifetimes = "deny" unused_qualifications = "deny" variant_size_differences = "deny" -# Allow the following lints since these cause issues with Rust v1.84.0 or newer -# Building Vaultwarden with Rust v1.85.0 with edition 2024 also works without issues -edition_2024_expr_fragment_specifier = "allow" # Once changed to Rust 2024 this should be removed and macro's should be validated again -if_let_rescope = "allow" -tail_expr_drop_order = "allow" +# The lints below are part of the rust_2024_compatibility group +static-mut-refs = "deny" +unsafe-op-in-unsafe-fn = "deny" # https://rust-lang.github.io/rust-clippy/stable/index.html -[workspace.lints.clippy] +[lints.clippy] # Warn dbg_macro = "warn" todo = "warn" -# Ignore/Allow -result_large_err = "allow" - # Deny -branches_sharing_code = "deny" case_sensitive_file_extension_comparisons = "deny" cast_lossless = "deny" clone_on_ref_ptr = "deny" -duration_suboptimal_units = "deny" equatable_if_let = "deny" -excessive_precision = "deny" filter_map_next = "deny" float_cmp_const = "deny" -implicit_clone = "deny" inefficient_to_string = "deny" iter_on_empty_collections = "deny" iter_on_single_items = "deny" @@ -317,24 +247,18 @@ macro_use_imports = "deny" manual_assert = "deny" manual_instant_elapsed = "deny" manual_string_new = "deny" +match_on_vec_items = "deny" match_wildcard_for_single_variants = "deny" mem_forget = "deny" -needless_borrow = "deny" -needless_collect = "deny" needless_continue = "deny" needless_lifetimes = "deny" option_option = "deny" -redundant_clone = "deny" -ref_option = "deny" string_add_assign = "deny" +string_to_string = "deny" unnecessary_join = "deny" unnecessary_self_imports = "deny" unnested_or_patterns = "deny" unused_async = "deny" unused_self = "deny" -useless_let_if_seq = "deny" verbose_file_reads = "deny" zero_sized_map_values = "deny" - -[lints] -workspace = true diff --git a/README.md b/README.md index 0b24ba69..6efbd73d 100644 --- a/README.md +++ b/README.md @@ -59,22 +59,19 @@ A nearly complete implementation of the Bitwarden Client API is provided, includ ## Usage > [!IMPORTANT] -> The web-vault requires the use of HTTPS and a secure context for the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API).
-> That means it will only work if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS).
-> We also suggest to use a [reverse proxy](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples). - -The recommended way to install and use Vaultwarden is via our container images which are published to [ghcr.io](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden), [docker.io](https://hub.docker.com/r/vaultwarden/server) and [quay.io](https://quay.io/repository/vaultwarden/server). -See [which container image to use](https://github.com/dani-garcia/vaultwarden/wiki/Which-container-image-to-use) for an explanation of the provided tags. - -There are also [community driven packages](https://github.com/dani-garcia/vaultwarden/wiki/Third-party-packages) which can be used, but those might be lagging behind the latest version or might deviate in the way Vaultwarden is configured, as described in our [Wiki](https://github.com/dani-garcia/vaultwarden/wiki). - -Alternatively, you can also [build Vaultwarden](https://github.com/dani-garcia/vaultwarden/wiki/Building-binary) yourself. - -While Vaultwarden is based upon the [Rocket web framework](https://rocket.rs) which has built-in support for TLS our recommendation would be that you setup a reverse proxy (see [proxy examples](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples)). +> Most modern web browsers disallow the use of Web Crypto APIs in insecure contexts. In this case, you might get an error like `Cannot read property 'importKey'`. To solve this problem, you need to access the web vault via HTTPS or localhost. +> +>This can be configured in [Vaultwarden directly](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS) or using a third-party reverse proxy ([some examples](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples)). +> +>If you have an available domain name, you can get HTTPS certificates with [Let's Encrypt](https://letsencrypt.org/), or you can generate self-signed certificates with utilities like [mkcert](https://github.com/FiloSottile/mkcert). Some proxies automatically do this step, like Caddy or Traefik (see examples linked above). > [!TIP] >**For more detailed examples on how to install, use and configure Vaultwarden you can check our [Wiki](https://github.com/dani-garcia/vaultwarden/wiki).** +The main way to use Vaultwarden is via our container images which are published to [ghcr.io](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden), [docker.io](https://hub.docker.com/r/vaultwarden/server) and [quay.io](https://quay.io/repository/vaultwarden/server). + +There are also [community driven packages](https://github.com/dani-garcia/vaultwarden/wiki/Third-party-packages) which can be used, but those might be lagging behind the latest version or might deviate in the way Vaultwarden is configured, as described in our [Wiki](https://github.com/dani-garcia/vaultwarden/wiki). + ### Docker/Podman CLI Pull the container image and mount a volume from the host for persistent storage.
@@ -86,7 +83,7 @@ docker run --detach --name vaultwarden \ --env DOMAIN="https://vw.domain.tld" \ --volume /vw-data/:/data/ \ --restart unless-stopped \ - --publish 127.0.0.1:8000:80 \ + --publish 80:80 \ vaultwarden/server:latest ``` @@ -107,7 +104,7 @@ services: volumes: - ./vw-data/:/data/ ports: - - 127.0.0.1:8000:80 + - 80:80 ```
diff --git a/SECURITY.md b/SECURITY.md index 4d23e51c..0917981c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,7 +21,7 @@ notify us. We welcome working with you to resolve the issue promptly. Thanks in The following bug classes are out-of scope: - Bugs that are already reported on Vaultwarden's issue tracker (https://github.com/dani-garcia/vaultwarden/issues) -- Bugs that are not part of Vaultwarden, like on the web-vault or mobile and desktop clients. These issues need to be reported in the respective project issue tracker at https://github.com/bitwarden to which we are not associated +- Bugs that are not part of Vaultwarden, like on the the web-vault or mobile and desktop clients. These issues need to be reported in the respective project issue tracker at https://github.com/bitwarden to which we are not associated - Issues in an upstream software dependency (ex: Rust, or External Libraries) which are already reported to the upstream maintainer - Attacks requiring physical access to a user's device - Issues related to software or protocols not under Vaultwarden's control diff --git a/build.rs b/build.rs index 2d1106c2..07bd99a7 100644 --- a/build.rs +++ b/build.rs @@ -2,27 +2,27 @@ use std::env; use std::process::Command; fn main() { - // These allow using e.g. #[cfg(mysql)] instead of #[cfg(feature = "mysql")], which helps when trying to add them through macros - #[cfg(feature = "sqlite_system")] // The `sqlite` feature implies this one. + // This allow using #[cfg(sqlite)] instead of #[cfg(feature = "sqlite")], which helps when trying to add them through macros + #[cfg(feature = "sqlite")] println!("cargo:rustc-cfg=sqlite"); #[cfg(feature = "mysql")] println!("cargo:rustc-cfg=mysql"); #[cfg(feature = "postgresql")] println!("cargo:rustc-cfg=postgresql"); - #[cfg(not(any(feature = "sqlite_system", feature = "mysql", feature = "postgresql")))] + #[cfg(feature = "query_logger")] + println!("cargo:rustc-cfg=query_logger"); + + #[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))] compile_error!( "You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite" ); - #[cfg(feature = "s3")] - println!("cargo:rustc-cfg=s3"); - // Use check-cfg to let cargo know which cfg's we define, // and avoid warnings when they are used in the code. println!("cargo::rustc-check-cfg=cfg(sqlite)"); println!("cargo::rustc-check-cfg=cfg(mysql)"); println!("cargo::rustc-check-cfg=cfg(postgresql)"); - println!("cargo::rustc-check-cfg=cfg(s3)"); + println!("cargo::rustc-check-cfg=cfg(query_logger)"); // Rerun when these paths are changed. // Someone could have checked-out a tag or specific commit, but no other files changed. @@ -31,6 +31,9 @@ fn main() { println!("cargo:rerun-if-changed=.git/index"); println!("cargo:rerun-if-changed=.git/refs/tags"); + #[cfg(all(not(debug_assertions), feature = "query_logger"))] + compile_error!("Query Logging is only allowed during development, it is not intended for production usage!"); + // Support $BWRS_VERSION for legacy compatibility, but default to $VW_VERSION. // If neither exist, read from git. let maybe_vaultwarden_version = @@ -45,8 +48,8 @@ fn main() { fn run(args: &[&str]) -> Result { let out = Command::new(args[0]).args(&args[1..]).output()?; if !out.status.success() { - use std::io::Error; - return Err(Error::other("Command not successful")); + use std::io::{Error, ErrorKind}; + return Err(Error::new(ErrorKind::Other, "Command not successful")); } Ok(String::from_utf8(out.stdout).unwrap().trim().to_string()) } diff --git a/diesel.toml b/diesel.toml index 71215dbf..5a78b550 100644 --- a/diesel.toml +++ b/diesel.toml @@ -2,4 +2,4 @@ # see diesel.rs/guides/configuring-diesel-cli [print_schema] -file = "src/db/schema.rs" +file = "src/db/schema.rs" \ No newline at end of file diff --git a/docker/DockerSettings.yaml b/docker/DockerSettings.yaml index 9d4a563a..c4c541fb 100644 --- a/docker/DockerSettings.yaml +++ b/docker/DockerSettings.yaml @@ -1,13 +1,13 @@ --- -vault_version: "v2026.4.1" -vault_image_digest: "sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe" -# Cross Compile Docker Helper Scripts v1.9.0 +vault_version: "v2024.6.2c" +vault_image_digest: "sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b" +# Cross Compile Docker Helper Scripts v1.5.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:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707" -rust_version: 1.95.0 # Rust version to be used -debian_version: trixie # Debian release name to be used -alpine_version: "3.23" # Alpine version to be used +xx_image_digest: "sha256:1978e7a58a1777cb0ef0dde76bad60b7914b21da57cfa88047875e4f364297aa" +rust_version: 1.82.0 # Rust version to be used +debian_version: bookworm # Debian release name to be used +alpine_version: "3.20" # Alpine version to be used # For which platforms/architectures will we try to build images platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"] # Determine the build images per OS/Arch @@ -17,6 +17,7 @@ build_stage_image: platform: "$BUILDPLATFORM" alpine: image: "build_${TARGETARCH}${TARGETVARIANT}" + platform: "linux/amd64" # The Alpine build images only have linux/amd64 images arch_image: amd64: "ghcr.io/blackdex/rust-musl:x86_64-musl-stable-{{rust_version}}" arm64: "ghcr.io/blackdex/rust-musl:aarch64-musl-stable-{{rust_version}}" diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine index cbb18e2b..c6c85003 100644 --- a/docker/Dockerfile.alpine +++ b/docker/Dockerfile.alpine @@ -19,27 +19,27 @@ # - 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.4.1 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1 -# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe] +# $ docker pull docker.io/vaultwarden/web-vault:v2024.6.2c +# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2024.6.2c +# [docker.io/vaultwarden/web-vault@sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe -# [docker.io/vaultwarden/web-vault:v2026.4.1] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b +# [docker.io/vaultwarden/web-vault:v2024.6.2c] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b AS vault ########################## ALPINE BUILD IMAGES ########################## -## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 +## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 ## 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.95.0 AS build_amd64 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.95.0 AS build_arm64 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.95.0 AS build_armv7 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.95.0 AS build_armv6 +FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.82.0 AS build_amd64 +FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.82.0 AS build_arm64 +FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.82.0 AS build_armv7 +FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.82.0 AS build_armv6 ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 -FROM --platform=$BUILDPLATFORM build_${TARGETARCH}${TARGETVARIANT} AS build +FROM --platform=linux/amd64 build_${TARGETARCH}${TARGETVARIANT} AS build ARG TARGETARCH ARG TARGETVARIANT ARG TARGETPLATFORM @@ -53,9 +53,9 @@ ENV DEBIAN_FRONTEND=noninteractive \ TERM=xterm-256color \ CARGO_HOME="/root/.cargo" \ USER="root" \ - # Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16 - # Debian Trixie uses libpq v17 - PQ_LIB_DIR="/usr/local/musl/pq17/lib" + # Use PostgreSQL v15 during Alpine/MUSL builds instead of the default v11 + # Debian Bookworm already contains libpq v15 + PQ_LIB_DIR="/usr/local/musl/pq15/lib" # Create CARGO_HOME folder and don't download rust docs @@ -76,7 +76,6 @@ RUN source /env-cargo && \ # Copies over *only* your manifests and build files COPY ./Cargo.* ./rust-toolchain.toml ./build.rs ./ -COPY ./macros ./macros ARG CARGO_PROFILE=release @@ -127,7 +126,7 @@ RUN source /env-cargo && \ # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 -FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.23 +FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.20 ENV ROCKET_PROFILE="release" \ ROCKET_ADDRESS=0.0.0.0 \ diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 829f59d2..eb502eb2 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.4.1 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1 -# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe] +# $ docker pull docker.io/vaultwarden/web-vault:v2024.6.2c +# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2024.6.2c +# [docker.io/vaultwarden/web-vault@sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe -# [docker.io/vaultwarden/web-vault:v2026.4.1] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b +# [docker.io/vaultwarden/web-vault:v2024.6.2c] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b 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:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx +FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:1978e7a58a1777cb0ef0dde76bad60b7914b21da57cfa88047875e4f364297aa AS xx ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 -FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.95.0-slim-trixie AS build +FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.82.0-slim-bookworm AS build COPY --from=xx / / ARG TARGETARCH ARG TARGETVARIANT @@ -51,6 +51,7 @@ ENV DEBIAN_FRONTEND=noninteractive \ TERM=xterm-256color \ CARGO_HOME="/root/.cargo" \ USER="root" + # Install clang to get `xx-cargo` working # Install pkg-config to allow amd64 builds to find all libraries # Install git so build.rs can determine the correct version @@ -67,11 +68,15 @@ RUN apt-get update && \ xx-apt-get install -y \ --no-install-recommends \ gcc \ + libmariadb3 \ libpq-dev \ libpq5 \ libssl-dev \ - libmariadb-dev \ zlib1g-dev && \ + # Force install arch dependend mariadb dev packages + # Installing them the normal way breaks several other packages (again) + apt-get download "libmariadb-dev-compat:$(xx-info debian-arch)" "libmariadb-dev:$(xx-info debian-arch)" && \ + dpkg --force-all -i ./libmariadb-dev*.deb && \ # Run xx-cargo early, since it sometimes seems to break when run at a later stage echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo @@ -84,24 +89,24 @@ RUN USER=root cargo new --bin /app WORKDIR /app # Environment variables for Cargo on Debian based builds -ARG TARGET_PKG_CONFIG_PATH +ARG ARCH_OPENSSL_LIB_DIR \ + ARCH_OPENSSL_INCLUDE_DIR RUN source /env-cargo && \ if xx-info is-cross ; then \ + # Some special variables if needed to override some build paths + if [[ -n "${ARCH_OPENSSL_LIB_DIR}" && -n "${ARCH_OPENSSL_INCLUDE_DIR}" ]]; then \ + echo "export $(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_OPENSSL_LIB_DIR=${ARCH_OPENSSL_LIB_DIR}" >> /env-cargo && \ + echo "export $(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_OPENSSL_INCLUDE_DIR=${ARCH_OPENSSL_INCLUDE_DIR}" >> /env-cargo ; \ + fi && \ # We can't use xx-cargo since that uses clang, which doesn't work for our libraries. # Because of this we generate the needed environment variables here which we can load in the needed steps. echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \ echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \ + echo "export PKG_CONFIG=/usr/bin/$(xx-info)-pkg-config" >> /env-cargo && \ echo "export CROSS_COMPILE=1" >> /env-cargo && \ - echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \ - # For some architectures `xx-info` returns a triple which doesn't matches the path on disk - # In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg - if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \ - echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \ - else \ - echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \ - fi && \ - echo "# End of env-cargo" >> /env-cargo ; \ + echo "export OPENSSL_INCLUDE_DIR=/usr/include/$(xx-info)" >> /env-cargo && \ + echo "export OPENSSL_LIB_DIR=/usr/lib/$(xx-info)" >> /env-cargo ; \ fi && \ # Output the current contents of the file cat /env-cargo @@ -111,7 +116,6 @@ RUN source /env-cargo && \ # Copies over *only* your manifests and build files COPY ./Cargo.* ./rust-toolchain.toml ./build.rs ./ -COPY ./macros ./macros ARG CARGO_PROFILE=release @@ -161,7 +165,7 @@ RUN source /env-cargo && \ # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 -FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim +FROM --platform=$TARGETPLATFORM docker.io/library/debian:bookworm-slim ENV ROCKET_PROFILE="release" \ ROCKET_ADDRESS=0.0.0.0 \ @@ -174,7 +178,7 @@ RUN mkdir /data && \ --no-install-recommends \ ca-certificates \ curl \ - libmariadb3 \ + libmariadb-dev-compat \ libpq5 \ openssl && \ apt-get clean && \ diff --git a/docker/Dockerfile.j2 b/docker/Dockerfile.j2 index f745780e..372be95e 100644 --- a/docker/Dockerfile.j2 +++ b/docker/Dockerfile.j2 @@ -19,13 +19,13 @@ # - 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:{{ vault_version | replace('+', '_') }} -# $ docker image inspect --format "{{ '{{' }}.RepoDigests}}" docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }} +# $ docker pull docker.io/vaultwarden/web-vault:{{ vault_version }} +# $ docker image inspect --format "{{ '{{' }}.RepoDigests}}" docker.io/vaultwarden/web-vault:{{ vault_version }} # [docker.io/vaultwarden/web-vault@{{ vault_image_digest }}] # # - Conversely, to get the tag name from the digest: # $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }} -# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}] +# [docker.io/vaultwarden/web-vault:{{ vault_version }}] # FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault @@ -36,16 +36,16 @@ FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_diges FROM --platform=linux/amd64 docker.io/tonistiigi/xx@{{ xx_image_digest }} AS xx {% elif base == "alpine" %} ########################## ALPINE BUILD IMAGES ########################## -## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 +## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 ## And for Alpine we define all build images here, they will only be loaded when actually used {% for arch in build_stage_image[base].arch_image %} -FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS build_{{ arch }} +FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].arch_image[arch] }} AS build_{{ arch }} {% endfor %} {% endif %} ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 -FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build +FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].image }} AS build {% if base == "debian" %} COPY --from=xx / / {% endif %} @@ -63,12 +63,13 @@ ENV DEBIAN_FRONTEND=noninteractive \ CARGO_HOME="/root/.cargo" \ USER="root" {%- if base == "alpine" %} \ - # Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16 - # Debian Trixie uses libpq v17 - PQ_LIB_DIR="/usr/local/musl/pq17/lib" + # Use PostgreSQL v15 during Alpine/MUSL builds instead of the default v11 + # Debian Bookworm already contains libpq v15 + PQ_LIB_DIR="/usr/local/musl/pq15/lib" {% endif %} {% if base == "debian" %} + # Install clang to get `xx-cargo` working # Install pkg-config to allow amd64 builds to find all libraries # Install git so build.rs can determine the correct version @@ -85,11 +86,15 @@ RUN apt-get update && \ xx-apt-get install -y \ --no-install-recommends \ gcc \ + libmariadb3 \ libpq-dev \ libpq5 \ libssl-dev \ - libmariadb-dev \ zlib1g-dev && \ + # Force install arch dependend mariadb dev packages + # Installing them the normal way breaks several other packages (again) + apt-get download "libmariadb-dev-compat:$(xx-info debian-arch)" "libmariadb-dev:$(xx-info debian-arch)" && \ + dpkg --force-all -i ./libmariadb-dev*.deb && \ # Run xx-cargo early, since it sometimes seems to break when run at a later stage echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo {% endif %} @@ -104,24 +109,24 @@ WORKDIR /app {% if base == "debian" %} # Environment variables for Cargo on Debian based builds -ARG TARGET_PKG_CONFIG_PATH +ARG ARCH_OPENSSL_LIB_DIR \ + ARCH_OPENSSL_INCLUDE_DIR RUN source /env-cargo && \ if xx-info is-cross ; then \ + # Some special variables if needed to override some build paths + if [[ -n "${ARCH_OPENSSL_LIB_DIR}" && -n "${ARCH_OPENSSL_INCLUDE_DIR}" ]]; then \ + echo "export $(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_OPENSSL_LIB_DIR=${ARCH_OPENSSL_LIB_DIR}" >> /env-cargo && \ + echo "export $(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_OPENSSL_INCLUDE_DIR=${ARCH_OPENSSL_INCLUDE_DIR}" >> /env-cargo ; \ + fi && \ # We can't use xx-cargo since that uses clang, which doesn't work for our libraries. # Because of this we generate the needed environment variables here which we can load in the needed steps. echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \ echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \ + echo "export PKG_CONFIG=/usr/bin/$(xx-info)-pkg-config" >> /env-cargo && \ echo "export CROSS_COMPILE=1" >> /env-cargo && \ - echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \ - # For some architectures `xx-info` returns a triple which doesn't matches the path on disk - # In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg - if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \ - echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \ - else \ - echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \ - fi && \ - echo "# End of env-cargo" >> /env-cargo ; \ + echo "export OPENSSL_INCLUDE_DIR=/usr/include/$(xx-info)" >> /env-cargo && \ + echo "export OPENSSL_LIB_DIR=/usr/lib/$(xx-info)" >> /env-cargo ; \ fi && \ # Output the current contents of the file cat /env-cargo @@ -138,7 +143,6 @@ RUN source /env-cargo && \ # Copies over *only* your manifests and build files COPY ./Cargo.* ./rust-toolchain.toml ./build.rs ./ -COPY ./macros ./macros ARG CARGO_PROFILE=release @@ -211,7 +215,7 @@ RUN mkdir /data && \ --no-install-recommends \ ca-certificates \ curl \ - libmariadb3 \ + libmariadb-dev-compat \ libpq5 \ openssl && \ apt-get clean && \ diff --git a/docker/README.md b/docker/README.md index d64d5789..2e78f534 100644 --- a/docker/README.md +++ b/docker/README.md @@ -46,7 +46,7 @@ There also is an option to use an other docker container to provide support for ```bash # To install and activate docker run --privileged --rm tonistiigi/binfmt --install arm64,arm -# To uninstall +# To unistall docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' ``` @@ -116,7 +116,7 @@ docker/bake.sh ``` You can append both `alpine` and `debian` with `-amd64`, `-arm64`, `-armv7` or `-armv6`, which will trigger a build for that specific platform.
-This will also append those values to the tag so you can see the built container when running `docker images`. +This will also append those values to the tag so you can see the builded container when running `docker images`. You can also append extra arguments after the target if you want. This can be useful for example to print what bake will use. ```bash @@ -162,7 +162,7 @@ You can append extra arguments after the target if you want. This can be useful For the podman builds you can, just like the `bake.sh` script, also append the architecture to build for that specific platform.
-### Testing podman built images +### Testing podman builded images The command to start a podman built container is almost the same as for the docker/bake built containers. The images start with `localhost/`, so you need to prepend that. diff --git a/docker/docker-bake.hcl b/docker/docker-bake.hcl index c27c39a6..38e7ef97 100644 --- a/docker/docker-bake.hcl +++ b/docker/docker-bake.hcl @@ -17,7 +17,7 @@ variable "SOURCE_REPOSITORY_URL" { default = null } -// The commit hash of the current commit this build was triggered on +// The commit hash of of the current commit this build was triggered on variable "SOURCE_COMMIT" { default = null } @@ -133,7 +133,8 @@ target "debian-386" { platforms = ["linux/386"] tags = generate_tags("", "-386") args = { - TARGET_PKG_CONFIG_PATH = "/usr/lib/i386-linux-gnu/pkgconfig" + ARCH_OPENSSL_LIB_DIR = "/usr/lib/i386-linux-gnu" + ARCH_OPENSSL_INCLUDE_DIR = "/usr/include/i386-linux-gnu" } } @@ -141,12 +142,20 @@ target "debian-ppc64le" { inherits = ["debian"] platforms = ["linux/ppc64le"] tags = generate_tags("", "-ppc64le") + args = { + ARCH_OPENSSL_LIB_DIR = "/usr/lib/powerpc64le-linux-gnu" + ARCH_OPENSSL_INCLUDE_DIR = "/usr/include/powerpc64le-linux-gnu" + } } target "debian-s390x" { inherits = ["debian"] platforms = ["linux/s390x"] tags = generate_tags("", "-s390x") + args = { + ARCH_OPENSSL_LIB_DIR = "/usr/lib/s390x-linux-gnu" + ARCH_OPENSSL_INCLUDE_DIR = "/usr/include/s390x-linux-gnu" + } } // ==== End of unsupported Debian architecture targets === diff --git a/dylint.toml b/dylint.toml new file mode 100644 index 00000000..021963a9 --- /dev/null +++ b/dylint.toml @@ -0,0 +1,2 @@ +[workspace.metadata.dylint] +libraries = [{ path = "dylints/*" }] diff --git a/dylints/README.md b/dylints/README.md new file mode 100644 index 00000000..996d2a85 --- /dev/null +++ b/dylints/README.md @@ -0,0 +1,7 @@ +# How to run Lints + +```sh +cargo install cargo-dylint dylint-link + +RUSTFLAGS="-Aunreachable_patterns" cargo dylint --all -- --features sqlite +``` \ No newline at end of file diff --git a/dylints/non_authenticated_routes/.cargo/config.toml b/dylints/non_authenticated_routes/.cargo/config.toml new file mode 100644 index 00000000..93dceb69 --- /dev/null +++ b/dylints/non_authenticated_routes/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.'cfg(all())'] +linker = "dylint-link" diff --git a/dylints/non_authenticated_routes/.gitignore b/dylints/non_authenticated_routes/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/dylints/non_authenticated_routes/.gitignore @@ -0,0 +1 @@ +/target diff --git a/dylints/non_authenticated_routes/Cargo.lock b/dylints/non_authenticated_routes/Cargo.lock new file mode 100644 index 00000000..e2501d54 --- /dev/null +++ b/dylints/non_authenticated_routes/Cargo.lock @@ -0,0 +1,1659 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24b1f0365a6c6bb4020cd05806fd0d33c44d38046b8bd7f0e40814b9763cabfc" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cc" +version = "1.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40545c26d092346d8a8dab71ee48e7685a7a9cba76e634790c215b41a4a7b4cf" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clippy_config" +version = "0.1.84" +source = "git+https://github.com/rust-lang/rust-clippy?rev=4f0e46b74dbc8441daf084b6f141a7fe414672a2#4f0e46b74dbc8441daf084b6f141a7fe414672a2" +dependencies = [ + "itertools", + "serde", + "toml 0.7.8", +] + +[[package]] +name = "clippy_utils" +version = "0.1.84" +source = "git+https://github.com/rust-lang/rust-clippy?rev=4f0e46b74dbc8441daf084b6f141a7fe414672a2#4f0e46b74dbc8441daf084b6f141a7fe414672a2" +dependencies = [ + "arrayvec", + "clippy_config", + "itertools", + "rustc_apfloat", +] + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "compiletest_rs" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fcc3c0c91b59c137b3cf8073cbc2f72a49b3d5505660ec88f94da3ed4bb1de" +dependencies = [ + "diff", + "filetime", + "getopts", + "lazy_static", + "libc", + "log", + "miow", + "regex", + "rustfix", + "serde", + "serde_derive", + "serde_json", + "tester", + "windows-sys 0.59.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dylint" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22752a1003c06a3f04f9ea66e4899f97132a1888276fee2d5fbe5f6820eee274" +dependencies = [ + "ansi_term", + "anyhow", + "cargo_metadata", + "dirs", + "dylint_internal", + "is-terminal", + "log", + "once_cell", + "semver", + "serde", + "serde_json", + "tempfile", + "walkdir", +] + +[[package]] +name = "dylint_internal" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02176c2fa705973bfce833c3f12a69196959086c776ec967a5aa23b5523d4b6" +dependencies = [ + "ansi_term", + "anyhow", + "bitflags 2.6.0", + "cargo_metadata", + "git2", + "home", + "if_chain", + "is-terminal", + "log", + "once_cell", + "regex", + "rust-embed", + "serde", + "thiserror", + "toml 0.8.19", +] + +[[package]] +name = "dylint_linting" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0338d94b92ffeece90a8ba405c6d051f18b4483558728537a333daa3bb422616" +dependencies = [ + "cargo_metadata", + "dylint_internal", + "paste", + "rustversion", + "serde", + "thiserror", + "toml 0.8.19", +] + +[[package]] +name = "dylint_testing" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1581603a3fc49b8ede8bed50f5e2d719ce607572f5e5d5d06f200f3285448a" +dependencies = [ + "anyhow", + "cargo_metadata", + "compiletest_rs", + "dylint", + "dylint_internal", + "env_logger", + "once_cell", + "regex", + "serde_json", + "tempfile", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" + +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "git2" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" +dependencies = [ + "bitflags 2.6.0", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + +[[package]] +name = "globset" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f1ce686646e7f1e19bf7d5533fe443a45dbfb990e00629110797578b42fb19" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if_chain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" + +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is-terminal" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +dependencies = [ + "hermit-abi 0.4.0", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.162" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" + +[[package]] +name = "libgit2-sys" +version = "0.16.2+1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "litemap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miow" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "359f76430b20a79f9e20e115b3428614e654f04fab314482fc0fda0ebd3c6044" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "non_authenticated_routes" +version = "0.1.0" +dependencies = [ + "clippy_utils", + "dylint_linting", + "dylint_testing", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi 0.3.9", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "proc-macro2" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +dependencies = [ + "bitflags 2.6.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rust-embed" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d" +dependencies = [ + "globset", + "sha2", + "walkdir", +] + +[[package]] +name = "rustc_apfloat" +version = "0.2.1+llvm-462a31f5a5ab" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "886d94c63c812a8037c4faca2607453a0fa4cf82f734665266876b022244543f" +dependencies = [ + "bitflags 1.3.2", + "smallvec", +] + +[[package]] +name = "rustfix" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb2b066405a6d48a1b39c0022270503e352ae84da0c24e1d5f8ffc38e97a325" +dependencies = [ + "serde", + "serde_json", + "thiserror", + "tracing", +] + +[[package]] +name = "rustix" +version = "0.38.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375116bee2be9ed569afe2154ea6a99dfdffd257f533f187498c2a8f5feaf4ee" +dependencies = [ + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustversion" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.214" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.214" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.132" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "syn" +version = "2.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + +[[package]] +name = "tester" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8bf7e0eb2dd7b4228cc1b6821fc5114cd6841ae59f652a85488c016091e5f" +dependencies = [ + "cfg-if", + "getopts", + "libc", + "num_cpus", + "term", +] + +[[package]] +name = "thiserror" +version = "1.0.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02dd99dc800bbb97186339685293e1cc5d9df1f8fae2d0aecd9ff1c77efea892" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c61ec9a6f64d2793d8a45faba21efbe3ced62a886d44c36a009b2b519b4c7e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.19.15", +] + +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.22.22", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow 0.6.20", +] + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "url" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d157f1b96d14500ffdc1f10ba712e780825526c03d9a49b4d0324b0d9113ada" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/dylints/non_authenticated_routes/Cargo.toml b/dylints/non_authenticated_routes/Cargo.toml new file mode 100644 index 00000000..396c96f3 --- /dev/null +++ b/dylints/non_authenticated_routes/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "non_authenticated_routes" +version = "0.1.0" +authors = ["authors go here"] +description = "description goes here" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +clippy_utils = { git = "https://github.com/rust-lang/rust-clippy", rev = "4f0e46b74dbc8441daf084b6f141a7fe414672a2" } +dylint_linting = "3.2.1" + +[dev-dependencies] +dylint_testing = "3.2.1" + +[package.metadata.rust-analyzer] +rustc_private = true diff --git a/dylints/non_authenticated_routes/rust-toolchain b/dylints/non_authenticated_routes/rust-toolchain new file mode 100644 index 00000000..3798c445 --- /dev/null +++ b/dylints/non_authenticated_routes/rust-toolchain @@ -0,0 +1,3 @@ +[toolchain] +channel = "nightly-2024-11-09" +components = ["llvm-tools-preview", "rustc-dev"] diff --git a/dylints/non_authenticated_routes/src/lib.rs b/dylints/non_authenticated_routes/src/lib.rs new file mode 100644 index 00000000..78bac586 --- /dev/null +++ b/dylints/non_authenticated_routes/src/lib.rs @@ -0,0 +1,167 @@ +#![feature(rustc_private)] +#![feature(let_chains)] + +extern crate rustc_arena; +extern crate rustc_ast; +extern crate rustc_ast_pretty; +extern crate rustc_attr; +extern crate rustc_data_structures; +extern crate rustc_errors; +extern crate rustc_hir; +extern crate rustc_hir_pretty; +extern crate rustc_index; +extern crate rustc_infer; +extern crate rustc_lexer; +extern crate rustc_middle; +extern crate rustc_mir_dataflow; +extern crate rustc_parse; +extern crate rustc_span; +extern crate rustc_target; +extern crate rustc_trait_selection; + +use clippy_utils::diagnostics::span_lint; +use rustc_hir::{def_id::DefId, Item, ItemKind, QPath, TyKind}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_span::{symbol::Ident, Span, Symbol}; + +dylint_linting::impl_late_lint! { + /// ### What it does + /// + /// ### Why is this bad? + /// + /// ### Known problems + /// Remove if none. + /// + /// ### Example + /// ```rust + /// // example code where a warning is issued + /// ``` + /// Use instead: + /// ```rust + /// // example code that does not raise a warning + /// ``` + pub NON_AUTHENTICATED_ROUTES, + Warn, + "description goes here", + NonAuthenticatedRoutes::default() +} + +#[derive(Default)] +pub struct NonAuthenticatedRoutes { + last_function_item: Option<(Ident, Span, bool)>, +} + +// Collect all the attribute macros that are applied to the given span +fn attr_def_ids(mut span: rustc_span::Span) -> Vec<(DefId, Symbol, Option)> { + use rustc_span::hygiene::{walk_chain, ExpnKind, MacroKind}; + use rustc_span::{ExpnData, SyntaxContext}; + + let mut def_ids = Vec::new(); + while span.ctxt() != SyntaxContext::root() { + if let ExpnData { + kind: ExpnKind::Macro(MacroKind::Attr, macro_symbol), + macro_def_id: Some(def_id), + parent_module, + .. + } = span.ctxt().outer_expn_data() + { + def_ids.push((def_id, macro_symbol, parent_module)); + } + span = walk_chain(span, SyntaxContext::root()); + } + def_ids +} + +const ROCKET_MACRO_EXCEPTIONS: [(&str, &str); 1] = [("rocket::catch", "catch")]; + +const VALID_AUTH_HEADERS: [&str; 6] = [ + "auth::Headers", + "auth::OrgHeaders", + "auth::AdminHeaders", + "auth::ManagerHeaders", + "auth::ManagerHeadersLoose", + "auth::OwnerHeaders", +]; + +impl<'tcx> LateLintPass<'tcx> for NonAuthenticatedRoutes { + fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx Item) { + if let ItemKind::Fn(sig, ..) = item.kind { + let mut has_auth_headers = false; + + for input in sig.decl.inputs { + let TyKind::Path(QPath::Resolved(_, path)) = input.kind else { + continue; + }; + + for seg in path.segments { + if let Some(def_id) = seg.res.opt_def_id() { + let def = cx.tcx.def_path_str(def_id); + if VALID_AUTH_HEADERS.contains(&def.as_str()) { + has_auth_headers = true; + } + } + } + } + + self.last_function_item = Some((item.ident, sig.span, has_auth_headers)); + return; + } + + let ItemKind::Struct(_data, _generics) = item.kind else { + return; + }; + + let def_ids = attr_def_ids(item.span); + + let mut is_rocket_route = false; + + for (def_id, sym, parent) in &def_ids { + let def_id = cx.tcx.def_path_str(*def_id); + let sym = sym.as_str(); + let parent = parent.map(|parent| cx.tcx.def_path_str(parent)); + + if ROCKET_MACRO_EXCEPTIONS.contains(&(&def_id, sym)) { + is_rocket_route = false; + break; + } + + if def_id.starts_with("rocket::") || parent.as_deref() == Some("rocket_codegen") { + is_rocket_route = true; + break; + } + } + + if !is_rocket_route { + return; + } + + let Some((func_ident, func_span, has_auth_headers)) = self.last_function_item.take() else { + span_lint(cx, NON_AUTHENTICATED_ROUTES, item.span, "No function found before the expanded route"); + return; + }; + + if func_ident != item.ident { + span_lint( + cx, + NON_AUTHENTICATED_ROUTES, + item.span, + "The function before the expanded route does not match the route", + ); + return; + } + + if !has_auth_headers { + span_lint( + cx, + NON_AUTHENTICATED_ROUTES, + func_span, + "This Rocket route does not have any authentication headers", + ); + } + } +} + +#[test] +fn ui() { + dylint_testing::ui_test(env!("CARGO_PKG_NAME"), "ui"); +} diff --git a/dylints/non_authenticated_routes/ui/main.rs b/dylints/non_authenticated_routes/ui/main.rs new file mode 100644 index 00000000..f328e4d9 --- /dev/null +++ b/dylints/non_authenticated_routes/ui/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql b/dylints/non_authenticated_routes/ui/main.stderr similarity index 100% rename from migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql rename to dylints/non_authenticated_routes/ui/main.stderr diff --git a/macros/Cargo.toml b/macros/Cargo.toml deleted file mode 100644 index eb3bd670..00000000 --- a/macros/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "macros" -version = "0.1.0" -repository.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -publish.workspace = true - -[lib] -name = "macros" -path = "src/lib.rs" -proc-macro = true - -[dependencies] -quote = "1.0.45" -syn = "2.0.117" - -[lints] -workspace = true diff --git a/macros/src/lib.rs b/macros/src/lib.rs deleted file mode 100644 index 2d923ce1..00000000 --- a/macros/src/lib.rs +++ /dev/null @@ -1,56 +0,0 @@ -use proc_macro::TokenStream; -use quote::quote; - -#[proc_macro_derive(UuidFromParam)] -pub fn derive_uuid_from_param(input: TokenStream) -> TokenStream { - let ast = syn::parse(input).unwrap(); - - impl_derive_uuid_macro(&ast) -} - -fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream { - let name = &ast.ident; - let gen_derive = quote! { - #[automatically_derived] - impl<'r> rocket::request::FromParam<'r> for #name { - type Error = (); - - #[inline(always)] - fn from_param(param: &'r str) -> Result { - if uuid::Uuid::parse_str(param).is_ok() { - Ok(Self(param.to_string())) - } else { - Err(()) - } - } - } - }; - gen_derive.into() -} - -#[proc_macro_derive(IdFromParam)] -pub fn derive_id_from_param(input: TokenStream) -> TokenStream { - let ast = syn::parse(input).unwrap(); - - impl_derive_safestring_macro(&ast) -} - -fn impl_derive_safestring_macro(ast: &syn::DeriveInput) -> TokenStream { - let name = &ast.ident; - let gen_derive = quote! { - #[automatically_derived] - impl<'r> rocket::request::FromParam<'r> for #name { - type Error = (); - - #[inline(always)] - fn from_param(param: &'r str) -> Result { - if param.chars().all(|c| matches!(c, 'a'..='z' | 'A'..='Z' |'0'..='9' | '-')) { - Ok(Self(param.to_string())) - } else { - Err(()) - } - } - } - }; - gen_derive.into() -} diff --git a/migrations/mysql/2023-09-10-133000_add_sso/down.sql b/migrations/mysql/2023-09-10-133000_add_sso/down.sql deleted file mode 100644 index 2c946dc5..00000000 --- a/migrations/mysql/2023-09-10-133000_add_sso/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE sso_nonce; diff --git a/migrations/mysql/2023-09-10-133000_add_sso/up.sql b/migrations/mysql/2023-09-10-133000_add_sso/up.sql deleted file mode 100644 index 518664df..00000000 --- a/migrations/mysql/2023-09-10-133000_add_sso/up.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE sso_nonce ( - nonce CHAR(36) NOT NULL PRIMARY KEY, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql b/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql deleted file mode 100644 index 3a708927..00000000 --- a/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users_organizations DROP COLUMN invited_by_email; diff --git a/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql b/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql deleted file mode 100644 index c94e1131..00000000 --- a/migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; diff --git a/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/down.sql b/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/down.sql deleted file mode 100644 index bce31222..00000000 --- a/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/down.sql +++ /dev/null @@ -1,6 +0,0 @@ -DROP TABLE IF EXISTS sso_nonce; - -CREATE TABLE sso_nonce ( - nonce CHAR(36) NOT NULL PRIMARY KEY, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql b/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql deleted file mode 100644 index f73aeea9..00000000 --- a/migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql +++ /dev/null @@ -1,8 +0,0 @@ -DROP TABLE IF EXISTS sso_nonce; - -CREATE TABLE sso_nonce ( - state VARCHAR(512) NOT NULL PRIMARY KEY, - nonce TEXT NOT NULL, - redirect_uri TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT now() -); diff --git a/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql b/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql deleted file mode 100644 index c033f7cb..00000000 --- a/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql +++ /dev/null @@ -1,8 +0,0 @@ -DROP TABLE IF EXISTS sso_nonce; - -CREATE TABLE sso_nonce ( - state VARCHAR(512) NOT NULL PRIMARY KEY, - nonce TEXT NOT NULL, - redirect_uri TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT now() -); diff --git a/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql b/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql deleted file mode 100644 index 42fb0efa..00000000 --- a/migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql +++ /dev/null @@ -1,9 +0,0 @@ -DROP TABLE IF EXISTS sso_nonce; - -CREATE TABLE sso_nonce ( - state VARCHAR(512) NOT NULL PRIMARY KEY, - nonce TEXT NOT NULL, - verifier TEXT, - redirect_uri TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT now() -); diff --git a/migrations/mysql/2024-03-06-170000_add_sso_users/down.sql b/migrations/mysql/2024-03-06-170000_add_sso_users/down.sql deleted file mode 100644 index f2f92f68..00000000 --- a/migrations/mysql/2024-03-06-170000_add_sso_users/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS sso_users; diff --git a/migrations/mysql/2024-03-06-170000_add_sso_users/up.sql b/migrations/mysql/2024-03-06-170000_add_sso_users/up.sql deleted file mode 100644 index 7809d43e..00000000 --- a/migrations/mysql/2024-03-06-170000_add_sso_users/up.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE sso_users ( - user_uuid CHAR(36) NOT NULL PRIMARY KEY, - identifier VARCHAR(768) NOT NULL UNIQUE, - created_at TIMESTAMP NOT NULL DEFAULT now(), - - FOREIGN KEY(user_uuid) REFERENCES users(uuid) -); diff --git a/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql b/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql deleted file mode 100644 index 9e5e46df..00000000 --- a/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Dynamically create DROP FOREIGN KEY --- Some versions of MySQL or MariaDB might fail if the key doesn't exists --- This checks if the key exists, and if so, will drop it. -SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE - CONSTRAINT_SCHEMA = DATABASE() AND - TABLE_NAME = 'sso_users' AND - CONSTRAINT_NAME = 'sso_users_ibfk_1' AND - CONSTRAINT_TYPE = 'FOREIGN KEY') = true, - 'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1', - 'SELECT 1'); -PREPARE stmt FROM @drop_sso_fk; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/migrations/mysql/2025-01-09-172300_add_manage/down.sql b/migrations/mysql/2025-01-09-172300_add_manage/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/mysql/2025-01-09-172300_add_manage/up.sql b/migrations/mysql/2025-01-09-172300_add_manage/up.sql deleted file mode 100644 index e234cc6e..00000000 --- a/migrations/mysql/2025-01-09-172300_add_manage/up.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE users_collections -ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE; - -ALTER TABLE collections_groups -ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/mysql/2025-08-20-120000_sso_nonce_to_auth/down.sql b/migrations/mysql/2025-08-20-120000_sso_nonce_to_auth/down.sql deleted file mode 100644 index 3a965886..00000000 --- a/migrations/mysql/2025-08-20-120000_sso_nonce_to_auth/down.sql +++ /dev/null @@ -1,9 +0,0 @@ -DROP TABLE IF EXISTS sso_auth; - -CREATE TABLE sso_nonce ( - state VARCHAR(512) NOT NULL PRIMARY KEY, - nonce TEXT NOT NULL, - verifier TEXT, - redirect_uri TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT now() -); diff --git a/migrations/mysql/2025-08-20-120000_sso_nonce_to_auth/up.sql b/migrations/mysql/2025-08-20-120000_sso_nonce_to_auth/up.sql deleted file mode 100644 index 1a68b715..00000000 --- a/migrations/mysql/2025-08-20-120000_sso_nonce_to_auth/up.sql +++ /dev/null @@ -1,12 +0,0 @@ -DROP TABLE IF EXISTS sso_nonce; - -CREATE TABLE sso_auth ( - state VARCHAR(512) NOT NULL PRIMARY KEY, - client_challenge TEXT NOT NULL, - nonce TEXT NOT NULL, - redirect_uri TEXT NOT NULL, - code_response TEXT, - auth_response TEXT, - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now() -); diff --git a/migrations/mysql/2026-03-09-005927_add_archives/down.sql b/migrations/mysql/2026-03-09-005927_add_archives/down.sql deleted file mode 100644 index a3ef20c3..00000000 --- a/migrations/mysql/2026-03-09-005927_add_archives/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS archives; diff --git a/migrations/mysql/2026-03-09-005927_add_archives/up.sql b/migrations/mysql/2026-03-09-005927_add_archives/up.sql deleted file mode 100644 index 6d7a7024..00000000 --- a/migrations/mysql/2026-03-09-005927_add_archives/up.sql +++ /dev/null @@ -1,10 +0,0 @@ -DROP TABLE IF EXISTS archives; - -CREATE TABLE archives ( - user_uuid CHAR(36) NOT NULL, - cipher_uuid CHAR(36) NOT NULL, - archived_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_uuid, cipher_uuid), - FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE, - FOREIGN KEY (cipher_uuid) REFERENCES ciphers (uuid) ON DELETE CASCADE -); diff --git a/migrations/mysql/2026-04-25-120000_sso_auth_binding/down.sql b/migrations/mysql/2026-04-25-120000_sso_auth_binding/down.sql deleted file mode 100644 index 17e3d8c7..00000000 --- a/migrations/mysql/2026-04-25-120000_sso_auth_binding/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth DROP COLUMN binding_hash; diff --git a/migrations/mysql/2026-04-25-120000_sso_auth_binding/up.sql b/migrations/mysql/2026-04-25-120000_sso_auth_binding/up.sql deleted file mode 100644 index 53ee8063..00000000 --- a/migrations/mysql/2026-04-25-120000_sso_auth_binding/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT; diff --git a/migrations/postgresql/2023-09-10-133000_add_sso/down.sql b/migrations/postgresql/2023-09-10-133000_add_sso/down.sql deleted file mode 100644 index 2c946dc5..00000000 --- a/migrations/postgresql/2023-09-10-133000_add_sso/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE sso_nonce; diff --git a/migrations/postgresql/2023-09-10-133000_add_sso/up.sql b/migrations/postgresql/2023-09-10-133000_add_sso/up.sql deleted file mode 100644 index 1321e246..00000000 --- a/migrations/postgresql/2023-09-10-133000_add_sso/up.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE sso_nonce ( - nonce CHAR(36) NOT NULL PRIMARY KEY, - created_at TIMESTAMP NOT NULL DEFAULT now() -); diff --git a/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql b/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql deleted file mode 100644 index 3a708927..00000000 --- a/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users_organizations DROP COLUMN invited_by_email; diff --git a/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql b/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql deleted file mode 100644 index c94e1131..00000000 --- a/migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; diff --git a/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/down.sql b/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/down.sql deleted file mode 100644 index 7cf4d9d6..00000000 --- a/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/down.sql +++ /dev/null @@ -1,6 +0,0 @@ -DROP TABLE sso_nonce; - -CREATE TABLE sso_nonce ( - nonce CHAR(36) NOT NULL PRIMARY KEY, - created_at TIMESTAMP NOT NULL DEFAULT now() -); diff --git a/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/up.sql b/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/up.sql deleted file mode 100644 index f7402460..00000000 --- a/migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/up.sql +++ /dev/null @@ -1,8 +0,0 @@ -DROP TABLE sso_nonce; - -CREATE TABLE sso_nonce ( - state TEXT NOT NULL PRIMARY KEY, - nonce TEXT NOT NULL, - redirect_uri TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT now() -); diff --git a/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql b/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql deleted file mode 100644 index ef209a45..00000000 --- a/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql +++ /dev/null @@ -1,8 +0,0 @@ -DROP TABLE IF EXISTS sso_nonce; - -CREATE TABLE sso_nonce ( - state TEXT NOT NULL PRIMARY KEY, - nonce TEXT NOT NULL, - redirect_uri TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT now() -); diff --git a/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql b/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql deleted file mode 100644 index f2dedfc9..00000000 --- a/migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql +++ /dev/null @@ -1,9 +0,0 @@ -DROP TABLE IF EXISTS sso_nonce; - -CREATE TABLE sso_nonce ( - state TEXT NOT NULL PRIMARY KEY, - nonce TEXT NOT NULL, - verifier TEXT, - redirect_uri TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT now() -); diff --git a/migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql b/migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql deleted file mode 100644 index f2f92f68..00000000 --- a/migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS sso_users; diff --git a/migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql b/migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql deleted file mode 100644 index b74b5728..00000000 --- a/migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE sso_users ( - user_uuid CHAR(36) NOT NULL PRIMARY KEY, - identifier TEXT NOT NULL UNIQUE, - created_at TIMESTAMP NOT NULL DEFAULT now(), - - FOREIGN KEY(user_uuid) REFERENCES users(uuid) -); diff --git a/migrations/postgresql/2024-03-13-170000_sso_users_cascade/down.sql b/migrations/postgresql/2024-03-13-170000_sso_users_cascade/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql b/migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql deleted file mode 100644 index 38f97b4d..00000000 --- a/migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE sso_users - DROP CONSTRAINT "sso_users_user_uuid_fkey", - ADD CONSTRAINT "sso_users_user_uuid_fkey" FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/migrations/postgresql/2025-01-09-172300_add_manage/down.sql b/migrations/postgresql/2025-01-09-172300_add_manage/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/postgresql/2025-01-09-172300_add_manage/up.sql b/migrations/postgresql/2025-01-09-172300_add_manage/up.sql deleted file mode 100644 index e234cc6e..00000000 --- a/migrations/postgresql/2025-01-09-172300_add_manage/up.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE users_collections -ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE; - -ALTER TABLE collections_groups -ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/postgresql/2025-08-20-120000_sso_nonce_to_auth/down.sql b/migrations/postgresql/2025-08-20-120000_sso_nonce_to_auth/down.sql deleted file mode 100644 index 8cc36353..00000000 --- a/migrations/postgresql/2025-08-20-120000_sso_nonce_to_auth/down.sql +++ /dev/null @@ -1,9 +0,0 @@ -DROP TABLE IF EXISTS sso_auth; - -CREATE TABLE sso_nonce ( - state TEXT NOT NULL PRIMARY KEY, - nonce TEXT NOT NULL, - verifier TEXT, - redirect_uri TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT now() -); diff --git a/migrations/postgresql/2025-08-20-120000_sso_nonce_to_auth/up.sql b/migrations/postgresql/2025-08-20-120000_sso_nonce_to_auth/up.sql deleted file mode 100644 index 0fee1b5a..00000000 --- a/migrations/postgresql/2025-08-20-120000_sso_nonce_to_auth/up.sql +++ /dev/null @@ -1,12 +0,0 @@ -DROP TABLE IF EXISTS sso_nonce; - -CREATE TABLE sso_auth ( - state TEXT NOT NULL PRIMARY KEY, - client_challenge TEXT NOT NULL, - nonce TEXT NOT NULL, - redirect_uri TEXT NOT NULL, - code_response TEXT, - auth_response TEXT, - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now() -); diff --git a/migrations/postgresql/2026-03-09-005927_add_archives/down.sql b/migrations/postgresql/2026-03-09-005927_add_archives/down.sql deleted file mode 100644 index a3ef20c3..00000000 --- a/migrations/postgresql/2026-03-09-005927_add_archives/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS archives; diff --git a/migrations/postgresql/2026-03-09-005927_add_archives/up.sql b/migrations/postgresql/2026-03-09-005927_add_archives/up.sql deleted file mode 100644 index c56d01a0..00000000 --- a/migrations/postgresql/2026-03-09-005927_add_archives/up.sql +++ /dev/null @@ -1,8 +0,0 @@ -DROP TABLE IF EXISTS archives; - -CREATE TABLE archives ( - user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE, - cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE, - archived_at TIMESTAMP NOT NULL DEFAULT now(), - PRIMARY KEY (user_uuid, cipher_uuid) -); diff --git a/migrations/postgresql/2026-04-25-120000_sso_auth_binding/down.sql b/migrations/postgresql/2026-04-25-120000_sso_auth_binding/down.sql deleted file mode 100644 index 17e3d8c7..00000000 --- a/migrations/postgresql/2026-04-25-120000_sso_auth_binding/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth DROP COLUMN binding_hash; diff --git a/migrations/postgresql/2026-04-25-120000_sso_auth_binding/up.sql b/migrations/postgresql/2026-04-25-120000_sso_auth_binding/up.sql deleted file mode 100644 index 53ee8063..00000000 --- a/migrations/postgresql/2026-04-25-120000_sso_auth_binding/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT; diff --git a/migrations/sqlite/2023-09-10-133000_add_sso/down.sql b/migrations/sqlite/2023-09-10-133000_add_sso/down.sql deleted file mode 100644 index 2c946dc5..00000000 --- a/migrations/sqlite/2023-09-10-133000_add_sso/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE sso_nonce; diff --git a/migrations/sqlite/2023-09-10-133000_add_sso/up.sql b/migrations/sqlite/2023-09-10-133000_add_sso/up.sql deleted file mode 100644 index 518664df..00000000 --- a/migrations/sqlite/2023-09-10-133000_add_sso/up.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE sso_nonce ( - nonce CHAR(36) NOT NULL PRIMARY KEY, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql b/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql deleted file mode 100644 index 3a708927..00000000 --- a/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users_organizations DROP COLUMN invited_by_email; diff --git a/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql b/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql deleted file mode 100644 index c94e1131..00000000 --- a/migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; diff --git a/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/down.sql b/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/down.sql deleted file mode 100644 index 3cbd4602..00000000 --- a/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/down.sql +++ /dev/null @@ -1,6 +0,0 @@ -DROP TABLE sso_nonce; - -CREATE TABLE sso_nonce ( - nonce CHAR(36) NOT NULL PRIMARY KEY, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/up.sql b/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/up.sql deleted file mode 100644 index 13e95fd8..00000000 --- a/migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/up.sql +++ /dev/null @@ -1,8 +0,0 @@ -DROP TABLE sso_nonce; - -CREATE TABLE sso_nonce ( - state TEXT NOT NULL PRIMARY KEY, - nonce TEXT NOT NULL, - redirect_uri TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql b/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql deleted file mode 100644 index e7a55bd8..00000000 --- a/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql +++ /dev/null @@ -1,8 +0,0 @@ -DROP TABLE IF EXISTS sso_nonce; - -CREATE TABLE sso_nonce ( - state TEXT NOT NULL PRIMARY KEY, - nonce TEXT NOT NULL, - redirect_uri TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql b/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql deleted file mode 100644 index 6b55e95d..00000000 --- a/migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql +++ /dev/null @@ -1,9 +0,0 @@ -DROP TABLE IF EXISTS sso_nonce; - -CREATE TABLE sso_nonce ( - state TEXT NOT NULL PRIMARY KEY, - nonce TEXT NOT NULL, - verifier TEXT, - redirect_uri TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql b/migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql deleted file mode 100644 index f2f92f68..00000000 --- a/migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS sso_users; diff --git a/migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql b/migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql deleted file mode 100644 index 6d015f04..00000000 --- a/migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE sso_users ( - user_uuid CHAR(36) NOT NULL PRIMARY KEY, - identifier TEXT NOT NULL UNIQUE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - FOREIGN KEY(user_uuid) REFERENCES users(uuid) -); diff --git a/migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql b/migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql b/migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql deleted file mode 100644 index 53b09cf4..00000000 --- a/migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql +++ /dev/null @@ -1,9 +0,0 @@ -DROP TABLE IF EXISTS sso_users; - -CREATE TABLE sso_users ( - user_uuid CHAR(36) NOT NULL PRIMARY KEY, - identifier TEXT NOT NULL UNIQUE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE -); diff --git a/migrations/sqlite/2025-01-09-172300_add_manage/down.sql b/migrations/sqlite/2025-01-09-172300_add_manage/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/sqlite/2025-01-09-172300_add_manage/up.sql b/migrations/sqlite/2025-01-09-172300_add_manage/up.sql deleted file mode 100644 index 4b4b07a5..00000000 --- a/migrations/sqlite/2025-01-09-172300_add_manage/up.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE users_collections -ADD COLUMN manage BOOLEAN NOT NULL DEFAULT 0; -- FALSE - -ALTER TABLE collections_groups -ADD COLUMN manage BOOLEAN NOT NULL DEFAULT 0; -- FALSE diff --git a/migrations/sqlite/2025-08-20-120000_sso_nonce_to_auth/down.sql b/migrations/sqlite/2025-08-20-120000_sso_nonce_to_auth/down.sql deleted file mode 100644 index 453e267b..00000000 --- a/migrations/sqlite/2025-08-20-120000_sso_nonce_to_auth/down.sql +++ /dev/null @@ -1,9 +0,0 @@ -DROP TABLE IF EXISTS sso_auth; - -CREATE TABLE sso_nonce ( - state TEXT NOT NULL PRIMARY KEY, - nonce TEXT NOT NULL, - verifier TEXT, - redirect_uri TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/migrations/sqlite/2025-08-20-120000_sso_nonce_to_auth/up.sql b/migrations/sqlite/2025-08-20-120000_sso_nonce_to_auth/up.sql deleted file mode 100644 index 1cd868b4..00000000 --- a/migrations/sqlite/2025-08-20-120000_sso_nonce_to_auth/up.sql +++ /dev/null @@ -1,12 +0,0 @@ -DROP TABLE IF EXISTS sso_nonce; - -CREATE TABLE sso_auth ( - state TEXT NOT NULL PRIMARY KEY, - client_challenge TEXT NOT NULL, - nonce TEXT NOT NULL, - redirect_uri TEXT NOT NULL, - code_response TEXT, - auth_response TEXT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/migrations/sqlite/2026-03-09-005927_add_archives/down.sql b/migrations/sqlite/2026-03-09-005927_add_archives/down.sql deleted file mode 100644 index a3ef20c3..00000000 --- a/migrations/sqlite/2026-03-09-005927_add_archives/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS archives; diff --git a/migrations/sqlite/2026-03-09-005927_add_archives/up.sql b/migrations/sqlite/2026-03-09-005927_add_archives/up.sql deleted file mode 100644 index d624f57b..00000000 --- a/migrations/sqlite/2026-03-09-005927_add_archives/up.sql +++ /dev/null @@ -1,8 +0,0 @@ -DROP TABLE IF EXISTS archives; - -CREATE TABLE archives ( - user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE, - cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE, - archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_uuid, cipher_uuid) -); diff --git a/migrations/sqlite/2026-04-25-120000_sso_auth_binding/down.sql b/migrations/sqlite/2026-04-25-120000_sso_auth_binding/down.sql deleted file mode 100644 index 17e3d8c7..00000000 --- a/migrations/sqlite/2026-04-25-120000_sso_auth_binding/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth DROP COLUMN binding_hash; diff --git a/migrations/sqlite/2026-04-25-120000_sso_auth_binding/up.sql b/migrations/sqlite/2026-04-25-120000_sso_auth_binding/up.sql deleted file mode 100644 index 53ee8063..00000000 --- a/migrations/sqlite/2026-04-25-120000_sso_auth_binding/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT; diff --git a/playwright/.env.template b/playwright/.env.template deleted file mode 100644 index a6696aab..00000000 --- a/playwright/.env.template +++ /dev/null @@ -1,64 +0,0 @@ -################################# -### Conf to run dev instances ### -################################# -ENV=dev -DC_ENV_FILE=.env -COMPOSE_IGNORE_ORPHANS=True -DOCKER_BUILDKIT=1 - -################ -# Users Config # -################ -TEST_USER=test -TEST_USER_PASSWORD=${TEST_USER} -TEST_USER_MAIL=${TEST_USER}@yopmail.com - -TEST_USER2=test2 -TEST_USER2_PASSWORD=${TEST_USER2} -TEST_USER2_MAIL=${TEST_USER2}@yopmail.com - -TEST_USER3=test3 -TEST_USER3_PASSWORD=${TEST_USER3} -TEST_USER3_MAIL=${TEST_USER3}@yopmail.com - -################### -# Keycloak Config # -################### -KEYCLOAK_ADMIN=admin -KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} -KC_HTTP_HOST=127.0.0.1 -KC_HTTP_PORT=8080 - -# Script parameters (use Keycloak and Vaultwarden config too) -TEST_REALM=test -DUMMY_REALM=dummy -DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} - -###################### -# Vaultwarden Config # -###################### -ROCKET_ADDRESS=0.0.0.0 -ROCKET_PORT=8000 -DOMAIN=http://localhost:${ROCKET_PORT} -LOG_LEVEL=info,oidcwarden::sso=debug -I_REALLY_WANT_VOLATILE_STORAGE=true - -SSO_ENABLED=true -SSO_ONLY=false -SSO_CLIENT_ID=warden -SSO_CLIENT_SECRET=warden -SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} - -SMTP_HOST=127.0.0.1 -SMTP_PORT=1025 -SMTP_SECURITY=off -SMTP_TIMEOUT=5 -SMTP_FROM=vaultwarden@test -SMTP_FROM_NAME=Vaultwarden - -######################################################## -# DUMMY values for docker-compose to stop bothering us # -######################################################## -MARIADB_PORT=3305 -MYSQL_PORT=3307 -POSTGRES_PORT=5432 diff --git a/playwright/.gitignore b/playwright/.gitignore deleted file mode 100644 index 8746d597..00000000 --- a/playwright/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -logs -node_modules/ -/test-results/ -/playwright-report/ -/playwright/.cache/ -temp diff --git a/playwright/README.md b/playwright/README.md deleted file mode 100644 index a27e6105..00000000 --- a/playwright/README.md +++ /dev/null @@ -1,179 +0,0 @@ -# Integration tests - -This allows running integration tests using [Playwright](https://playwright.dev/). - -It uses its own `test.env` with different ports to not collide with a running dev instance. - -## Install - -This relies on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/). -Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in containers. - -### Running Playwright outside docker - -It is possible to run `Playwright` outside of the container, this removes the need to rebuild the image for each change. -You will additionally need `nodejs` then run: - -```bash -npm ci --ignore-scripts -npx playwright install-deps -npx playwright install firefox -``` - -## Usage - -To run all the tests: - -```bash -DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright -``` - -To force a rebuild of the Playwright image: -```bash -DOCKER_BUILDKIT=1 docker compose --env-file test.env build Playwright -``` - -To access the UI to easily run test individually and debug if needed (this will not work in docker): - -```bash -npx playwright test --ui -``` - -### DB - -Projects are configured to allow to run tests only on specific database. - -You can use: - -```bash -DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mariadb -DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mysql -DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=postgres -DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -``` - -### SSO - -To run the SSO tests: - -```bash -DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project sso-sqlite -``` - -### Keep services running - -If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests): - -```bash -PW_KEEP_SERVICE_RUNNNING=true npx playwright test -``` - -### Running specific tests - -To run a whole file you can : - -```bash -DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts -DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login -``` - -To run only a specifc test (It might fail if it has dependency): - -```bash -DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation" -DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts:16 -``` - -## Writing scenario - -When creating new scenario use the recorder to more easily identify elements -(in general try to rely on visible hint to identify elements and not hidden IDs). -This does not start the server, you will need to start it manually. - -```bash -DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden -npx playwright codegen "http://127.0.0.1:8003" -``` - -## Override web-vault - -It is possible to change the `web-vault` used by referencing a different `vw_web_builds` commit. - -Simplest is to set and uncomment `PW_VW_REPO_URL` and `PW_VW_COMMIT_HASH` in the `test.env`. -Ensure that the image is built with: - -```bash -DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env build Vaultwarden -``` - -You can check the result running: - -```bash -DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden -``` - -Then check `http://127.0.0.1:8003/admin/diagnostics` with `admin`. - -# OpenID Connect test setup - -Additionally this `docker-compose` template allows to run locally Vaultwarden, -[Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC. - -## Setup - -This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/). -First create a copy of `.env.template` as `.env` (This is done to prevent committing your custom settings, Ex `SMTP_`). - -## Usage - -Then start the stack (the `profile` is required to run `Vaultwarden`) : - -```bash -> docker compose --profile vaultwarden --env-file .env up -.... -keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master -keycloakSetup_1 | Created new realm with id 'test' -keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e -oidc_keycloakSetup_1 exited with code 0 -``` - -Wait until `oidc_keycloakSetup_1 exited with code 0` which indicates the correct setup of the Keycloak realm, client and user -(It is normal for this container to stop once the configuration is done). - -Then you can access : - -- `Vaultwarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`. -- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin` -- `Maildev` on http://0.0.0.0:1080 - -To proceed with an SSO login after you enter the email, on the screen prompting for `Master Password` the SSO button should be visible. -To use your computer external ip (for example when testing with a phone) you will have to configure `KC_HTTP_HOST` and `DOMAIN`. - -## Running only Keycloak - -You can run just `Keycloak` with `--profile keycloak`: - -```bash -> docker compose --profile keycloak --env-file .env up -``` -When running with a local Vaultwarden, you can use a front-end build from [dani-garcia/bw_web_builds](https://github.com/dani-garcia/bw_web_builds/releases). - -## Rebuilding the Vaultwarden - -To force rebuilding the Vaultwarden image you can run - -```bash -docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild Vaultwarden -``` - -## Configuration - -All configuration for `keycloak` / `Vaultwarden` / `keycloak_setup.sh` can be found in [.env](.env.template). -The content of the file will be loaded as environment variables in all containers. - -- `keycloak` [configuration](https://www.keycloak.org/server/all-config) includes `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)). -- All `Vaultwarden` configuration can be set (EX: `SMTP_*`) - -## Cleanup - -Use `docker compose --profile vaultwarden down`. diff --git a/playwright/compose/keycloak/Dockerfile b/playwright/compose/keycloak/Dockerfile deleted file mode 100644 index 0188988f..00000000 --- a/playwright/compose/keycloak/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -FROM docker.io/library/debian:trixie-slim - -ARG KEYCLOAK_VERSION - -ENV DEBIAN_FRONTEND=noninteractive -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -RUN apt-get update && apt-get install -y ca-certificates curl jq openjdk-21-jdk-headless wget - -WORKDIR / - -RUN wget -c https://github.com/keycloak/keycloak/releases/download/${KEYCLOAK_VERSION}/keycloak-${KEYCLOAK_VERSION}.tar.gz -O - | tar -xz \ - && mkdir -p /opt/keycloak \ - && mv /keycloak-${KEYCLOAK_VERSION}/bin /opt/keycloak/bin \ - && rm -rf /keycloak-${KEYCLOAK_VERSION} - -COPY setup.sh /setup.sh - -CMD "/setup.sh" diff --git a/playwright/compose/keycloak/setup.sh b/playwright/compose/keycloak/setup.sh deleted file mode 100755 index a27caaff..00000000 --- a/playwright/compose/keycloak/setup.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash - -export PATH=/opt/keycloak/bin:$PATH - -STATUS_CODE=0 -while [[ "$STATUS_CODE" != "404" ]] ; do - echo "Will retry in 2 seconds" - sleep 2 - - STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$DUMMY_AUTHORITY") - - if [[ "$STATUS_CODE" = "200" ]]; then - echo "Setup should already be done. Will not run." - exit 0 - fi -done - -set -e - -kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli - -kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600" -kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i - -TEST_USER_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER" -s "firstName=$TEST_USER" -s "lastName=$TEST_USER" -s "email=$TEST_USER_MAIL" -s emailVerified=true -s enabled=true -i) -kcadm.sh update users/$TEST_USER_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER_PASSWORD" -n - -TEST_USER2_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER2" -s "firstName=$TEST_USER2" -s "lastName=$TEST_USER2" -s "email=$TEST_USER2_MAIL" -s emailVerified=true -s enabled=true -i) -kcadm.sh update users/$TEST_USER2_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER2_PASSWORD" -n - -TEST_USER3_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER3" -s "firstName=$TEST_USER3" -s "lastName=$TEST_USER3" -s "email=$TEST_USER3_MAIL" -s emailVerified=true -s enabled=true -i) -kcadm.sh update users/$TEST_USER3_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER3_PASSWORD" -n - -# Dummy realm to mark end of setup -kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLifespan=600" - -# TO DEBUG uncomment the following line to keep the setup container running -# sleep 3600 -# THEN in another terminal: -# docker exec -it keycloakSetup-dev /bin/bash -# export PATH=$PATH:/opt/keycloak/bin -# kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli -# ENJOY -# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html diff --git a/playwright/compose/playwright/Dockerfile b/playwright/compose/playwright/Dockerfile deleted file mode 100644 index 4dae1ae4..00000000 --- a/playwright/compose/playwright/Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -FROM docker.io/library/debian:trixie-slim - -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update \ - && apt-get install -y ca-certificates curl \ - && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ - && chmod a+r /etc/apt/keyrings/docker.asc \ - && echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian trixie stable" | tee /etc/apt/sources.list.d/docker.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - containerd.io \ - docker-buildx-plugin \ - docker-ce \ - docker-ce-cli \ - docker-compose-plugin \ - git \ - libmariadb-dev-compat \ - libpq5 \ - nodejs \ - npm \ - openssl \ - && rm -rf /var/lib/apt/lists/* - -RUN mkdir /playwright -WORKDIR /playwright - -COPY package.json package-lock.json . -RUN npm ci --ignore-scripts && npx playwright install-deps && npx playwright install firefox - -COPY docker-compose.yml test.env ./ -COPY compose ./compose - -COPY *.ts test.env ./ -COPY tests ./tests - -ENTRYPOINT ["/usr/bin/npx", "playwright"] -CMD ["test"] diff --git a/playwright/compose/warden/Dockerfile b/playwright/compose/warden/Dockerfile deleted file mode 100644 index e472d207..00000000 --- a/playwright/compose/warden/Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -FROM playwright_oidc_vaultwarden_prebuilt AS prebuilt - -FROM node:22-trixie AS build - -ARG REPO_URL -ARG COMMIT_HASH - -ENV REPO_URL=$REPO_URL -ENV COMMIT_HASH=$COMMIT_HASH - -COPY --from=prebuilt /web-vault /web-vault - -COPY build.sh /build.sh -RUN /build.sh - -######################## RUNTIME IMAGE ######################## -FROM docker.io/library/debian:trixie-slim - -ENV DEBIAN_FRONTEND=noninteractive - -# Create data folder and Install needed libraries -RUN mkdir /data && \ - apt-get update && apt-get install -y \ - --no-install-recommends \ - ca-certificates \ - curl \ - libmariadb-dev \ - libpq5 \ - openssl && \ - rm -rf /var/lib/apt/lists/* - -# Copies the files from the context (Rocket.toml file and web-vault) -# and the binary from the "build" stage to the current stage -WORKDIR / - -COPY --from=prebuilt /start.sh . -COPY --from=prebuilt /vaultwarden . -COPY --from=build /web-vault ./web-vault - -ENTRYPOINT ["/start.sh"] diff --git a/playwright/compose/warden/build.sh b/playwright/compose/warden/build.sh deleted file mode 100755 index 37e9a25e..00000000 --- a/playwright/compose/warden/build.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash - -echo $REPO_URL -echo $COMMIT_HASH - -if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then - rm -rf /web-vault - - mkdir -p vw_web_builds; - cd vw_web_builds; - - git -c init.defaultBranch=main init - git remote add origin "$REPO_URL" - git fetch --depth 1 origin "$COMMIT_HASH" - git -c advice.detachedHead=false checkout FETCH_HEAD - - npm ci --ignore-scripts - - cd apps/web - npm run dist:oss:selfhost - printf '{"version":"%s"}' "$COMMIT_HASH" > build/vw-version.json - - mv build /web-vault -fi diff --git a/playwright/docker-compose.yml b/playwright/docker-compose.yml deleted file mode 100644 index f4402326..00000000 --- a/playwright/docker-compose.yml +++ /dev/null @@ -1,124 +0,0 @@ -services: - VaultwardenPrebuild: - profiles: ["playwright", "vaultwarden"] - container_name: playwright_oidc_vaultwarden_prebuilt - image: playwright_oidc_vaultwarden_prebuilt - build: - context: .. - dockerfile: Dockerfile - entrypoint: /bin/bash - restart: "no" - - Vaultwarden: - profiles: ["playwright", "vaultwarden"] - container_name: playwright_oidc_vaultwarden-${ENV:-dev} - image: playwright_oidc_vaultwarden-${ENV:-dev} - network_mode: "host" - build: - context: compose/warden - dockerfile: Dockerfile - args: - REPO_URL: ${PW_VW_REPO_URL:-} - COMMIT_HASH: ${PW_VW_COMMIT_HASH:-} - env_file: ${DC_ENV_FILE:-.env} - environment: - - ADMIN_TOKEN - - DATABASE_URL - - I_REALLY_WANT_VOLATILE_STORAGE - - LOG_LEVEL - - LOGIN_RATELIMIT_MAX_BURST - - SMTP_HOST - - SMTP_FROM - - SMTP_DEBUG - - SSO_DEBUG_TOKENS - - SSO_ENABLED - - SSO_FRONTEND - - SSO_ONLY - - SSO_SCOPES - restart: "no" - depends_on: - - VaultwardenPrebuild - - Playwright: - profiles: ["playwright"] - container_name: playwright_oidc_playwright - image: playwright_oidc_playwright - network_mode: "host" - build: - context: . - dockerfile: compose/playwright/Dockerfile - environment: - - PW_WV_REPO_URL - - PW_WV_COMMIT_HASH - restart: "no" - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ..:/project - - Mariadb: - profiles: ["playwright"] - container_name: playwright_mariadb - image: mariadb:11.2.4 - env_file: test.env - healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] - start_period: 10s - interval: 10s - ports: - - ${MARIADB_PORT}:3306 - - Mysql: - profiles: ["playwright"] - container_name: playwright_mysql - image: mysql:8.4.1 - env_file: test.env - healthcheck: - test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] - start_period: 10s - interval: 10s - ports: - - ${MYSQL_PORT}:3306 - - Postgres: - profiles: ["playwright"] - container_name: playwright_postgres - image: postgres:16.3 - env_file: test.env - healthcheck: - test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] - start_period: 20s - interval: 30s - ports: - - ${POSTGRES_PORT}:5432 - - Maildev: - profiles: ["vaultwarden", "maildev"] - container_name: maildev - image: timshel/maildev:3.0.4 - ports: - - ${SMTP_PORT}:1025 - - 1080:1080 - - Keycloak: - profiles: ["keycloak", "vaultwarden"] - container_name: keycloak-${ENV:-dev} - image: quay.io/keycloak/keycloak:26.3.4 - network_mode: "host" - command: - - start-dev - env_file: ${DC_ENV_FILE:-.env} - - KeycloakSetup: - profiles: ["keycloak", "vaultwarden"] - container_name: keycloakSetup-${ENV:-dev} - image: keycloak_setup-${ENV:-dev} - build: - context: compose/keycloak - dockerfile: Dockerfile - args: - KEYCLOAK_VERSION: 26.3.4 - network_mode: "host" - depends_on: - - Keycloak - restart: "no" - env_file: ${DC_ENV_FILE:-.env} diff --git a/playwright/global-setup.ts b/playwright/global-setup.ts deleted file mode 100644 index 89405f12..00000000 --- a/playwright/global-setup.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { firefox, type FullConfig } from '@playwright/test'; -import { execSync } from 'node:child_process'; -import fs from 'fs'; - -const utils = require('./global-utils'); - -utils.loadEnv(); - -async function globalSetup(config: FullConfig) { - // Are we running in docker and the project is mounted ? - const path = (fs.existsSync("/project/playwright/playwright.config.ts") ? "/project/playwright" : "."); - execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build VaultwardenPrebuild`, { - env: { ...process.env }, - stdio: "inherit" - }); - execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build Vaultwarden`, { - env: { ...process.env }, - stdio: "inherit" - }); -} - -export default globalSetup; diff --git a/playwright/global-utils.ts b/playwright/global-utils.ts deleted file mode 100644 index 224bb4b8..00000000 --- a/playwright/global-utils.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { expect, type Browser, type TestInfo } from '@playwright/test'; -import { EventEmitter } from "events"; -import { type Mail, MailServer } from 'maildev'; -import { execSync } from 'node:child_process'; - -import dotenv from 'dotenv'; -import dotenvExpand from 'dotenv-expand'; - -const fs = require("fs"); -const { spawn } = require('node:child_process'); - -export function loadEnv(){ - var myEnv = dotenv.config({ path: 'test.env', quiet: true }); - dotenvExpand.expand(myEnv); - - return { - user1: { - email: process.env.TEST_USER_MAIL, - name: process.env.TEST_USER, - password: process.env.TEST_USER_PASSWORD, - }, - user2: { - email: process.env.TEST_USER2_MAIL, - name: process.env.TEST_USER2, - password: process.env.TEST_USER2_PASSWORD, - }, - user3: { - email: process.env.TEST_USER3_MAIL, - name: process.env.TEST_USER3, - password: process.env.TEST_USER3_PASSWORD, - }, - } -} - -export async function waitFor(url: String, browser: Browser) { - var ready = false; - var context; - - do { - try { - context = await browser.newContext(); - const page = await context.newPage(); - await page.waitForTimeout(500); - const result = await page.goto(url); - ready = result.status() === 200; - } catch(e) { - if( !e.message.includes("CONNECTION_REFUSED") ){ - throw e; - } - } finally { - await context.close(); - } - } while(!ready); -} - -export function startComposeService(serviceName: String){ - console.log(`Starting ${serviceName}`); - execSync(`docker compose --profile playwright --env-file test.env up -d ${serviceName}`); -} - -export function stopComposeService(serviceName: String){ - console.log(`Stopping ${serviceName}`); - execSync(`docker compose --profile playwright --env-file test.env stop ${serviceName}`); -} - -function wipeSqlite(){ - console.log(`Delete Vaultwarden container to wipe sqlite`); - execSync(`docker compose --env-file test.env stop Vaultwarden`); - execSync(`docker compose --env-file test.env rm -f Vaultwarden`); -} - -async function wipeMariaDB(){ - var mysql = require('mysql2/promise'); - var ready = false; - var connection; - - do { - try { - connection = await mysql.createConnection({ - user: process.env.MARIADB_USER, - host: "127.0.0.1", - database: process.env.MARIADB_DATABASE, - password: process.env.MARIADB_PASSWORD, - port: process.env.MARIADB_PORT, - }); - - await connection.execute(`DROP DATABASE ${process.env.MARIADB_DATABASE}`); - await connection.execute(`CREATE DATABASE ${process.env.MARIADB_DATABASE}`); - console.log('Successfully wiped mariadb'); - ready = true; - } catch (err) { - console.log(`Error when wiping mariadb: ${err}`); - } finally { - if( connection ){ - connection.end(); - } - } - await new Promise(r => setTimeout(r, 1000)); - } while(!ready); -} - -async function wipeMysqlDB(){ - var mysql = require('mysql2/promise'); - var ready = false; - var connection; - - do{ - try { - connection = await mysql.createConnection({ - user: process.env.MYSQL_USER, - host: "127.0.0.1", - database: process.env.MYSQL_DATABASE, - password: process.env.MYSQL_PASSWORD, - port: process.env.MYSQL_PORT, - }); - - await connection.execute(`DROP DATABASE ${process.env.MYSQL_DATABASE}`); - await connection.execute(`CREATE DATABASE ${process.env.MYSQL_DATABASE}`); - console.log('Successfully wiped mysql'); - ready = true; - } catch (err) { - console.log(`Error when wiping mysql: ${err}`); - } finally { - if( connection ){ - connection.end(); - } - } - await new Promise(r => setTimeout(r, 1000)); - } while(!ready); -} - -async function wipePostgres(){ - const { Client } = require('pg'); - - const client = new Client({ - user: process.env.POSTGRES_USER, - host: "127.0.0.1", - database: "postgres", - password: process.env.POSTGRES_PASSWORD, - port: process.env.POSTGRES_PORT, - }); - - try { - await client.connect(); - await client.query(`DROP DATABASE ${process.env.POSTGRES_DB}`); - await client.query(`CREATE DATABASE ${process.env.POSTGRES_DB}`); - console.log('Successfully wiped postgres'); - } catch (err) { - console.log(`Error when wiping postgres: ${err}`); - } finally { - client.end(); - } -} - -function dbConfig(testInfo: TestInfo){ - switch(testInfo.project.name) { - case "postgres": - case "sso-postgres": - return { DATABASE_URL: `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@127.0.0.1:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}` }; - case "mariadb": - case "sso-mariadb": - return { DATABASE_URL: `mysql://${process.env.MARIADB_USER}:${process.env.MARIADB_PASSWORD}@127.0.0.1:${process.env.MARIADB_PORT}/${process.env.MARIADB_DATABASE}` }; - case "mysql": - case "sso-mysql": - return { DATABASE_URL: `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PASSWORD}@127.0.0.1:${process.env.MYSQL_PORT}/${process.env.MYSQL_DATABASE}`}; - case "sqlite": - case "sso-sqlite": - return { I_REALLY_WANT_VOLATILE_STORAGE: true }; - default: - throw new Error(`Unknow database name: ${testInfo.project.name}`); - } -} - -/** - * All parameters passed in `env` need to be added to the docker-compose.yml - **/ -export async function startVault(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) { - if( resetDB ){ - switch(testInfo.project.name) { - case "postgres": - case "sso-postgres": - await wipePostgres(); - break; - case "mariadb": - case "sso-mariadb": - await wipeMariaDB(); - break; - case "mysql": - case "sso-mysql": - await wipeMysqlDB(); - break; - case "sqlite": - case "sso-sqlite": - wipeSqlite(); - break; - default: - throw new Error(`Unknow database name: ${testInfo.project.name}`); - } - } - - console.log(`Starting Vaultwarden`); - execSync(`docker compose --profile playwright --env-file test.env up -d Vaultwarden`, { - env: { ...env, ...dbConfig(testInfo) }, - }); - await waitFor("/", browser); - console.log(`Vaultwarden running on: ${process.env.DOMAIN}`); -} - -export async function stopVault(force: boolean = false) { - if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { - console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`); - } else { - console.log(`Vaultwarden stopping`); - execSync(`docker compose --profile playwright --env-file test.env stop Vaultwarden`); - } -} - -export async function restartVault(page: Page, testInfo: TestInfo, env, resetDB: Boolean = true) { - stopVault(true); - return startVault(page.context().browser(), testInfo, env, resetDB); -} - -export async function checkNotification(page: Page, hasText: string) { - await expect(page.locator('bit-toast', { hasText })).toBeVisible(); - try { - await page.locator('bit-toast', { hasText }).getByRole('button', { name: 'Close' }).click({force: true, timeout: 10_000}); - } catch (error) { - console.log(`Closing notification failed but it should now be invisible (${error})`); - } - await expect(page.locator('bit-toast', { hasText })).toHaveCount(0); -} - -export async function cleanLanding(page: Page) { - await page.goto('/', { waitUntil: 'domcontentloaded' }); - await expect(page.getByRole('button').nth(0)).toBeVisible(); - - const logged = await page.getByRole('button', { name: 'Log out' }).count(); - if( logged > 0 ){ - await page.getByRole('button', { name: 'Log out' }).click(); - await page.getByRole('button', { name: 'Log out' }).click(); - } -} - -export async function logout(test: Test, page: Page, user: { name: string }) { - await test.step('logout', async () => { - await page.getByRole('button', { name: user.name, exact: true }).click(); - await page.getByRole('menuitem', { name: 'Log out' }).click(); - await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible(); - }); -} - -export async function ignoreExtension(page: Page) { - await page.waitForLoadState('domcontentloaded'); - - try { - await page.getByRole('button', { name: 'Add it later' }).click({timeout: 5_000}); - await page.getByRole('link', { name: 'Skip to web app' }).click(); - } catch (error) { - console.log('Extension setup not visible. Continuing'); - } - -} diff --git a/playwright/package-lock.json b/playwright/package-lock.json deleted file mode 100644 index 2f4cd0c1..00000000 --- a/playwright/package-lock.json +++ /dev/null @@ -1,2706 +0,0 @@ -{ - "name": "scenarios", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "scenarios", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "mysql2": "3.15.3", - "otpauth": "9.4.1", - "pg": "8.16.3" - }, - "devDependencies": { - "@playwright/test": "1.56.1", - "dotenv": "17.2.3", - "dotenv-expand": "12.0.3", - "maildev": "npm:@timshel_npm/maildev@3.2.5" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz", - "integrity": "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==", - "dev": true, - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.1" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.3", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.3.tgz", - "integrity": "sha512-kiGFeY+Hxf5KbPpjRLf+ffWbkos1aGo8MBfd91oxS3O57RgU3XhZrt/6UzoVF9VMpWbC3v87SRc9jxGrc9qHtQ==", - "dev": true, - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.2" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true - }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.15.tgz", - "integrity": "sha512-q0p6zkVq2lJnmzZVPR33doA51G7YOja+FBvRdp5ISIthL0MtFCgYHHhR563z9WFGxcOn0WfjSkPDJ5Qig3H3Sw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@playwright/test": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", - "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", - "dev": true, - "dependencies": { - "playwright": "1.56.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@selderee/plugin-htmlparser2": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", - "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", - "dev": true, - "dependencies": { - "domhandler": "^5.0.3", - "selderee": "^0.11.0" - }, - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "dev": true - }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/mailparser": { - "version": "3.4.6", - "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz", - "integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==", - "dev": true, - "dependencies": { - "@types/node": "*", - "iconv-lite": "^0.6.3" - } - }, - "node_modules/@types/node": { - "version": "24.2.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", - "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==", - "dev": true, - "dependencies": { - "undici-types": "~7.10.0" - } - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, - "optional": true - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/addressparser": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.1.tgz", - "integrity": "sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==", - "dev": true - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "engines": { - "node": ">= 14" - } - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true - }, - "node_modules/aws-ssl-profiles": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", - "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/base32.js": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", - "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "dev": true, - "engines": { - "node": "^4.5.0 || >= 5.9" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "dev": true, - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/commander": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", - "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", - "dev": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "dev": true, - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "dev": true, - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dev": true, - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "dev": true, - "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/cssstyle": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz", - "integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==", - "dev": true, - "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", - "css-tree": "^3.1.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", - "dev": true, - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/dompurify": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", - "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", - "dev": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", - "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", - "dev": true, - "dependencies": { - "dotenv": "^16.4.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/encoding-japanese": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", - "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", - "dev": true, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/engine.io": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", - "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", - "dev": true, - "dependencies": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.17.1" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "dev": true, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/engine.io/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/engine.io/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/engine.io/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "dev": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "dev": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "dev": true, - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generate-function": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "dependencies": { - "is-property": "^1.0.2" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "bin": { - "he": "bin/he" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/html-to-text": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", - "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", - "dev": true, - "dependencies": { - "@selderee/plugin-htmlparser2": "^0.11.0", - "deepmerge": "^4.3.1", - "dom-serializer": "^2.0.0", - "htmlparser2": "^8.0.2", - "selderee": "^0.11.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/http-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/ipv6-normalize": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz", - "integrity": "sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==", - "dev": true - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true - }, - "node_modules/is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" - }, - "node_modules/jsdom": { - "version": "27.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz", - "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", - "dev": true, - "dependencies": { - "@asamuzakjp/dom-selector": "^6.7.2", - "cssstyle": "^5.3.1", - "data-urls": "^6.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/leac": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", - "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", - "dev": true, - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, - "node_modules/libbase64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", - "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", - "dev": true - }, - "node_modules/libmime": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", - "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", - "dev": true, - "dependencies": { - "encoding-japanese": "2.2.0", - "iconv-lite": "0.6.3", - "libbase64": "1.3.0", - "libqp": "2.1.1" - } - }, - "node_modules/libqp": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", - "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", - "dev": true - }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "dev": true, - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" - }, - "node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "dev": true, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/lru.min": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", - "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", - "engines": { - "bun": ">=1.0.0", - "deno": ">=1.30.0", - "node": ">=8.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wellwelwel" - } - }, - "node_modules/maildev": { - "name": "@timshel_npm/maildev", - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/@timshel_npm/maildev/-/maildev-3.2.5.tgz", - "integrity": "sha512-suWQu2s2kmO+MXtNJYW9peklznhd+aorIUb4tSNrfaKoEJjDa3vLXTvWf+3cb67o4Yv4Z6nPeKdMTCDZVn/Nyw==", - "dev": true, - "dependencies": { - "@types/mailparser": "3.4.6", - "addressparser": "1.0.1", - "async": "3.2.6", - "commander": "14.0.1", - "compression": "1.8.1", - "cors": "2.8.5", - "dompurify": "3.3.0", - "express": "5.1.0", - "jsdom": "27.0.1", - "mailparser": "3.7.5", - "mime": "4.1.0", - "nodemailer": "7.0.9", - "smtp-server": "3.15.0", - "socket.io": "4.8.1", - "wildstring": "1.0.9" - }, - "bin": { - "maildev": "bin/maildev" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/mailparser": { - "version": "3.7.5", - "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.5.tgz", - "integrity": "sha512-o59RgZC+4SyCOn4xRH1mtRiZ1PbEmi6si6Ufnd3tbX/V9zmZN1qcqu8xbXY62H6CwIclOT3ppm5u/wV2nujn4g==", - "dev": true, - "dependencies": { - "encoding-japanese": "2.2.0", - "he": "1.2.0", - "html-to-text": "9.0.5", - "iconv-lite": "0.7.0", - "libmime": "5.3.7", - "linkify-it": "5.0.0", - "mailsplit": "5.4.6", - "nodemailer": "7.0.9", - "punycode.js": "2.3.1", - "tlds": "1.260.0" - } - }, - "node_modules/mailparser/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mailsplit": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.6.tgz", - "integrity": "sha512-M+cqmzaPG/mEiCDmqQUz8L177JZLZmXAUpq38owtpq2xlXlTSw+kntnxRt2xsxVFFV6+T8Mj/U0l5s7s6e0rNw==", - "deprecated": "This package has been renamed to @zone-eu/mailsplit. Please update your dependencies.", - "dev": true, - "dependencies": { - "libbase64": "1.3.0", - "libmime": "5.3.7", - "libqp": "2.1.1" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", - "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa" - ], - "bin": { - "mime": "bin/cli.js" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/mysql2": { - "version": "3.15.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", - "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", - "dependencies": { - "aws-ssl-profiles": "^1.1.1", - "denque": "^2.1.0", - "generate-function": "^2.3.1", - "iconv-lite": "^0.7.0", - "long": "^5.2.1", - "lru.min": "^1.0.0", - "named-placeholders": "^1.1.3", - "seq-queue": "^0.0.5", - "sqlstring": "^2.3.2" - }, - "engines": { - "node": ">= 8.0" - } - }, - "node_modules/mysql2/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/named-placeholders": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", - "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", - "dependencies": { - "lru-cache": "^7.14.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/named-placeholders/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/nodemailer": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz", - "integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/otpauth": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.4.1.tgz", - "integrity": "sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw==", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "funding": { - "url": "https://github.com/hectorm/otpauth?sponsor=1" - } - }, - "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/parseley": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", - "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", - "dev": true, - "dependencies": { - "leac": "^0.6.0", - "peberminta": "^0.9.0" - }, - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "dev": true, - "engines": { - "node": ">=16" - } - }, - "node_modules/peberminta": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", - "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", - "dev": true, - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, - "node_modules/pg": { - "version": "8.16.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", - "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", - "dependencies": { - "pg-connection-string": "^2.9.1", - "pg-pool": "^3.10.1", - "pg-protocol": "^1.10.3", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.2.7" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", - "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", - "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", - "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", - "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "dependencies": { - "split2": "^4.1.0" - } - }, - "node_modules/playwright": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", - "dev": true, - "dependencies": { - "playwright-core": "1.56.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", - "dev": true, - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", - "dev": true, - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/router/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/selderee": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", - "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", - "dev": true, - "dependencies": { - "parseley": "^0.12.0" - }, - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "dev": true, - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/send/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/seq-queue": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" - }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "dev": true, - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/smtp-server": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.15.0.tgz", - "integrity": "sha512-yv945vk0/xcukSKAoIhGz6GOlcXoCyGQH2w9IlLrTKk3SJiOBH9bcO6tD0ILTZYJsMqRa6OTRZAyqeuLXkv59Q==", - "dev": true, - "dependencies": { - "base32.js": "0.1.0", - "ipv6-normalize": "1.0.1", - "nodemailer": "7.0.9", - "punycode.js": "2.3.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/socket.io": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", - "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", - "dev": true, - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.3.2", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/socket.io-adapter": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", - "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", - "dev": true, - "dependencies": { - "debug": "~4.3.4", - "ws": "~8.17.1" - } - }, - "node_modules/socket.io-adapter/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io-adapter/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "dev": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", - "dev": true, - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io-parser/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/socket.io/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/socket.io/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/socket.io/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/socket.io/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/socket.io/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/sqlstring": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true - }, - "node_modules/tlds": { - "version": "1.260.0", - "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.260.0.tgz", - "integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==", - "dev": true, - "bin": { - "tlds": "bin.js" - } - }, - "node_modules/tldts": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", - "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", - "dev": true, - "dependencies": { - "tldts-core": "^7.0.17" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", - "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", - "dev": true - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "dev": true, - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "dev": true - }, - "node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", - "dev": true - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", - "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", - "dev": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dev": true, - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", - "dev": true, - "dependencies": { - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/wildstring": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/wildstring/-/wildstring-1.0.9.tgz", - "integrity": "sha512-XBNxKIMLO6uVHf1Xvo++HGWAZZoiVCHmEMCmZJzJ82vQsuUJCLw13Gzq0mRCATk7a3+ZcgeOKSDioavuYqtlfA==", - "dev": true - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "engines": { - "node": ">=0.4" - } - } - } -} diff --git a/playwright/package.json b/playwright/package.json deleted file mode 100644 index f47ec5dc..00000000 --- a/playwright/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "scenarios", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": {}, - "keywords": [], - "author": "", - "license": "ISC", - "devDependencies": { - "@playwright/test": "1.56.1", - "dotenv": "17.2.3", - "dotenv-expand": "12.0.3", - "maildev": "npm:@timshel_npm/maildev@3.2.5" - }, - "dependencies": { - "mysql2": "3.15.3", - "otpauth": "9.4.1", - "pg": "8.16.3" - } -} diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts deleted file mode 100644 index de721aa3..00000000 --- a/playwright/playwright.config.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; -import { exec } from 'node:child_process'; - -const utils = require('./global-utils'); - -utils.loadEnv(); - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: './.', - /* Run tests in files in parallel */ - fullyParallel: false, - - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - - retries: 0, - workers: 1, - - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', - - /* Long global timeout for complex tests - * But short action/nav/expect timeouts to fail on specific step (raise locally if not enough). - */ - timeout: 120 * 1000, - actionTimeout: 20 * 1000, - navigationTimeout: 20 * 1000, - expect: { timeout: 20 * 1000 }, - - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: process.env.DOMAIN, - browserName: 'firefox', - locale: 'en-GB', - timezoneId: 'Europe/London', - - /* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */ - trace: 'on', - viewport: { - width: 1080, - height: 720, - }, - video: "on", - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: 'mariadb-setup', - testMatch: 'tests/setups/db-setup.ts', - use: { serviceName: "Mariadb" }, - teardown: 'mariadb-teardown', - }, - { - name: 'mysql-setup', - testMatch: 'tests/setups/db-setup.ts', - use: { serviceName: "Mysql" }, - teardown: 'mysql-teardown', - }, - { - name: 'postgres-setup', - testMatch: 'tests/setups/db-setup.ts', - use: { serviceName: "Postgres" }, - teardown: 'postgres-teardown', - }, - { - name: 'sso-setup', - testMatch: 'tests/setups/sso-setup.ts', - teardown: 'sso-teardown', - }, - - { - name: 'mariadb', - testMatch: 'tests/*.spec.ts', - testIgnore: 'tests/sso_*.spec.ts', - dependencies: ['mariadb-setup'], - }, - { - name: 'mysql', - testMatch: 'tests/*.spec.ts', - testIgnore: 'tests/sso_*.spec.ts', - dependencies: ['mysql-setup'], - }, - { - name: 'postgres', - testMatch: 'tests/*.spec.ts', - testIgnore: 'tests/sso_*.spec.ts', - dependencies: ['postgres-setup'], - }, - { - name: 'sqlite', - testMatch: 'tests/*.spec.ts', - testIgnore: 'tests/sso_*.spec.ts', - }, - - { - name: 'sso-mariadb', - testMatch: 'tests/sso_*.spec.ts', - dependencies: ['sso-setup', 'mariadb-setup'], - }, - { - name: 'sso-mysql', - testMatch: 'tests/sso_*.spec.ts', - dependencies: ['sso-setup', 'mysql-setup'], - }, - { - name: 'sso-postgres', - testMatch: 'tests/sso_*.spec.ts', - dependencies: ['sso-setup', 'postgres-setup'], - }, - { - name: 'sso-sqlite', - testMatch: 'tests/sso_*.spec.ts', - dependencies: ['sso-setup'], - }, - - { - name: 'mariadb-teardown', - testMatch: 'tests/setups/db-teardown.ts', - use: { serviceName: "Mariadb" }, - }, - { - name: 'mysql-teardown', - testMatch: 'tests/setups/db-teardown.ts', - use: { serviceName: "Mysql" }, - }, - { - name: 'postgres-teardown', - testMatch: 'tests/setups/db-teardown.ts', - use: { serviceName: "Postgres" }, - }, - { - name: 'sso-teardown', - testMatch: 'tests/setups/sso-teardown.ts', - }, - ], - - globalSetup: require.resolve('./global-setup'), -}); diff --git a/playwright/test.env b/playwright/test.env deleted file mode 100644 index df182ebe..00000000 --- a/playwright/test.env +++ /dev/null @@ -1,98 +0,0 @@ -################################################################## -### Shared Playwright conf test file Vaultwarden and Databases ### -################################################################## - -ENV=test -DC_ENV_FILE=test.env -COMPOSE_IGNORE_ORPHANS=True -DOCKER_BUILDKIT=1 - -##################### -# Playwright Config # -##################### -PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false} -PW_SMTP_FROM=vaultwarden@playwright.test - -##################### -# Maildev Config # -##################### -MAILDEV_HTTP_PORT=1081 -MAILDEV_SMTP_PORT=1026 -MAILDEV_HOST=127.0.0.1 - -################ -# Users Config # -################ -TEST_USER=test -TEST_USER_PASSWORD=Master Password -TEST_USER_MAIL=${TEST_USER}@example.com - -TEST_USER2=test2 -TEST_USER2_PASSWORD=Master Password -TEST_USER2_MAIL=${TEST_USER2}@example.com - -TEST_USER3=test3 -TEST_USER3_PASSWORD=Master Password -TEST_USER3_MAIL=${TEST_USER3}@example.com - -################### -# Keycloak Config # -################### -KEYCLOAK_ADMIN=admin -KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} -KC_HTTP_HOST=127.0.0.1 -KC_HTTP_PORT=8081 - -# Script parameters (use Keycloak and Vaultwarden config too) -TEST_REALM=test -DUMMY_REALM=dummy -DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} - -###################### -# Vaultwarden Config # -###################### -ROCKET_PORT=8003 -DOMAIN=http://localhost:${ROCKET_PORT} -LOG_LEVEL=info,oidcwarden::sso=debug -LOGIN_RATELIMIT_MAX_BURST=100 -ADMIN_TOKEN=admin - -SMTP_SECURITY=off -SMTP_PORT=${MAILDEV_SMTP_PORT} -SMTP_FROM_NAME=Vaultwarden -SMTP_TIMEOUT=5 - -SSO_CLIENT_ID=warden -SSO_CLIENT_SECRET=warden -SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} -SSO_DEBUG_TOKENS=true - -# Custom web-vault build -# PW_VW_REPO_URL=https://github.com/vaultwarden/vw_web_builds.git -# PW_VW_COMMIT_HASH=b5f5b2157b9b64b5813bc334a75a277d0377b5d3 - -########################### -# Docker MariaDb container# -########################### -MARIADB_PORT=3307 -MARIADB_ROOT_PASSWORD=warden -MARIADB_USER=warden -MARIADB_PASSWORD=warden -MARIADB_DATABASE=warden - -########################### -# Docker Mysql container# -########################### -MYSQL_PORT=3309 -MYSQL_ROOT_PASSWORD=warden -MYSQL_USER=warden -MYSQL_PASSWORD=warden -MYSQL_DATABASE=warden - -############################ -# Docker Postgres container# -############################ -POSTGRES_PORT=5433 -POSTGRES_USER=warden -POSTGRES_PASSWORD=warden -POSTGRES_DB=warden diff --git a/playwright/tests/collection.spec.ts b/playwright/tests/collection.spec.ts deleted file mode 100644 index 786a4644..00000000 --- a/playwright/tests/collection.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { test, expect, type TestInfo } from '@playwright/test'; - -import * as utils from "../global-utils"; -import { createAccount } from './setups/user'; - -let users = utils.loadEnv(); - -test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVault(browser, testInfo); -}); - -test.afterAll('Teardown', async ({}) => { - utils.stopVault(); -}); - -test('Create', async ({ page }) => { - await createAccount(test, page, users.user1); - - await test.step('Create Org', async () => { - await page.getByRole('link', { name: 'New organisation' }).click(); - await page.getByLabel('Organisation name (required)').fill('Test'); - await page.getByRole('button', { name: 'Submit' }).click(); - await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); - - await utils.checkNotification(page, 'Organisation created'); - }); - - await test.step('Create Collection', async () => { - await page.getByRole('link', { name: 'Collections' }).click(); - await page.getByRole('button', { name: 'New' }).click(); - await page.getByRole('menuitem', { name: 'Collection' }).click(); - await page.getByLabel('Name (required)').fill('RandomCollec'); - await page.getByRole('button', { name: 'Save' }).click(); - await utils.checkNotification(page, 'Created collection RandomCollec'); - await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible(); - }); -}); diff --git a/playwright/tests/login.smtp.spec.ts b/playwright/tests/login.smtp.spec.ts deleted file mode 100644 index 87474b79..00000000 --- a/playwright/tests/login.smtp.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { test, expect, type TestInfo } from '@playwright/test'; -import { MailDev } from 'maildev'; - -const utils = require('../global-utils'); -import { createAccount, logUser } from './setups/user'; -import { activateEmail, retrieveEmailCode, disableEmail } 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(); - } -}); - -test('Account creation', async ({ page }) => { - const mailBuffer = mailserver.buffer(users.user1.email); - - await createAccount(test, page, users.user1, mailBuffer); - - mailBuffer.close(); -}); - -test('Login', async ({ context, page }) => { - const mailBuffer = mailserver.buffer(users.user1.email); - - await logUser(test, page, users.user1, mailBuffer); - - await test.step('verify email', async () => { - await page.getByText('Verify your account\'s email').click(); - await expect(page.getByText('Verify your account\'s email')).toBeVisible(); - await page.getByRole('button', { name: 'Send email' }).click(); - - await utils.checkNotification(page, 'Check your email inbox for a verification link'); - - const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email"); - expect(verify.from[0]?.address).toBe(process.env.PW_SMTP_FROM); - - const page2 = await context.newPage(); - await page2.setContent(verify.html); - const link = await page2.getByTestId("verify").getAttribute("href"); - await page2.close(); - - await page.goto(link); - await utils.checkNotification(page, 'Account email verified'); - }); - - mailBuffer.close(); -}); - -test('Activate 2fa', async ({ page }) => { - const emails = mailserver.buffer(users.user1.email); - - await logUser(test, page, users.user1); - - await activateEmail(test, page, users.user1, emails); - - emails.close(); -}); - -test('2fa', async ({ page }) => { - const emails = mailserver.buffer(users.user1.email); - - await test.step('login', async () => { - await page.goto('/'); - - await page.getByLabel(/Email address/).fill(users.user1.email); - await page.getByRole('button', { name: 'Continue' }).click(); - await page.getByLabel('Master password').fill(users.user1.password); - await page.getByRole('button', { name: 'Log in with master password' }).click(); - - await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - const code = await retrieveEmailCode(test, page, emails); - await page.getByLabel(/Verification code/).fill(code); - await page.getByRole('button', { name: 'Continue' }).click(); - - await page.getByRole('button', { name: 'Add it later' }).click(); - await page.getByRole('link', { name: 'Skip to web app' }).click(); - - await expect(page).toHaveTitle(/Vaults/); - }) - - await disableEmail(test, page, users.user1); - - emails.close(); -}); diff --git a/playwright/tests/login.spec.ts b/playwright/tests/login.spec.ts deleted file mode 100644 index aaac4708..00000000 --- a/playwright/tests/login.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { test, expect, type Page, type TestInfo } from '@playwright/test'; -import * as OTPAuth from "otpauth"; - -import * as utils from "../global-utils"; -import { createAccount, logUser } from './setups/user'; -import { activateTOTP, disableTOTP } from './setups/2fa'; - -let users = utils.loadEnv(); -let totp; - -test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVault(browser, testInfo, {}); -}); - -test.afterAll('Teardown', async ({}) => { - utils.stopVault(); -}); - -test('Account creation', async ({ page }) => { - await createAccount(test, page, users.user1); -}); - -test('Master password login', async ({ page }) => { - await logUser(test, page, users.user1); -}); - -test('Authenticator 2fa', async ({ page }) => { - await logUser(test, page, users.user1); - - let totp = await activateTOTP(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.getByLabel('Master password').fill(users.user1.password); - await page.getByRole('button', { name: 'Log in with master password' }).click(); - - await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - await page.getByLabel(/Verification code/).fill(totp.generate({timestamp})); - await page.getByRole('button', { name: 'Continue' }).click(); - - await expect(page).toHaveTitle(/Vaultwarden Web/); - }); - - await disableTOTP(test, page, users.user1); -}); diff --git a/playwright/tests/organization.smtp.spec.ts b/playwright/tests/organization.smtp.spec.ts deleted file mode 100644 index 35dfcdb1..00000000 --- a/playwright/tests/organization.smtp.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { test, expect, type TestInfo } from '@playwright/test'; -import { MailDev } from 'maildev'; - -import * as utils from '../global-utils'; -import * as orgs from './setups/orgs'; -import { createAccount, logUser } from './setups/user'; - -let users = utils.loadEnv(); - -let mailServer, mail1Buffer, mail2Buffer, mail3Buffer; - -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, - }); - - mail1Buffer = mailServer.buffer(users.user1.email); - mail2Buffer = mailServer.buffer(users.user2.email); - mail3Buffer = mailServer.buffer(users.user3.email); -}); - -test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { - utils.stopVault(testInfo); - [mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); -}); - -test('Create user3', async ({ page }) => { - await createAccount(test, page, users.user3, mail3Buffer); -}); - -test('Invite users', async ({ page }) => { - await createAccount(test, page, users.user1, mail1Buffer); - - await orgs.create(test, page, 'Test'); - await orgs.members(test, page, 'Test'); - await orgs.invite(test, page, 'Test', users.user2.email); - await orgs.invite(test, page, 'Test', users.user3.email, { - navigate: false, - }); -}); - -test('invited with new account', async ({ page }) => { - const invited = await mail2Buffer.expect((mail) => mail.subject === 'Join Test'); - - await test.step('Create account', async () => { - await page.setContent(invited.html); - const link = await page.getByTestId('invite').getAttribute('href'); - await page.goto(link); - await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); - - //await page.getByLabel('Name').fill(users.user2.name); - await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password); - await page.getByLabel('Confirm master password (').fill(users.user2.password); - await page.getByRole('button', { name: 'Create account' }).click(); - await utils.checkNotification(page, 'Your new account has been created'); - - await utils.checkNotification(page, 'Invitation accepted'); - await utils.ignoreExtension(page); - - // Redirected to the vault - await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); - // await utils.checkNotification(page, 'You have been logged in!'); - }); - - await test.step('Check mails', async () => { - await mail2Buffer.expect((m) => m.subject === 'Welcome'); - await mail2Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); - await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); - }); -}); - -test('invited with existing account', async ({ page }) => { - const invited = await mail3Buffer.expect((mail) => mail.subject === 'Join Test'); - - await page.setContent(invited.html); - const link = await page.getByTestId('invite').getAttribute('href'); - - await page.goto(link); - - // We should be on login page with email prefilled - await expect(page).toHaveTitle(/Vaultwarden Web/); - await page.getByRole('button', { name: 'Continue' }).click(); - - // Unlock page - await page.getByLabel('Master password').fill(users.user3.password); - await page.getByRole('button', { name: 'Log in with master password' }).click(); - - await utils.checkNotification(page, 'Invitation accepted'); - await utils.ignoreExtension(page); - - // We are now in the default vault page - await expect(page).toHaveTitle(/Vaultwarden Web/); - - await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); - await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); -}); - -test('Confirm invited user', async ({ page }) => { - await logUser(test, page, users.user1, mail1Buffer); - - await orgs.members(test, page, 'Test'); - await orgs.confirm(test, page, 'Test', users.user2.email); - - await mail2Buffer.expect((m) => m.subject.includes('Invitation to Test confirmed')); -}); - -test('Organization is visible', async ({ page }) => { - await logUser(test, page, users.user2, mail2Buffer); - await page.getByRole('button', { name: 'vault: Test', exact: true }).click(); - await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); -}); diff --git a/playwright/tests/organization.spec.ts b/playwright/tests/organization.spec.ts deleted file mode 100644 index 4e644fa7..00000000 --- a/playwright/tests/organization.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { test, expect, type TestInfo } from '@playwright/test'; -import { MailDev } from 'maildev'; - -import * as utils from "../global-utils"; -import * as orgs from './setups/orgs'; -import { createAccount, logUser } from './setups/user'; - -let users = utils.loadEnv(); - -test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVault(browser, testInfo); -}); - -test.afterAll('Teardown', async ({}) => { - utils.stopVault(); -}); - -test('Invite', async ({ page }) => { - await createAccount(test, page, users.user3); - await createAccount(test, page, users.user1); - - await orgs.create(test, page, 'New organisation'); - await orgs.members(test, page, 'New organisation'); - - await test.step('missing user2', async () => { - await orgs.invite(test, page, 'New organisation', users.user2.email); - await expect(page.getByRole('row', { name: users.user2.email })).toHaveText(/Invited/); - }); - - await test.step('existing user3', async () => { - await orgs.invite(test, page, 'New organisation', users.user3.email); - await expect(page.getByRole('row', { name: users.user3.email })).toHaveText(/Needs confirmation/); - await orgs.confirm(test, page, 'New organisation', users.user3.email); - }); - - await test.step('confirm user2', async () => { - await createAccount(test, page, users.user2); - await logUser(test, page, users.user1); - await orgs.members(test, page, 'New organisation'); - await orgs.confirm(test, page, 'New organisation', users.user2.email); - }); - - await test.step('Org visible user2 ', async () => { - await logUser(test, page, users.user2); - await page.getByRole('button', { name: 'vault: New organisation', exact: true }).click(); - await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); - }); - - await test.step('Org visible user3 ', async () => { - await logUser(test, page, users.user3); - await page.getByRole('button', { name: 'vault: New organisation', exact: true }).click(); - await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); - }); -}); diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts deleted file mode 100644 index d7936420..00000000 --- a/playwright/tests/setups/2fa.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { expect, type Page, Test } from '@playwright/test'; -import { type MailBuffer } from 'maildev'; -import * as OTPAuth from "otpauth"; - -import * as utils from '../../global-utils'; - -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(); - 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.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); - await page.getByLabel('Master password (required)').fill(user.password); - await page.getByRole('button', { name: 'Continue' }).click(); - - const secret = await page.getByLabel('Key').innerText(); - let totp = new OTPAuth.TOTP({ secret, period: 30 }); - - 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; - }) -} - -export async function disableTOTP(test: Test, page: Page, user: { password: string }) { - await test.step('Disable TOTP 2FA', async () => { - await page.getByRole('button', { name: 'Test' }).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.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); - await page.getByLabel('Master password (required)').click(); - await page.getByLabel('Master password (required)').fill(user.password); - await page.getByRole('button', { name: 'Continue' }).click(); - await page.getByRole('button', { name: 'Turn off' }).click(); - await page.getByRole('button', { name: 'Yes' }).click(); - await utils.checkNotification(page, 'Two-step login provider turned off'); - }); -} - -export async function activateEmail(test: Test, page: Page, user: { name: string, password: string }, mailBuffer: MailBuffer) { - await test.step('Activate Email 2FA', 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.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click(); - await page.getByLabel('Master password (required)').fill(user.password); - await page.getByRole('button', { name: 'Continue' }).click(); - await page.getByRole('button', { name: 'Send email' }).click(); - }); - - let code = await retrieveEmailCode(test, page, mailBuffer); - - await test.step('input code', async () => { - await page.getByLabel('2. Enter the resulting 6').fill(code); - await page.getByRole('button', { name: 'Turn on' }).click(); - await page.getByRole('heading', { name: 'Turned on', exact: true }); - }); -} - -export async function retrieveEmailCode(test: Test, page: Page, mailBuffer: MailBuffer): string { - return await test.step('retrieve code', async () => { - const codeMail = await mailBuffer.expect((mail) => mail.subject.includes("Login Verification Code")); - const page2 = await page.context().newPage(); - await page2.setContent(codeMail.html); - const code = await page2.getByTestId("2fa").innerText(); - await page2.close(); - return code; - }); -} - -export async function disableEmail(test: Test, page: Page, user: { password: string }) { - await test.step('Disable Email 2FA', async () => { - await page.getByRole('button', { name: 'Test' }).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.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click(); - await page.getByLabel('Master password (required)').click(); - await page.getByLabel('Master password (required)').fill(user.password); - await page.getByRole('button', { name: 'Continue' }).click(); - await page.getByRole('button', { name: 'Turn off' }).click(); - await page.getByRole('button', { name: 'Yes' }).click(); - - await utils.checkNotification(page, 'Two-step login provider turned off'); - }); -} diff --git a/playwright/tests/setups/db-setup.ts b/playwright/tests/setups/db-setup.ts deleted file mode 100644 index eb37fdc1..00000000 --- a/playwright/tests/setups/db-setup.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { test } from './db-test'; - -const utils = require('../../global-utils'); - -test('DB start', async ({ serviceName }) => { - utils.startComposeService(serviceName); -}); diff --git a/playwright/tests/setups/db-teardown.ts b/playwright/tests/setups/db-teardown.ts deleted file mode 100644 index 5f753a9d..00000000 --- a/playwright/tests/setups/db-teardown.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { test } from './db-test'; - -const utils = require('../../global-utils'); - -utils.loadEnv(); - -test('DB teardown ?', async ({ serviceName }) => { - if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) { - utils.stopComposeService(serviceName); - } -}); diff --git a/playwright/tests/setups/db-test.ts b/playwright/tests/setups/db-test.ts deleted file mode 100644 index 4a72d37c..00000000 --- a/playwright/tests/setups/db-test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { test as base } from '@playwright/test'; - -export type TestOptions = { - serviceName: string; -}; - -export const test = base.extend({ - serviceName: ['', { option: true }], -}); diff --git a/playwright/tests/setups/orgs.ts b/playwright/tests/setups/orgs.ts deleted file mode 100644 index 04d81b45..00000000 --- a/playwright/tests/setups/orgs.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { expect, type Browser,Page } from '@playwright/test'; - -import * as utils from '../../global-utils'; - -export async function create(test, page: Page, name: string) { - await test.step('Create Org', async () => { - await page.locator('a').filter({ hasText: 'Password Manager' }).first().click(); - await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); - await page.getByRole('link', { name: 'New organisation' }).click(); - await page.getByLabel('Organisation name (required)').fill(name); - await page.getByRole('button', { name: 'Submit' }).click(); - - await utils.checkNotification(page, 'Organisation created'); - }); -} - -export async function policies(test, page: Page, name: string) { - await test.step(`Navigate to ${name} policies`, async () => { - await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); - await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); - await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); - await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); - await page.getByRole('button', { name: 'Toggle collapse Settings' }).click(); - await page.getByRole('link', { name: 'Policies' }).click(); - await expect(page.getByRole('heading', { name: 'Policies' })).toBeVisible(); - }); -} - -export async function members(test, page: Page, name: string) { - await test.step(`Navigate to ${name} members`, async () => { - await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); - await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); - await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); - await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); - await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); - await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); - await expect(page.getByRole('cell', { name: 'All' })).toBeVisible(); - }); -} - -export async function invite(test, page: Page, name: string, email: string) { - await test.step(`Invite ${email}`, async () => { - await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); - await page.getByRole('button', { name: 'Invite member' }).click(); - await page.getByLabel('Email (required)').fill(email); - await page.getByRole('tab', { name: 'Collections' }).click(); - await page.getByRole('combobox', { name: 'Permission' }).click(); - await page.getByText('Edit items', { exact: true }).click(); - await page.getByLabel('Select collections').click(); - await page.getByText('Default collection').click(); - await page.getByRole('cell', { name: 'Collection', exact: true }).click(); - await page.getByRole('button', { name: 'Save' }).click(); - await utils.checkNotification(page, 'User(s) invited'); - }); -} - -export async function confirm(test, page: Page, name: string, user_email: string) { - await test.step(`Confirm ${user_email}`, async () => { - await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); - await page.getByRole('row').filter({hasText: user_email}).getByLabel('Options').click(); - await page.getByRole('menuitem', { name: 'Confirm' }).click(); - await expect(page.getByRole('heading', { name: 'Confirm user' })).toBeVisible(); - await page.getByRole('button', { name: 'Confirm' }).click(); - await utils.checkNotification(page, 'confirmed'); - }); -} - -export async function revoke(test, page: Page, name: string, user_email: string) { - await test.step(`Revoke ${user_email}`, async () => { - await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); - await page.getByRole('row').filter({hasText: user_email}).getByLabel('Options').click(); - await page.getByRole('menuitem', { name: 'Revoke access' }).click(); - await expect(page.getByRole('heading', { name: 'Revoke access' })).toBeVisible(); - await page.getByRole('button', { name: 'Revoke access' }).click(); - await utils.checkNotification(page, 'Revoked organisation access'); - }); -} diff --git a/playwright/tests/setups/sso-setup.ts b/playwright/tests/setups/sso-setup.ts deleted file mode 100644 index 2ac92c0b..00000000 --- a/playwright/tests/setups/sso-setup.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { test, expect, type TestInfo } from '@playwright/test'; - -const { exec } = require('node:child_process'); -const utils = require('../../global-utils'); - -utils.loadEnv(); - -test.beforeAll('Setup', async () => { - console.log("Starting Keycloak"); - exec(`docker compose --profile keycloak --env-file test.env up`); -}); - -test('Keycloak is up', async ({ page }) => { - await utils.waitFor(process.env.SSO_AUTHORITY, page.context().browser()); - // Dummy authority is created at the end of the setup - await utils.waitFor(process.env.DUMMY_AUTHORITY, page.context().browser()); - console.log(`Keycloak running on: ${process.env.SSO_AUTHORITY}`); -}); diff --git a/playwright/tests/setups/sso-teardown.ts b/playwright/tests/setups/sso-teardown.ts deleted file mode 100644 index 2899afff..00000000 --- a/playwright/tests/setups/sso-teardown.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { test, type FullConfig } from '@playwright/test'; - -const { execSync } = require('node:child_process'); -const utils = require('../../global-utils'); - -utils.loadEnv(); - -test('Keycloak teardown', async () => { - if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { - console.log("Keep Keycloak running"); - } else { - console.log("Keycloak stopping"); - execSync(`docker compose --profile keycloak --env-file test.env stop Keycloak`); - } -}); diff --git a/playwright/tests/setups/sso.ts b/playwright/tests/setups/sso.ts deleted file mode 100644 index 6317f8b0..00000000 --- a/playwright/tests/setups/sso.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { expect, type Page, Test } from '@playwright/test'; -import { type MailBuffer, MailServer } from 'maildev'; -import * as OTPAuth from "otpauth"; - -import * as utils from '../../global-utils'; -import { retrieveEmailCode } from './2fa'; - -/** - * If a MailBuffer is passed it will be used and consume the expected emails - */ -export async function logNewUser( - test: Test, - page: Page, - user: { email: string, name: string, password: string }, - options: { mailBuffer?: MailBuffer } = {} -) { - await test.step(`Create user ${user.name}`, async () => { - await page.context().clearCookies(); - - await test.step('Landing page', async () => { - await utils.cleanLanding(page); - - await page.locator("input[type=email].vw-email-sso").fill(user.email); - await page.getByRole('button', { name: /Use single sign-on/ }).click(); - }); - - await test.step('Keycloak login', async () => { - await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); - await page.getByLabel(/Username/).fill(user.name); - await page.getByLabel('Password', { exact: true }).fill(user.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - }); - - await test.step('Create Vault account', async () => { - await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); - await page.getByLabel('Master password (required)', { exact: true }).fill(user.password); - await page.getByLabel('Confirm master password (').fill(user.password); - await page.getByRole('button', { name: 'Create account' }).click(); - }); - - await utils.checkNotification(page, 'Account successfully created!'); - await utils.checkNotification(page, 'Invitation accepted'); - - await utils.ignoreExtension(page); - - await test.step('Default vault page', async () => { - await expect(page).toHaveTitle(/Vaultwarden Web/); - await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); - }); - - if( options.mailBuffer ){ - let mailBuffer = options.mailBuffer; - await test.step('Check emails', async () => { - await mailBuffer.expect((m) => m.subject === "Welcome"); - await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); - }); - } - }); -} - -/** - * If a MailBuffer is passed it will be used and consume the expected emails - */ -export async function logUser( - test: Test, - page: Page, - user: { email: string, password: string }, - options: { - mailBuffer ?: MailBuffer, - totp?: OTPAuth.TOTP, - mail2fa?: boolean, - } = {} -) { - let mailBuffer = options.mailBuffer; - - await test.step(`Log user ${user.email}`, async () => { - await page.context().clearCookies(); - - await test.step('Landing page', async () => { - await utils.cleanLanding(page); - - await page.locator("input[type=email].vw-email-sso").fill(user.email); - await page.getByRole('button', { name: /Use single sign-on/ }).click(); - }); - - await test.step('Keycloak login', async () => { - await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); - await page.getByLabel(/Username/).fill(user.name); - await page.getByLabel('Password', { exact: true }).fill(user.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - }); - - if( options.totp || options.mail2fa ){ - let code; - - 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, mailBuffer); - } - - await page.getByLabel(/Verification code/).fill(code); - await page.getByRole('button', { name: 'Continue' }).click(); - }); - } - - await test.step('Unlock vault', async () => { - await expect(page).toHaveTitle('Vaultwarden Web'); - await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible(); - await page.getByLabel('Master password').fill(user.password); - await page.getByRole('button', { name: 'Unlock' }).click(); - }); - - await utils.ignoreExtension(page); - - await test.step('Default vault page', async () => { - await expect(page).toHaveTitle(/Vaultwarden Web/); - await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); - }); - - if( mailBuffer ){ - await test.step('Check email', async () => { - await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); - }); - } - }); -} diff --git a/playwright/tests/setups/user.ts b/playwright/tests/setups/user.ts deleted file mode 100644 index 395196ae..00000000 --- a/playwright/tests/setups/user.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { expect, type Browser, Page } from '@playwright/test'; - -import { type MailBuffer } from 'maildev'; - -import * as utils from '../../global-utils'; - -export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) { - await test.step(`Create user ${user.name}`, async () => { - await utils.cleanLanding(page); - - await page.getByRole('link', { name: 'Create account' }).click(); - - // Back to Vault create account - await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); - await page.getByLabel(/Email address/).fill(user.email); - await page.getByLabel('Name').fill(user.name); - await page.getByRole('button', { name: 'Continue' }).click(); - - // Vault finish Creation - await page.getByLabel('Master password (required)', { exact: true }).fill(user.password); - await page.getByLabel('Confirm master password (').fill(user.password); - await page.getByRole('button', { name: 'Create account' }).click(); - - await utils.checkNotification(page, 'Your new account has been created') - await utils.ignoreExtension(page); - - // We are now in the default vault page - await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); - // await utils.checkNotification(page, 'You have been logged in!'); - - if( mailBuffer ){ - await mailBuffer.expect((m) => m.subject === "Welcome"); - await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); - } - }); -} - -export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) { - await test.step(`Log user ${user.email}`, async () => { - await utils.cleanLanding(page); - - await page.getByLabel(/Email address/).fill(user.email); - await page.getByRole('button', { name: 'Continue' }).click(); - - // Unlock page - await page.getByLabel('Master password').fill(user.password); - await page.getByRole('button', { name: 'Log in with master password' }).click(); - - await utils.ignoreExtension(page); - - // We are now in the default vault page - await expect(page).toHaveTitle(/Vaultwarden Web/); - - if( mailBuffer ){ - await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); - } - }); -} diff --git a/playwright/tests/sso_login.smtp.spec.ts b/playwright/tests/sso_login.smtp.spec.ts deleted file mode 100644 index 7a615cd6..00000000 --- a/playwright/tests/sso_login.smtp.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { test, expect, type TestInfo } from '@playwright/test'; -import { MailDev } from 'maildev'; - -import { logNewUser, logUser } from './setups/sso'; -import { activateEmail, disableEmail } from './setups/2fa'; -import * as utils from "../global-utils"; - -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, { - SSO_ENABLED: true, - SSO_ONLY: false, - SMTP_HOST: process.env.MAILDEV_HOST, - SMTP_FROM: process.env.PW_SMTP_FROM, - }); -}); - -test.afterAll('Teardown', async ({}) => { - utils.stopVault(); - if( mailserver ){ - await mailserver.close(); - } -}); - -test('Create and activate 2FA', async ({ page }) => { - const mailBuffer = mailserver.buffer(users.user1.email); - - await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer}); - - await activateEmail(test, page, users.user1, mailBuffer); - - mailBuffer.close(); -}); - -test('Log and disable', async ({ page }) => { - const mailBuffer = mailserver.buffer(users.user1.email); - - await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true}); - - await disableEmail(test, page, users.user1); - - mailBuffer.close(); -}); diff --git a/playwright/tests/sso_login.spec.ts b/playwright/tests/sso_login.spec.ts deleted file mode 100644 index 8a1bb9ab..00000000 --- a/playwright/tests/sso_login.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { test, expect, type TestInfo } from '@playwright/test'; - -import { logNewUser, logUser } from './setups/sso'; -import { activateTOTP, disableTOTP } from './setups/2fa'; -import * as utils from "../global-utils"; - -let users = utils.loadEnv(); - -test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVault(browser, testInfo, { - SSO_ENABLED: true, - SSO_ONLY: false - }); -}); - -test.afterAll('Teardown', async ({}) => { - utils.stopVault(); -}); - -test('Account creation using SSO', async ({ page }) => { - // Landing page - await logNewUser(test, page, users.user1); -}); - -test('SSO login', async ({ page }) => { - await logUser(test, page, users.user1); -}); - -test('Non SSO login', async ({ page }) => { - // Landing page - await page.goto('/'); - await page.locator("input[type=email].vw-email-sso").fill(users.user1.email); - await page.getByRole('button', { name: 'Other' }).click(); - - // Unlock page - await page.getByLabel('Master password').fill(users.user1.password); - await page.getByRole('button', { name: 'Log in with master password' }).click(); - - // We are now in the default vault page - await expect(page).toHaveTitle(/Vaultwarden Web/); -}); - -test('SSO login with TOTP 2fa', async ({ page }) => { - await logUser(test, page, users.user1); - - let totp = await activateTOTP(test, page, users.user1); - - await logUser(test, page, users.user1, { totp }); - - await disableTOTP(test, page, users.user1); -}); - -test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) => { - await utils.restartVault(page, testInfo, { - SSO_ENABLED: true, - SSO_ONLY: true - }, false); - - // Landing page - await page.goto('/'); - - // Check that SSO login is available - await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1); - - // No Continue/Other - await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0); -}); - - -test('No SSO login', async ({ page }, testInfo: TestInfo) => { - await utils.restartVault(page, testInfo, { - SSO_ENABLED: false - }, false); - - // Landing page - await page.goto('/'); - - // No SSO button (rely on a correct selector checked in previous test) - await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0); - - // Can continue to Master password - await page.getByLabel(/Email address/).fill(users.user1.email); - await page.getByRole('button', { name: 'Continue' }).click(); - await expect(page.getByRole('button', { name: 'Log in with master password' })).toHaveCount(1); -}); diff --git a/playwright/tests/sso_organization.smtp.spec.ts b/playwright/tests/sso_organization.smtp.spec.ts deleted file mode 100644 index 92813f72..00000000 --- a/playwright/tests/sso_organization.smtp.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { test, expect, type TestInfo } from '@playwright/test'; -import { MailDev } from 'maildev'; - -import * as utils from "../global-utils"; -import * as orgs from './setups/orgs'; -import { logNewUser, logUser } from './setups/sso'; - -let users = utils.loadEnv(); - -let mailServer, mail1Buffer, mail2Buffer, mail3Buffer; - -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, - SSO_ENABLED: true, - SSO_ONLY: true, - }); - - mail1Buffer = mailServer.buffer(users.user1.email); - mail2Buffer = mailServer.buffer(users.user2.email); - mail3Buffer = mailServer.buffer(users.user3.email); -}); - -test.afterAll('Teardown', async ({}) => { - utils.stopVault(); - [mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); -}); - -test('Create user3', async ({ page }) => { - await logNewUser(test, page, users.user3, { mailBuffer: mail3Buffer }); -}); - -test('Invite users', async ({ page }) => { - await logNewUser(test, page, users.user1, { mailBuffer: mail1Buffer }); - - await orgs.create(test, page, '/Test'); - await orgs.members(test, page, '/Test'); - await orgs.invite(test, page, '/Test', users.user2.email); - await orgs.invite(test, page, '/Test', users.user3.email); -}); - -test('invited with new account', async ({ page }) => { - const link = await test.step('Extract email link', async () => { - const invited = await mail2Buffer.expect((m) => m.subject === "Join /Test"); - await page.setContent(invited.html); - return await page.getByTestId("invite").getAttribute("href"); - }); - - await test.step('Redirect to Keycloak', async () => { - await page.goto(link); - }); - - await test.step('Keycloak login', async () => { - await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); - await page.getByLabel(/Username/).fill(users.user2.name); - await page.getByLabel('Password', { exact: true }).fill(users.user2.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - }); - - await test.step('Create Vault account', async () => { - await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); - await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password); - await page.getByLabel('Confirm master password (').fill(users.user2.password); - await page.getByRole('button', { name: 'Create account' }).click(); - - await utils.checkNotification(page, 'Account successfully created!'); - await utils.checkNotification(page, 'Invitation accepted'); - await utils.ignoreExtension(page); - }); - - await test.step('Default vault page', async () => { - await expect(page).toHaveTitle(/Vaultwarden Web/); - }); - - await test.step('Check mails', async () => { - await mail2Buffer.expect((m) => m.subject.includes("New Device Logged")); - await mail1Buffer.expect((m) => m.subject === "Invitation to /Test accepted"); - }); -}); - -test('invited with existing account', async ({ page }) => { - const link = await test.step('Extract email link', async () => { - const invited = await mail3Buffer.expect((m) => m.subject === "Join /Test"); - await page.setContent(invited.html); - return await page.getByTestId("invite").getAttribute("href"); - }); - - await test.step('Redirect to Keycloak', async () => { - await page.goto(link); - }); - - await test.step('Keycloak login', async () => { - await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); - await page.getByLabel(/Username/).fill(users.user3.name); - await page.getByLabel('Password', { exact: true }).fill(users.user3.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - }); - - await test.step('Unlock vault', async () => { - await expect(page).toHaveTitle('Vaultwarden Web'); - await page.getByLabel('Master password').fill(users.user3.password); - await page.getByRole('button', { name: 'Unlock' }).click(); - - await utils.checkNotification(page, 'Invitation accepted'); - await utils.ignoreExtension(page); - }); - - await test.step('Default vault page', async () => { - await expect(page).toHaveTitle(/Vaultwarden Web/); - }); - - await test.step('Check mails', async () => { - await mail3Buffer.expect((m) => m.subject.includes("New Device Logged")); - await mail1Buffer.expect((m) => m.subject === "Invitation to /Test accepted"); - }); -}); diff --git a/playwright/tests/sso_organization.spec.ts b/playwright/tests/sso_organization.spec.ts deleted file mode 100644 index c1238d45..00000000 --- a/playwright/tests/sso_organization.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { test, expect, type TestInfo } from '@playwright/test'; -import { MailDev } from 'maildev'; - -import * as utils from "../global-utils"; -import * as orgs from './setups/orgs'; -import { logNewUser, logUser } from './setups/sso'; - -let users = utils.loadEnv(); - -test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVault(browser, testInfo, { - SSO_ENABLED: true, - SSO_ONLY: true, - }); -}); - -test.afterAll('Teardown', async ({}) => { - utils.stopVault(); -}); - -test('Create user3', async ({ page }) => { - await logNewUser(test, page, users.user3); -}); - -test('Invite users', async ({ page }) => { - await logNewUser(test, page, users.user1); - - await orgs.create(test, page, '/Test'); - await orgs.members(test, page, '/Test'); - await orgs.invite(test, page, '/Test', users.user2.email); - await orgs.invite(test, page, '/Test', users.user3.email); - await orgs.confirm(test, page, '/Test', users.user3.email); -}); - -test('Create invited account', async ({ page }) => { - await logNewUser(test, page, users.user2); -}); - -test('Confirm invited user', async ({ page }) => { - await logUser(test, page, users.user1); - await orgs.members(test, page, '/Test'); - await expect(page.getByRole('row', { name: users.user2.name })).toHaveText(/Needs confirmation/); - await orgs.confirm(test, page, '/Test', users.user2.email); -}); - -test('Organization is visible', async ({ page }) => { - await logUser(test, page, users.user2); - await page.getByLabel('vault: /Test').click(); - await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); -}); - -test('Enforce password policy', async ({ page }) => { - await logUser(test, page, users.user1); - await orgs.policies(test, page, '/Test'); - - await test.step(`Set master password policy`, async () => { - await page.getByRole('button', { name: 'Master password requirements' }).click(); - await page.getByRole('checkbox', { name: 'Turn on' }).check(); - await page.getByRole('checkbox', { name: 'Require existing members to' }).check(); - await page.getByRole('spinbutton', { name: 'Minimum length' }).fill('42'); - await page.getByRole('button', { name: 'Save' }).click(); - await utils.checkNotification(page, 'Edited policy Master password requirements.'); - }); - - await utils.logout(test, page, users.user1); - - await test.step(`Unlock trigger policy`, async () => { - await page.locator("input[type=email].vw-email-sso").fill(users.user1.email); - await page.getByRole('button', { name: 'Use single sign-on' }).click(); - - await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password); - await page.getByRole('button', { name: 'Unlock' }).click(); - - await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible(); - }); -}); diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 775ded5a..a74a99f4 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.95.0" +channel = "1.82.0" components = [ "rustfmt", "clippy" ] profile = "minimal" diff --git a/src/api/admin.rs b/src/api/admin.rs index 02c976cc..cc902e39 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,16 +1,17 @@ -use std::{env, sync::LazyLock}; - +use once_cell::sync::Lazy; use reqwest::Method; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::env; + +use rocket::serde::json::Json; use rocket::{ form::Form, http::{Cookie, CookieJar, MediaType, SameSite, Status}, request::{FromRequest, Outcome, Request}, response::{content::RawHtml as Html, Redirect}, - serde::json::Json, Catcher, Route, }; -use serde::de::DeserializeOwned; -use serde_json::Value; use crate::{ api::{ @@ -19,21 +20,13 @@ use crate::{ }, auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp, Secure}, config::ConfigBuilder, - db::{ - backup_sqlite, get_sql_server_version, - models::{ - Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId, - MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, - }, - DbConn, DbConnType, ACTIVE_DB_TYPE, - }, + db::{backup_database, get_sql_server_version, models::*, DbConn, DbConnType}, error::{Error, MapResult}, http_client::make_http_request, mail, - sso::FAKE_SSO_IDENTIFIER, util::{ - container_base_image, format_naive_datetime_local, get_active_web_release, get_display_size, - is_running_in_container, parse_experimental_client_feature_flags, FeatureFlagFilter, NumberOrString, + container_base_image, format_naive_datetime_local, get_display_size, get_web_vault_version, + is_running_in_container, NumberOrString, }, CONFIG, VERSION, }; @@ -53,12 +46,11 @@ pub fn routes() -> Vec { invite_user, logout, delete_user, - delete_sso_user, deauth_user, disable_user, enable_user, remove_2fa, - update_membership_type, + update_user_org_type, update_revision_users, post_config, delete_config, @@ -70,7 +62,6 @@ pub fn routes() -> Vec { diagnostics, get_diagnostics_config, resend_user_invite, - get_diagnostics_http, ] } @@ -82,21 +73,18 @@ pub fn catchers() -> Vec { } } -static DB_TYPE: LazyLock<&str> = LazyLock::new(|| match ACTIVE_DB_TYPE.get() { - #[cfg(mysql)] - Some(DbConnType::Mysql) => "MySQL", - #[cfg(postgresql)] - Some(DbConnType::Postgresql) => "PostgreSQL", - #[cfg(sqlite)] - Some(DbConnType::Sqlite) => "SQLite", - _ => "Unknown", +static DB_TYPE: Lazy<&str> = Lazy::new(|| { + DbConnType::from_url(&CONFIG.database_url()) + .map(|t| match t { + DbConnType::sqlite => "SQLite", + DbConnType::mysql => "MySQL", + DbConnType::postgresql => "PostgreSQL", + }) + .unwrap_or("Unknown") }); -#[cfg(sqlite)] -static CAN_BACKUP: LazyLock = - LazyLock::new(|| ACTIVE_DB_TYPE.get().map(|t| *t == DbConnType::Sqlite).unwrap_or(false)); -#[cfg(not(sqlite))] -static CAN_BACKUP: LazyLock = LazyLock::new(|| false); +static CAN_BACKUP: Lazy = + Lazy::new(|| DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::sqlite).unwrap_or(false)); #[get("/")] fn admin_disabled() -> &'static str { @@ -110,10 +98,9 @@ const DT_FMT: &str = "%Y-%m-%d %H:%M:%S %Z"; const BASE_TEMPLATE: &str = "admin/base"; const ACTING_ADMIN_USER: &str = "vaultwarden-admin-00000-000000000000"; -pub const FAKE_ADMIN_UUID: &str = "00000000-0000-0000-0000-000000000000"; fn admin_path() -> String { - format!("{}{ADMIN_PATH}", CONFIG.domain_path()) + format!("{}{}", CONFIG.domain_path(), ADMIN_PATH) } #[derive(Debug)] @@ -158,10 +145,10 @@ fn admin_login(request: &Request<'_>) -> ApiResult> { err_code!("Authorization failed.", Status::Unauthorized.code); } let redirect = request.segments::(0..).unwrap_or_default().display().to_string(); - render_admin_login(None, Some(&redirect)) + render_admin_login(None, Some(redirect)) } -fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult> { +fn render_admin_login(msg: Option<&str>, redirect: Option) -> ApiResult> { // If there is an error, show it let msg = msg.map(|msg| format!("Error: {msg}")); let json = json!({ @@ -182,7 +169,7 @@ struct LoginForm { redirect: Option, } -#[post("/", format = "application/x-www-form-urlencoded", data = "")] +#[post("/", data = "")] fn post_admin_login( data: Form, cookies: &CookieJar<'_>, @@ -195,17 +182,14 @@ fn post_admin_login( if crate::ratelimit::check_limit_admin(&ip.ip).is_err() { return Err(AdminResponse::TooManyRequests(render_admin_login( Some("Too many requests, try again later."), - redirect.as_deref(), + redirect, ))); } // If the token is invalid, redirect to login page if !_validate_token(&data.token) { error!("Invalid admin token. IP: {}", ip.ip); - Err(AdminResponse::Unauthorized(render_admin_login( - Some("Invalid admin token, please try again."), - redirect.as_deref(), - ))) + Err(AdminResponse::Unauthorized(render_admin_login(Some("Invalid admin token, please try again."), redirect))) } else { // If the token received is valid, generate JWT and save it as a cookie let claims = generate_admin_claims(); @@ -220,7 +204,7 @@ fn post_admin_login( cookies.add(cookie); if let Some(redirect) = redirect { - Ok(Redirect::to(format!("{}{redirect}", admin_path()))) + Ok(Redirect::to(format!("{}{}", admin_path(), redirect))) } else { Err(AdminResponse::Ok(render_admin_page())) } @@ -253,7 +237,6 @@ struct AdminTemplateData { page_data: Option, logged_in: bool, urlpath: String, - sso_enabled: bool, } impl AdminTemplateData { @@ -263,7 +246,6 @@ impl AdminTemplateData { page_data: Some(page_data), logged_in: true, urlpath: CONFIG.domain_path(), - sso_enabled: CONFIG.sso_enabled(), } } @@ -297,45 +279,39 @@ struct InviteData { email: String, } -async fn get_user_or_404(user_id: &UserId, conn: &DbConn) -> ApiResult { - if let Some(user) = User::find_by_uuid(user_id, conn).await { +async fn get_user_or_404(uuid: &str, conn: &mut DbConn) -> ApiResult { + if let Some(user) = User::find_by_uuid(uuid, conn).await { Ok(user) } else { err_code!("User doesn't exist", Status::NotFound.code); } } -#[post("/invite", format = "application/json", data = "")] -async fn invite_user(data: Json, _token: AdminToken, conn: DbConn) -> JsonResult { +#[post("/invite", data = "")] +async fn invite_user(data: Json, _token: AdminToken, mut conn: DbConn) -> JsonResult { let data: InviteData = data.into_inner(); - if User::find_by_mail(&data.email, &conn).await.is_some() { + if User::find_by_mail(&data.email, &mut conn).await.is_some() { err_code!("User already exists", Status::Conflict.code) } - let mut user = User::new(&data.email, None); + let mut user = User::new(data.email); - async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult { + async fn _generate_invite(user: &User, conn: &mut DbConn) -> EmptyResult { if CONFIG.mail_enabled() { - let org_id: OrganizationId = if CONFIG.sso_enabled() { - FAKE_SSO_IDENTIFIER.into() - } else { - FAKE_ADMIN_UUID.into() - }; - let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into(); - mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await + mail::send_invite(user, None, None, &CONFIG.invitation_org_name(), None).await } else { let invitation = Invitation::new(&user.email); invitation.save(conn).await } } - _generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?; - user.save(&conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?; + _generate_invite(&user, &mut conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?; + user.save(&mut conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?; - Ok(Json(user.to_json(&conn).await)) + Ok(Json(user.to_json(&mut conn).await)) } -#[post("/test/smtp", format = "application/json", data = "")] +#[post("/test/smtp", data = "")] async fn test_smtp(data: Json, _token: AdminToken) -> EmptyResult { let data: InviteData = data.into_inner(); @@ -353,14 +329,14 @@ fn logout(cookies: &CookieJar<'_>) -> Redirect { } #[get("/users")] -async fn get_users_json(_token: AdminToken, conn: DbConn) -> Json { - let users = User::get_all(&conn).await; +async fn get_users_json(_token: AdminToken, mut conn: DbConn) -> Json { + let users = User::get_all(&mut conn).await; let mut users_json = Vec::with_capacity(users.len()); - for (u, _) in users { - let mut usr = u.to_json(&conn).await; + for u in users { + let mut usr = u.to_json(&mut conn).await; usr["userEnabled"] = json!(u.enabled); usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); - usr["lastActive"] = match u.last_active(&conn).await { + usr["lastActive"] = match u.last_active(&mut conn).await { Some(dt) => json!(format_naive_datetime_local(&dt, DT_FMT)), None => json!(None::), }; @@ -371,23 +347,20 @@ async fn get_users_json(_token: AdminToken, conn: DbConn) -> Json { } #[get("/users/overview")] -async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult> { - let users = User::get_all(&conn).await; +async fn users_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult> { + let users = User::get_all(&mut conn).await; let mut users_json = Vec::with_capacity(users.len()); - for (u, sso_u) in users { - let mut usr = u.to_json(&conn).await; - usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &conn).await); - usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &conn).await); - usr["attachment_size"] = json!(get_display_size(Attachment::size_by_user(&u.uuid, &conn).await)); + for u in users { + let mut usr = u.to_json(&mut conn).await; + usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &mut conn).await); + usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &mut conn).await); + usr["attachment_size"] = json!(get_display_size(Attachment::size_by_user(&u.uuid, &mut conn).await)); usr["user_enabled"] = json!(u.enabled); usr["created_at"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); - usr["last_active"] = match u.last_active(&conn).await { + usr["last_active"] = match u.last_active(&mut conn).await { Some(dt) => json!(format_naive_datetime_local(&dt, DT_FMT)), None => json!("Never"), }; - - usr["sso_identifier"] = json!(sso_u.map(|u| u.identifier.to_string()).unwrap_or(String::new())); - users_json.push(usr); } @@ -396,9 +369,9 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult")] -async fn get_user_by_mail_json(mail: &str, _token: AdminToken, conn: DbConn) -> JsonResult { - if let Some(u) = User::find_by_mail(mail, &conn).await { - let mut usr = u.to_json(&conn).await; +async fn get_user_by_mail_json(mail: &str, _token: AdminToken, mut conn: DbConn) -> JsonResult { + if let Some(u) = User::find_by_mail(mail, &mut conn).await { + let mut usr = u.to_json(&mut conn).await; usr["userEnabled"] = json!(u.enabled); usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); Ok(Json(usr)) @@ -407,32 +380,32 @@ async fn get_user_by_mail_json(mail: &str, _token: AdminToken, conn: DbConn) -> } } -#[get("/users/")] -async fn get_user_json(user_id: UserId, _token: AdminToken, conn: DbConn) -> JsonResult { - let u = get_user_or_404(&user_id, &conn).await?; - let mut usr = u.to_json(&conn).await; +#[get("/users/")] +async fn get_user_json(uuid: &str, _token: AdminToken, mut conn: DbConn) -> JsonResult { + let u = get_user_or_404(uuid, &mut conn).await?; + let mut usr = u.to_json(&mut conn).await; usr["userEnabled"] = json!(u.enabled); usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); Ok(Json(usr)) } -#[post("/users//delete", format = "application/json")] -async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult { - let user = get_user_or_404(&user_id, &conn).await?; +#[post("/users//delete")] +async fn delete_user(uuid: &str, token: AdminToken, mut conn: DbConn) -> EmptyResult { + let user = get_user_or_404(uuid, &mut conn).await?; - // Get the membership records before deleting the actual user - let memberships = Membership::find_any_state_by_user(&user_id, &conn).await; - let res = user.delete(&conn).await; + // Get the user_org records before deleting the actual user + let user_orgs = UserOrganization::find_any_state_by_user(uuid, &mut conn).await; + let res = user.delete(&mut conn).await; - for membership in memberships { + for user_org in user_orgs { log_event( - EventType::OrganizationUserDeleted as i32, - &membership.uuid, - &membership.org_uuid, - &ACTING_ADMIN_USER.into(), + EventType::OrganizationUserRemoved as i32, + &user_org.uuid, + &user_org.org_uuid, + ACTING_ADMIN_USER, 14, // Use UnknownBrowser type &token.ip.ip, - &conn, + &mut conn, ) .await; } @@ -440,96 +413,68 @@ async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyR res } -#[delete("/users//sso", format = "application/json")] -async fn delete_sso_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult { - let memberships = Membership::find_any_state_by_user(&user_id, &conn).await; - let res = SsoUser::delete(&user_id, &conn).await; +#[post("/users//deauth")] +async fn deauth_user(uuid: &str, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { + let mut user = get_user_or_404(uuid, &mut conn).await?; - for membership in memberships { - log_event( - EventType::OrganizationUserUnlinkedSso as i32, - &membership.uuid, - &membership.org_uuid, - &ACTING_ADMIN_USER.into(), - 14, // Use UnknownBrowser type - &token.ip.ip, - &conn, - ) - .await; - } - - res -} - -#[post("/users//deauth", format = "application/json")] -async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - let mut user = get_user_or_404(&user_id, &conn).await?; - - nt.send_logout(&user, None, &conn).await; + nt.send_logout(&user, None).await; if CONFIG.push_enabled() { - for device in Device::find_push_devices_by_user(&user.uuid, &conn).await { - match unregister_push_device(device.push_uuid.as_ref()).await { + for device in Device::find_push_devices_by_user(&user.uuid, &mut conn).await { + match unregister_push_device(device.push_uuid).await { Ok(r) => r, - Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"), + Err(e) => error!("Unable to unregister devices from Bitwarden server: {}", e), }; } } - Device::delete_all_by_user(&user.uuid, &conn).await?; - user.reset_security_stamp(&conn).await?; + Device::delete_all_by_user(&user.uuid, &mut conn).await?; + user.reset_security_stamp(); - user.save(&conn).await + user.save(&mut conn).await } -#[post("/users//disable", format = "application/json")] -async fn disable_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - let mut user = get_user_or_404(&user_id, &conn).await?; - user.reset_security_stamp(&conn).await?; +#[post("/users//disable")] +async fn disable_user(uuid: &str, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { + let mut user = get_user_or_404(uuid, &mut conn).await?; + Device::delete_all_by_user(&user.uuid, &mut conn).await?; + user.reset_security_stamp(); user.enabled = false; - let save_result = user.save(&conn).await; + let save_result = user.save(&mut conn).await; - nt.send_logout(&user, None, &conn).await; - - Device::delete_all_by_user(&user.uuid, &conn).await?; + nt.send_logout(&user, None).await; save_result } -#[post("/users//enable", format = "application/json")] -async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> EmptyResult { - let mut user = get_user_or_404(&user_id, &conn).await?; +#[post("/users//enable")] +async fn enable_user(uuid: &str, _token: AdminToken, mut conn: DbConn) -> EmptyResult { + let mut user = get_user_or_404(uuid, &mut conn).await?; user.enabled = true; - user.save(&conn).await + user.save(&mut conn).await } -#[post("/users//remove-2fa", format = "application/json")] -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?; - two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?; +#[post("/users//remove-2fa")] +async fn remove_2fa(uuid: &str, token: AdminToken, mut conn: DbConn) -> EmptyResult { + let mut user = get_user_or_404(uuid, &mut conn).await?; + TwoFactor::delete_all_by_user(&user.uuid, &mut conn).await?; + two_factor::enforce_2fa_policy(&user, ACTING_ADMIN_USER, 14, &token.ip.ip, &mut conn).await?; user.totp_recover = None; - user.save(&conn).await + user.save(&mut conn).await } -#[post("/users//invite/resend", format = "application/json")] -async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -> EmptyResult { - if let Some(user) = User::find_by_uuid(&user_id, &conn).await { +#[post("/users//invite/resend")] +async fn resend_user_invite(uuid: &str, _token: AdminToken, mut conn: DbConn) -> EmptyResult { + if let Some(user) = User::find_by_uuid(uuid, &mut conn).await { //TODO: replace this with user.status check when it will be available (PR#3397) if !user.password_hash.is_empty() { err_code!("User already accepted invitation", Status::BadRequest.code); } if CONFIG.mail_enabled() { - let org_id: OrganizationId = if CONFIG.sso_enabled() { - FAKE_SSO_IDENTIFIER.into() - } else { - FAKE_ADMIN_UUID.into() - }; - let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into(); - mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await + mail::send_invite(&user, None, None, &CONFIG.invitation_org_name(), None).await } else { Ok(()) } @@ -539,69 +484,85 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) - } #[derive(Debug, Deserialize)] -struct MembershipTypeData { +struct UserOrgTypeData { user_type: NumberOrString, - user_uuid: UserId, - org_uuid: OrganizationId, + user_uuid: String, + org_uuid: String, } -#[post("/users/org_type", format = "application/json", data = "")] -async fn update_membership_type(data: Json, token: AdminToken, conn: DbConn) -> EmptyResult { - let data: MembershipTypeData = data.into_inner(); +#[post("/users/org_type", data = "")] +async fn update_user_org_type(data: Json, token: AdminToken, mut conn: DbConn) -> EmptyResult { + let data: UserOrgTypeData = data.into_inner(); - let Some(mut member_to_edit) = Membership::find_by_user_and_org(&data.user_uuid, &data.org_uuid, &conn).await - else { - err!("The specified user isn't member of the organization") - }; + let mut user_to_edit = + match UserOrganization::find_by_user_and_org(&data.user_uuid, &data.org_uuid, &mut conn).await { + Some(user) => user, + None => err!("The specified user isn't member of the organization"), + }; - let new_type = match MembershipType::from_str(&data.user_type.into_string()) { + let new_type = match UserOrgType::from_str(&data.user_type.into_string()) { Some(new_type) => new_type as i32, None => err!("Invalid type"), }; - if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner { + if user_to_edit.atype == UserOrgType::Owner && new_type != UserOrgType::Owner { // Removing owner permission, check that there is at least one other confirmed owner - if Membership::count_confirmed_by_org_and_type(&data.org_uuid, MembershipType::Owner, &conn).await <= 1 { + if UserOrganization::count_confirmed_by_org_and_type(&data.org_uuid, UserOrgType::Owner, &mut conn).await <= 1 { err!("Can't change the type of the last owner") } } - 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?; + // This check is also done at api::organizations::{accept_invite(), _confirm_invite, _activate_user(), edit_user()}, update_user_org_type + // It returns different error messages per function. + if new_type < UserOrgType::Admin { + match OrgPolicy::is_user_allowed(&user_to_edit.user_uuid, &user_to_edit.org_uuid, true, &mut conn).await { + Ok(_) => {} + Err(OrgPolicyErr::TwoFactorMissing) => { + if CONFIG.email_2fa_auto_fallback() { + two_factor::email::find_and_activate_email_2fa(&user_to_edit.user_uuid, &mut conn).await?; + } else { + err!("You cannot modify this user to this type because they have not setup 2FA"); + } + } + Err(OrgPolicyErr::SingleOrgEnforced) => { + err!("You cannot modify this user to this type because it is a member of an organization which forbids it"); + } + } + } log_event( EventType::OrganizationUserUpdated as i32, - &member_to_edit.uuid, + &user_to_edit.uuid, &data.org_uuid, - &ACTING_ADMIN_USER.into(), + ACTING_ADMIN_USER, 14, // Use UnknownBrowser type &token.ip.ip, - &conn, + &mut conn, ) .await; - member_to_edit.save(&conn).await + user_to_edit.atype = new_type; + user_to_edit.save(&mut conn).await } -#[post("/users/update_revision", format = "application/json")] -async fn update_revision_users(_token: AdminToken, conn: DbConn) -> EmptyResult { - User::update_all_revisions(&conn).await +#[post("/users/update_revision")] +async fn update_revision_users(_token: AdminToken, mut conn: DbConn) -> EmptyResult { + User::update_all_revisions(&mut conn).await } #[get("/organizations/overview")] -async fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult> { - let organizations = Organization::get_all(&conn).await; +async fn organizations_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult> { + let organizations = Organization::get_all(&mut conn).await; let mut organizations_json = Vec::with_capacity(organizations.len()); for o in organizations { let mut org = o.to_json(); - org["user_count"] = json!(Membership::count_by_org(&o.uuid, &conn).await); - org["cipher_count"] = json!(Cipher::count_by_org(&o.uuid, &conn).await); - org["collection_count"] = json!(Collection::count_by_org(&o.uuid, &conn).await); - org["group_count"] = json!(Group::count_by_org(&o.uuid, &conn).await); - org["event_count"] = json!(Event::count_by_org(&o.uuid, &conn).await); - org["attachment_count"] = json!(Attachment::count_by_org(&o.uuid, &conn).await); - org["attachment_size"] = json!(get_display_size(Attachment::size_by_org(&o.uuid, &conn).await)); + org["user_count"] = json!(UserOrganization::count_by_org(&o.uuid, &mut conn).await); + org["cipher_count"] = json!(Cipher::count_by_org(&o.uuid, &mut conn).await); + org["collection_count"] = json!(Collection::count_by_org(&o.uuid, &mut conn).await); + org["group_count"] = json!(Group::count_by_org(&o.uuid, &mut conn).await); + org["event_count"] = json!(Event::count_by_org(&o.uuid, &mut conn).await); + org["attachment_count"] = json!(Attachment::count_by_org(&o.uuid, &mut conn).await); + org["attachment_size"] = json!(get_display_size(Attachment::size_by_org(&o.uuid, &mut conn).await)); organizations_json.push(org); } @@ -609,10 +570,10 @@ async fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult/delete", format = "application/json")] -async fn delete_organization(org_id: OrganizationId, _token: AdminToken, conn: DbConn) -> EmptyResult { - let org = Organization::find_by_uuid(&org_id, &conn).await.map_res("Organization doesn't exist")?; - org.delete(&conn).await +#[post("/organizations//delete")] +async fn delete_organization(uuid: &str, _token: AdminToken, mut conn: DbConn) -> EmptyResult { + let org = Organization::find_by_uuid(uuid, &mut conn).await.map_res("Organization doesn't exist")?; + org.delete(&mut conn).await } #[derive(Deserialize)] @@ -625,17 +586,24 @@ struct GitCommit { sha: String, } +#[derive(Deserialize)] +struct TimeApi { + year: u16, + month: u8, + day: u8, + hour: u8, + minute: u8, + seconds: u8, +} + async fn get_json_api(url: &str) -> Result { Ok(make_http_request(Method::GET, url)?.send().await?.error_for_status()?.json::().await?) } -async fn get_text_api(url: &str) -> Result { - Ok(make_http_request(Method::GET, url)?.send().await?.error_for_status()?.text().await?) -} - async fn has_http_access() -> bool { - let Ok(req) = make_http_request(Method::HEAD, "https://github.com/dani-garcia/vaultwarden") else { - return false; + let req = match make_http_request(Method::HEAD, "https://github.com/dani-garcia/vaultwarden") { + Ok(r) => r, + Err(_) => return false, }; match req.send().await { Ok(r) => r.status().is_success(), @@ -644,11 +612,10 @@ async fn has_http_access() -> bool { } use cached::proc_macro::cached; -/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already -/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit -/// Any cache will be lost if Vaultwarden is restarted -#[cached(time = 600, sync_writes = "default")] -async fn get_release_info(has_http_access: bool) -> (String, String, String) { +/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already. +/// It will cache this function for 300 seconds (5 minutes) which should prevent the exhaustion of the rate limit. +#[cached(time = 300, sync_writes = true)] +async fn get_release_info(has_http_access: bool, running_within_container: bool) -> (String, String, String) { // If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway. if has_http_access { ( @@ -665,13 +632,19 @@ async fn get_release_info(has_http_access: bool) -> (String, String, String) { } _ => "-".to_string(), }, - // Do not fetch the web-vault version when running within a container + // Do not fetch the web-vault version when running within a container. // The web-vault version is embedded within the container it self, and should not be updated manually - match get_json_api::("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest") + if running_within_container { + "-".to_string() + } else { + match get_json_api::( + "https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest", + ) .await - { - Ok(r) => r.tag_name.trim_start_matches('v').to_string(), - _ => "-".to_string(), + { + Ok(r) => r.tag_name.trim_start_matches('v').to_string(), + _ => "-".to_string(), + } }, ) } else { @@ -681,45 +654,24 @@ async fn get_release_info(has_http_access: bool) -> (String, String, String) { async fn get_ntp_time(has_http_access: bool) -> String { if has_http_access { - if let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await { - for line in cf_trace.lines() { - if let Some((key, value)) = line.split_once('=') { - if key == "ts" { - let ts = value.split_once('.').map_or(value, |(s, _)| s); - if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") { - return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(); - } - break; - } - } - } + if let Ok(ntp_time) = get_json_api::("https://www.timeapi.io/api/Time/current/zone?timeZone=UTC").await + { + return format!( + "{year}-{month:02}-{day:02} {hour:02}:{minute:02}:{seconds:02} UTC", + year = ntp_time.year, + month = ntp_time.month, + day = ntp_time.day, + hour = ntp_time.hour, + minute = ntp_time.minute, + seconds = ntp_time.seconds + ); } } String::from("Unable to fetch NTP time.") } -fn web_vault_compare(active: &str, latest: &str) -> i8 { - use semver::Version; - use std::cmp::Ordering; - - let active_semver = Version::parse(active).unwrap_or_else(|e| { - warn!("Unable to parse active web-vault version '{active}': {e}"); - Version::parse("2025.1.1").unwrap() - }); - let latest_semver = Version::parse(latest).unwrap_or_else(|e| { - warn!("Unable to parse latest web-vault version '{latest}': {e}"); - Version::parse("2025.1.1").unwrap() - }); - - match active_semver.cmp(&latest_semver) { - Ordering::Less => -1, - Ordering::Equal => 0, - Ordering::Greater => 1, - } -} - #[get("/diagnostics")] -async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult> { +async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn) -> ApiResult> { use chrono::prelude::*; use std::net::ToSocketAddrs; @@ -737,28 +689,22 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A _ => "Unable to resolve domain name.".to_string(), }; - let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await; - let active_web_release = get_active_web_release(); - let web_vault_compare = web_vault_compare(&active_web_release, &latest_web_release); + let (latest_release, latest_commit, latest_web_build) = + get_release_info(has_http_access, running_within_container).await; let ip_header_name = &ip_header.0.unwrap_or_default(); - let invalid_feature_flags: Vec = parse_experimental_client_feature_flags( - &CONFIG.experimental_client_feature_flags(), - FeatureFlagFilter::InvalidOnly, - ) - .into_keys() - .collect(); + // Get current running versions + let web_vault_version = get_web_vault_version(); let diagnostics_json = json!({ "dns_resolved": dns_resolved, "current_release": VERSION, - "latest_release": latest_vw_release, - "latest_commit": latest_vw_commit, + "latest_release": latest_release, + "latest_commit": latest_commit, "web_vault_enabled": &CONFIG.web_vault_enabled(), - "active_web_release": active_web_release, - "latest_web_release": latest_web_release, - "web_vault_compare": web_vault_compare, + "web_vault_version": web_vault_version, + "latest_web_build": latest_web_build, "running_within_container": running_within_container, "container_base_image": if running_within_container { container_base_image() } else { "Not applicable" }, "has_http_access": has_http_access, @@ -767,15 +713,12 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A "ip_header_name": ip_header_name, "ip_header_config": &CONFIG.ip_header(), "uses_proxy": uses_proxy, - "enable_websocket": &CONFIG.enable_websocket(), "db_type": *DB_TYPE, - "db_version": get_sql_server_version(&conn).await, + "db_version": get_sql_server_version(&mut conn).await, "admin_url": format!("{}/diagnostics", admin_url()), "overrides": &CONFIG.get_overrides().join(", "), - "invalid_feature_flags": invalid_feature_flags, "host_arch": env::consts::ARCH, "host_os": env::consts::OS, - "tz_env": env::var("TZ").unwrap_or_default(), "server_time_local": Local::now().format("%Y-%m-%d %H:%M:%S %Z").to_string(), "server_time": Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), // Run the server date/time check as late as possible to minimize the time difference "ntp_time": get_ntp_time(has_http_access).await, // Run the ntp check as late as possible to minimize the time difference @@ -785,38 +728,33 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A Ok(Html(text)) } -#[get("/diagnostics/config", format = "application/json")] +#[get("/diagnostics/config")] fn get_diagnostics_config(_token: AdminToken) -> Json { let support_json = CONFIG.get_support_json(); Json(support_json) } -#[get("/diagnostics/http?")] -fn get_diagnostics_http(code: u16, _token: AdminToken) -> EmptyResult { - err_code!(format!("Testing error {code} response"), code); -} - -#[post("/config", format = "application/json", data = "")] -async fn post_config(data: Json, _token: AdminToken) -> EmptyResult { +#[post("/config", data = "")] +fn post_config(data: Json, _token: AdminToken) -> EmptyResult { let data: ConfigBuilder = data.into_inner(); - if let Err(e) = CONFIG.update_config(data, true).await { + if let Err(e) = CONFIG.update_config(data) { err!(format!("Unable to save config: {e:?}")) } Ok(()) } -#[post("/config/delete", format = "application/json")] -async fn delete_config(_token: AdminToken) -> EmptyResult { - if let Err(e) = CONFIG.delete_user_config().await { +#[post("/config/delete")] +fn delete_config(_token: AdminToken) -> EmptyResult { + if let Err(e) = CONFIG.delete_user_config() { err!(format!("Unable to delete config: {e:?}")) } Ok(()) } -#[post("/config/backup_db", format = "application/json")] -fn backup_db(_token: AdminToken) -> ApiResult { +#[post("/config/backup_db")] +async fn backup_db(_token: AdminToken, mut conn: DbConn) -> ApiResult { if *CAN_BACKUP { - match backup_sqlite() { + match backup_database(&mut conn).await { Ok(f) => Ok(format!("Backup to '{f}' was successful")), Err(e) => err!(format!("Backup was unsuccessful {e}")), } @@ -839,7 +777,11 @@ impl<'r> FromRequest<'r> for AdminToken { _ => err_handler!("Error getting Client IP"), }; - if !CONFIG.disable_admin_token() { + if CONFIG.disable_admin_token() { + Outcome::Success(Self { + ip, + }) + } else { let cookies = request.cookies(); let access_token = match cookies.get(COOKIE_NAME) { @@ -863,39 +805,10 @@ impl<'r> FromRequest<'r> for AdminToken { error!("Invalid or expired admin JWT. IP: {}.", &ip.ip); return Outcome::Error((Status::Unauthorized, "Session expired")); } + + Outcome::Success(Self { + ip, + }) } - - Outcome::Success(Self { - ip, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn validate_web_vault_compare() { - // web_vault_compare(active, latest) - // Test normal versions - assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1); - assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0); - assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1); - - // Test patched/+build.n versions - // Newer latest version - assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1); - assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1); - assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1); - assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1); - // Equal versions - assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0); - assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0); - // Newer active version - assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1); - assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1); - assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1); - assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1); } } diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index a8f9768e..f9822629 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1,28 +1,19 @@ -use std::collections::HashSet; - use crate::db::DbPool; -use chrono::Utc; +use chrono::{SecondsFormat, Utc}; use rocket::serde::json::Json; use serde_json::Value; use crate::{ api::{ - core::{accept_org_invite, log_user_event, two_factor::email}, - master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult, - JsonResult, Notify, PasswordOrOtpData, UpdateType, + core::{log_user_event, two_factor::email}, + register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult, Notify, + PasswordOrOtpData, UpdateType, }, auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers}, crypto, - db::{ - models::{ - AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, EmergencyAccess, - EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, OrgPolicy, - OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType, - }, - DbConn, - }, + db::{models::*, DbConn}, mail, - util::{deser_opt_nonempty_str, format_date, NumberOrString}, + util::NumberOrString, CONFIG, }; @@ -33,14 +24,13 @@ use rocket::{ pub fn routes() -> Vec { routes![ + register, profile, put_profile, post_profile, - put_avatar, get_public_keys, post_keys, post_password, - post_set_password, post_kdf, post_rotatekey, post_sstamp, @@ -50,8 +40,9 @@ pub fn routes() -> Vec { post_verify_email_token, post_delete_recover, post_delete_recover_token, - post_delete_account, + post_device_token, delete_account, + post_delete_account, revision_date, password_hint, prelogin, @@ -59,74 +50,34 @@ pub fn routes() -> Vec { api_key, rotate_api_key, get_known_device, - get_all_devices, - get_device, - post_device_token, + put_avatar, put_device_token, put_clear_device_token, post_clear_device_token, - get_tasks, post_auth_request, get_auth_request, put_auth_request, get_auth_request_response, get_auth_requests, - get_auth_requests_pending, ] } -#[derive(Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct KDFData { - #[serde(alias = "kdfType")] - kdf: i32, - #[serde(alias = "iterations")] - kdf_iterations: i32, - #[serde(alias = "memory")] - kdf_memory: Option, - #[serde(alias = "parallelism")] - kdf_parallelism: Option, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RegisterData { email: String, - - #[serde(flatten)] - kdf: KDFData, - - #[serde(alias = "userSymmetricKey")] + kdf: Option, + kdf_iterations: Option, + kdf_memory: Option, + kdf_parallelism: Option, key: String, - #[serde(alias = "userAsymmetricKeys")] keys: Option, - master_password_hash: String, 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, - #[serde(alias = "token")] - org_invite_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SetPasswordData { - #[serde(flatten)] - kdf: KDFData, - - key: String, - keys: Option, - master_password_hash: String, - master_password_hint: Option, - org_identifier: Option, + token: Option, + #[allow(dead_code)] + organization_user_id: Option, } #[derive(Debug, Deserialize)] @@ -137,7 +88,7 @@ struct KeysData { } /// Trims whitespace from password hints, and converts blank password hints to `None`. -fn clean_password_hint(password_hint: Option<&String>) -> Option { +fn clean_password_hint(password_hint: &Option) -> Option { match password_hint { None => None, Some(h) => match h.trim() { @@ -147,94 +98,35 @@ fn clean_password_hint(password_hint: Option<&String>) -> Option { } } -fn enforce_password_hint_setting(password_hint: Option<&String>) -> EmptyResult { +fn enforce_password_hint_setting(password_hint: &Option) -> EmptyResult { if password_hint.is_some() && !CONFIG.password_hints_allowed() { err!("Password hints have been disabled by the administrator. Remove the hint and try again."); } Ok(()) } -async fn is_email_2fa_required(member_id: Option, conn: &DbConn) -> bool { +async fn is_email_2fa_required(org_user_uuid: Option, conn: &mut DbConn) -> bool { if !CONFIG._enable_email_2fa() { return false; } if CONFIG.email_2fa_enforce_on_verified_invite() { return true; } - if let Some(member_id) = member_id { - return OrgPolicy::is_enabled_for_member(&member_id, OrgPolicyType::TwoFactorAuthentication, conn).await; + if org_user_uuid.is_some() { + return OrgPolicy::is_enabled_for_member(&org_user_uuid.unwrap(), OrgPolicyType::TwoFactorAuthentication, conn) + .await; } false } -pub async fn _register(data: Json, email_verification: bool, conn: DbConn) -> JsonResult { - let mut data: RegisterData = data.into_inner(); +#[post("/accounts/register", data = "")] +async fn register(data: Json, conn: DbConn) -> JsonResult { + _register(data, conn).await +} + +pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult { + let data: RegisterData = data.into_inner(); let email = data.email.to_lowercase(); - let mut email_verified = false; - - let mut pending_emergency_access = None; - - // First, validate the provided verification tokens - if email_verification { - match ( - &data.email_verification_token, - &data.accept_emergency_access_id, - &data.accept_emergency_access_invite_token, - &data.organization_user_id, - &data.org_invite_token, - ) { - // Normal user registration, when email verification is required - (Some(email_verification_token), None, None, None, None) => { - let claims = crate::auth::decode_register_verify(email_verification_token)?; - if claims.sub != data.email { - err!("Email verification token does not match email"); - } - - // During this call we don't get the name, so extract it from the claims - if claims.name.is_some() { - data.name = claims.name; - } - email_verified = claims.verified; - } - // 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; - } - - _ => { - err!("Registration is missing required parameters") - } - } - } - // Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden) // This also prevents issues with very long usernames causing to large JWT's. See #2419 if let Some(ref name) = data.name { @@ -245,30 +137,36 @@ pub async fn _register(data: Json, email_verification: bool, conn: // 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 password_hint = clean_password_hint(&data.master_password_hint); + enforce_password_hint_setting(&password_hint)?; - let mut user = match User::find_by_mail(&email, &conn).await { - Some(user) => { + let mut verified_by_invite = false; + + let mut user = match User::find_by_mail(&email, &mut conn).await { + Some(mut user) => { if !user.password_hash.is_empty() { err!("Registration not allowed or user already exists") } - if let Some(token) = data.org_invite_token { + if let Some(token) = data.token { let claims = decode_invite(&token)?; if claims.email == email { // Verify the email address when signing up via a valid invite token - email_verified = true; + verified_by_invite = true; + user.verified_at = Some(Utc::now().naive_utc()); user } else { err!("Registration email does not match invite email") } - } else if Invitation::take(&email, &conn).await { - Membership::accept_user_invitations(&user.uuid, &conn).await?; + } else if Invitation::take(&email, &mut conn).await { + for user_org in UserOrganization::find_invited_by_user(&user.uuid, &mut conn).await.iter_mut() { + user_org.status = UserOrgStatus::Accepted as i32; + user_org.save(&mut conn).await?; + } user } else if CONFIG.is_signup_allowed(&email) || (CONFIG.emergency_access_allowed() - && EmergencyAccess::find_invited_by_grantee_email(&email, &conn).await.is_some()) + && EmergencyAccess::find_invited_by_grantee_email(&email, &mut conn).await.is_some()) { user } else { @@ -279,11 +177,8 @@ pub async fn _register(data: Json, email_verification: bool, conn: // Order is important here; the invitation check must come first // because the vaultwarden admin can invite anyone, regardless // of other signup restrictions. - if Invitation::take(&email, &conn).await - || CONFIG.is_signup_allowed(&email) - || pending_emergency_access.is_some() - { - User::new(&email, None) + if Invitation::take(&email, &mut conn).await || CONFIG.is_signup_allowed(&email) { + User::new(email.clone()) } else { err!("Registration not allowed or user already exists") } @@ -291,11 +186,20 @@ pub async fn _register(data: Json, email_verification: bool, conn: }; // Make sure we don't leave a lingering invitation. - Invitation::take(&email, &conn).await; + Invitation::take(&email, &mut conn).await; - set_kdf_data(&mut user, &data.kdf)?; + if let Some(client_kdf_type) = data.kdf { + user.client_kdf_type = client_kdf_type; + } - user.set_password(&data.master_password_hash, Some(data.key), true, None, &conn).await?; + if let Some(client_kdf_iter) = data.kdf_iterations { + user.client_kdf_iter = client_kdf_iter; + } + + user.client_kdf_memory = data.kdf_memory; + user.client_kdf_parallelism = data.kdf_parallelism; + + user.set_password(&data.master_password_hash, Some(data.key), true, None); user.password_hint = password_hint; // Add extra fields if present @@ -308,31 +212,27 @@ pub async fn _register(data: Json, email_verification: bool, conn: user.public_key = Some(keys.public_key); } - if email_verified { - user.verified_at = Some(Utc::now().naive_utc()); - } - if CONFIG.mail_enabled() { - if CONFIG.signups_verify() && !email_verified { + if CONFIG.signups_verify() && !verified_by_invite { if let Err(e) = mail::send_welcome_must_verify(&user.email, &user.uuid).await { - error!("Error sending welcome email: {e:#?}"); + error!("Error sending welcome email: {:#?}", e); } user.last_verifying_at = Some(user.created_at); } else if let Err(e) = mail::send_welcome(&user.email).await { - error!("Error sending welcome email: {e:#?}"); + error!("Error sending welcome email: {:#?}", e); } - if email_verified && is_email_2fa_required(data.organization_user_id, &conn).await { - email::activate_email_2fa(&user, &conn).await.ok(); + if verified_by_invite && is_email_2fa_required(data.organization_user_id, &mut conn).await { + email::activate_email_2fa(&user, &mut conn).await.ok(); } } - user.save(&conn).await?; + user.save(&mut conn).await?; // accept any open emergency access invitations if !CONFIG.mail_enabled() && CONFIG.emergency_access_allowed() { - for mut emergency_invite in EmergencyAccess::find_all_invited_by_grantee_email(&user.email, &conn).await { - emergency_invite.accept_invite(&user.uuid, &user.email, &conn).await.ok(); + for mut emergency_invite in EmergencyAccess::find_all_invited_by_grantee_email(&user.email, &mut conn).await { + emergency_invite.accept_invite(&user.uuid, &user.email, &mut conn).await.ok(); } } @@ -342,77 +242,16 @@ pub async fn _register(data: Json, email_verification: bool, conn: }))) } -#[post("/accounts/set-password", data = "")] -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() { - err!("Account already initialized, cannot set password") - } - - // 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())?; - - set_kdf_data(&mut user, &data.kdf)?; - - user.set_password( - &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, - ) - .await?; - user.password_hint = password_hint; - - 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 { - if identifier != crate::sso::FAKE_SSO_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID { - let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else { - err!("Failed to retrieve the associated organization") - }; - - let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await else { - err!("Failed to retrieve the invitation") - }; - - accept_org_invite(&user, membership, None, &conn).await?; - } - } - - if CONFIG.mail_enabled() { - mail::send_welcome(&user.email.to_lowercase()).await?; - } else { - Membership::accept_user_invitations(&user.uuid, &conn).await?; - } - - log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; - - user.save(&conn).await?; - - Ok(Json(json!({ - "object": "set-password", - "captchaBypassToken": "", - }))) -} - #[get("/accounts/profile")] -async fn profile(headers: Headers, conn: DbConn) -> Json { - Json(headers.user.to_json(&conn).await) +async fn profile(headers: Headers, mut conn: DbConn) -> Json { + Json(headers.user.to_json(&mut conn).await) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ProfileData { // culture: String, // Ignored, always use en-US + // masterPasswordHint: Option, // Ignored, has been moved to ChangePassData name: String, } @@ -422,7 +261,7 @@ async fn put_profile(data: Json, headers: Headers, conn: DbConn) -> } #[post("/accounts/profile", data = "")] -async fn post_profile(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn post_profile(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: ProfileData = data.into_inner(); // Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden) @@ -434,8 +273,8 @@ async fn post_profile(data: Json, headers: Headers, conn: DbConn) - let mut user = headers.user; user.name = data.name; - user.save(&conn).await?; - Ok(Json(user.to_json(&conn).await)) + user.save(&mut conn).await?; + Ok(Json(user.to_json(&mut conn).await)) } #[derive(Deserialize)] @@ -445,7 +284,7 @@ struct AvatarData { } #[put("/accounts/avatar", data = "")] -async fn put_avatar(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn put_avatar(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: AvatarData = data.into_inner(); // It looks like it only supports the 6 hex color format. @@ -460,13 +299,13 @@ async fn put_avatar(data: Json, headers: Headers, conn: DbConn) -> J let mut user = headers.user; user.avatar_color = data.avatar_color; - user.save(&conn).await?; - Ok(Json(user.to_json(&conn).await)) + user.save(&mut conn).await?; + Ok(Json(user.to_json(&mut conn).await)) } -#[get("/users//public-key")] -async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> JsonResult { - let user = match User::find_by_uuid(&user_id, &conn).await { +#[get("/users//public-key")] +async fn get_public_keys(uuid: &str, _headers: Headers, mut conn: DbConn) -> JsonResult { + let user = match User::find_by_uuid(uuid, &mut 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), @@ -480,7 +319,7 @@ async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> Js } #[post("/accounts/keys", data = "")] -async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn post_keys(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: KeysData = data.into_inner(); let mut user = headers.user; @@ -488,7 +327,7 @@ async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> Json user.private_key = Some(data.encrypted_private_key); user.public_key = Some(data.public_key); - user.save(&conn).await?; + user.save(&mut conn).await?; Ok(Json(json!({ "privateKey": user.private_key, @@ -507,7 +346,7 @@ struct ChangePassData { } #[post("/accounts/password", data = "")] -async fn post_password(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { +async fn post_password(data: Json, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { let data: ChangePassData = data.into_inner(); let mut user = headers.user; @@ -515,37 +354,51 @@ async fn post_password(data: Json, headers: Headers, conn: DbCon err!("Invalid password") } - user.password_hint = clean_password_hint(data.master_password_hint.as_ref()); - enforce_password_hint_setting(user.password_hint.as_ref())?; + user.password_hint = clean_password_hint(&data.master_password_hint); + enforce_password_hint_setting(&user.password_hint)?; - log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) + log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn) .await; user.set_password( &data.new_master_password_hash, Some(data.key), true, - Some(vec![ - String::from("post_rotatekey"), - String::from("get_contacts"), - String::from("get_public_keys"), - String::from("get_api_webauthn"), - ]), - &conn, - ) - .await?; + Some(vec![String::from("post_rotatekey"), String::from("get_contacts"), String::from("get_public_keys")]), + ); - let save_result = user.save(&conn).await; + let save_result = user.save(&mut 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. - nt.send_logout(&user, Some(&headers.device), &conn).await; + nt.send_logout(&user, Some(headers.device.uuid)).await; save_result } -fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult { +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChangeKdfData { + kdf: i32, + kdf_iterations: i32, + kdf_memory: Option, + kdf_parallelism: Option, + + master_password_hash: String, + new_master_password_hash: String, + key: String, +} + +#[post("/accounts/kdf", data = "")] +async fn post_kdf(data: Json, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { + let data: ChangeKdfData = data.into_inner(); + let mut user = headers.user; + + if !user.check_valid_password(&data.master_password_hash) { + err!("Invalid password") + } + if data.kdf == UserKdfType::Pbkdf2 as i32 && data.kdf_iterations < 100_000 { err!("PBKDF2 KDF iterations must be at least 100000.") } @@ -576,69 +429,10 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult { } user.client_kdf_iter = data.kdf_iterations; user.client_kdf_type = data.kdf; + user.set_password(&data.new_master_password_hash, Some(data.key), true, None); + let save_result = user.save(&mut conn).await; - Ok(()) -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct AuthenticationData { - salt: String, - kdf: KDFData, - master_password_authentication_hash: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UnlockData { - salt: String, - kdf: KDFData, - master_key_wrapped_user_key: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ChangeKdfData { - #[allow(dead_code)] - new_master_password_hash: String, - #[allow(dead_code)] - key: String, - authentication_data: AuthenticationData, - unlock_data: UnlockData, - master_password_hash: String, -} - -#[post("/accounts/kdf", data = "")] -async fn post_kdf(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - let data: ChangeKdfData = data.into_inner(); - - if !headers.user.check_valid_password(&data.master_password_hash) { - err!("Invalid password") - } - - if data.authentication_data.kdf != data.unlock_data.kdf { - err!("KDF settings must be equal for authentication and unlock") - } - - if headers.user.email != data.authentication_data.salt || headers.user.email != data.unlock_data.salt { - err!("Invalid master password salt") - } - - let mut user = headers.user; - - set_kdf_data(&mut user, &data.unlock_data.kdf)?; - - user.set_password( - &data.authentication_data.master_password_authentication_hash, - Some(data.unlock_data.master_key_wrapped_user_key), - true, - None, - &conn, - ) - .await?; - let save_result = user.save(&conn).await; - - nt.send_logout(&user, Some(&headers.device), &conn).await; + nt.send_logout(&user, Some(headers.device.uuid)).await; save_result } @@ -649,22 +443,21 @@ struct UpdateFolderData { // There is a bug in 2024.3.x which adds a `null` item. // To bypass this we allow a Option here, but skip it during the updates // See: https://github.com/bitwarden/clients/issues/8453 - #[serde(default, deserialize_with = "deser_opt_nonempty_str")] - id: Option, + id: Option, name: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateEmergencyAccessData { - id: EmergencyAccessId, + id: String, key_encrypted: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateResetPasswordData { - organization_id: OrganizationId, + organization_id: String, reset_password_key: String, } @@ -674,131 +467,22 @@ use super::sends::{update_send_from_data, SendData}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct KeyData { - account_unlock_data: RotateAccountUnlockData, - account_keys: RotateAccountKeys, - account_data: RotateAccountData, - old_master_key_authentication_hash: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct RotateAccountUnlockData { - emergency_access_unlock_data: Vec, - master_password_unlock_data: MasterPasswordUnlockData, - organization_account_recovery_unlock_data: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct MasterPasswordUnlockData { - 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, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct RotateAccountKeys { - user_key_encrypted_account_private_key: String, - account_public_key: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct RotateAccountData { ciphers: Vec, folders: Vec, sends: Vec, + emergency_access_keys: Vec, + reset_password_keys: Vec, + key: String, + master_password_hash: String, + private_key: String, } -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 - .account_data - .ciphers - .iter() - .filter(|c| c.organization_id.is_none()) - .filter_map(|c| c.id.as_ref()) - .collect::>(); - if !provided_cipher_ids.is_superset(&existing_cipher_ids) { - err!("All existing ciphers must be included in the rotation") - } - - // Check that we're correctly rotating all the user's folders - let existing_folder_ids = existing_folders.iter().map(|f| &f.uuid).collect::>(); - let provided_folder_ids = - data.account_data.folders.iter().filter_map(|f| f.id.as_ref()).collect::>(); - if !provided_folder_ids.is_superset(&existing_folder_ids) { - err!("All existing folders must be included in the rotation") - } - - // 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 - .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") - } - - // Check that we're correctly rotating all the user's reset password keys - let existing_reset_password_ids = - existing_memberships.iter().map(|m| &m.org_uuid).collect::>(); - let provided_reset_password_ids = data - .account_unlock_data - .organization_account_recovery_unlock_data - .iter() - .map(|rp| &rp.organization_id) - .collect::>(); - if !provided_reset_password_ids.is_superset(&existing_reset_password_ids) { - err!("All existing reset password keys must be included in the rotation") - } - - // Check that we're correctly rotating all the user's sends - let existing_send_ids = existing_sends.iter().map(|s| &s.uuid).collect::>(); - let provided_send_ids = data.account_data.sends.iter().filter_map(|s| s.id.as_ref()).collect::>(); - if !provided_send_ids.is_superset(&existing_send_ids) { - err!("All existing sends must be included in the rotation") - } - - Ok(()) -} - -#[post("/accounts/key-management/rotate-user-account-keys", data = "")] -async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { +#[post("/accounts/key", data = "")] +async fn post_rotatekey(data: Json, headers: Headers, mut 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(); - if !headers.user.check_valid_password(&data.old_master_key_authentication_hash) { + if !headers.user.check_valid_password(&data.master_password_hash) { err!("Invalid password") } @@ -806,130 +490,117 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: // 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)?; + Cipher::validate_cipher_data(&data.ciphers)?; - 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; - 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_keydata( - &data, - &existing_ciphers, - &existing_folders, - &existing_emergency_access, - &existing_memberships, - &existing_sends, - &headers.user, - )?; + let user_uuid = &headers.user.uuid; // Update folder data - for folder_data in data.account_data.folders { + for folder_data in data.folders { // Skip `null` folder id entries. // See: https://github.com/bitwarden/clients/issues/8453 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") + let mut saved_folder = match Folder::find_by_uuid(&folder_id, &mut conn).await { + Some(folder) => folder, + None => err!("Folder doesn't exist"), }; + if &saved_folder.user_uuid != user_uuid { + err!("The folder is not owned by the user") + } + saved_folder.name = folder_data.name; - saved_folder.save(&conn).await? + saved_folder.save(&mut 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") - }; + for emergency_access_data in data.emergency_access_keys { + let mut saved_emergency_access = + match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emergency_access_data.id, user_uuid, &mut conn).await + { + Some(emergency_access) => emergency_access, + None => 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? + saved_emergency_access.save(&mut 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") - }; + for reset_password_data in data.reset_password_keys { + let mut user_org = + match UserOrganization::find_by_user_and_org(user_uuid, &reset_password_data.organization_id, &mut conn) + .await + { + Some(reset_password) => reset_password, + None => err!("Reset password doesn't exist"), + }; - membership.reset_password_key = Some(reset_password_data.reset_password_key); - membership.save(&conn).await? + user_org.reset_password_key = Some(reset_password_data.reset_password_key); + user_org.save(&mut conn).await? } // Update send data - for send_data in data.account_data.sends { - let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else { - err!("Send doesn't exist") + for send_data in data.sends { + let mut send = match Send::find_by_uuid(send_data.id.as_ref().unwrap(), &mut conn).await { + Some(send) => send, + None => err!("Send doesn't exist"), }; - update_send_from_data(send, send_data, &headers, &conn, &nt, UpdateType::None).await?; + update_send_from_data(&mut send, send_data, &headers, &mut conn, &nt, UpdateType::None).await?; } // Update cipher data use super::ciphers::update_cipher_from_data; - for cipher_data in data.account_data.ciphers { + for cipher_data in data.ciphers { if cipher_data.organization_id.is_none() { - let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap()) - else { - err!("Cipher doesn't exist") + let mut saved_cipher = match Cipher::find_by_uuid(cipher_data.id.as_ref().unwrap(), &mut conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), }; + if saved_cipher.user_uuid.as_ref().unwrap() != user_uuid { + err!("The cipher is not owned by the user") + } + // 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_cipher_from_data(&mut saved_cipher, cipher_data, &headers, None, &mut conn, &nt, UpdateType::None) + .await? } } // Update user data let mut user = headers.user; - 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.key; + user.private_key = Some(data.private_key); + user.reset_security_stamp(); - let save_result = user.save(&conn).await; + let save_result = user.save(&mut 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. - nt.send_logout(&user, Some(&headers.device), &conn).await; + nt.send_logout(&user, Some(headers.device.uuid)).await; save_result } #[post("/accounts/security-stamp", data = "")] -async fn post_sstamp(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { +async fn post_sstamp(data: Json, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { let data: PasswordOrOtpData = data.into_inner(); let mut user = headers.user; - data.validate(&user, true, &conn).await?; + data.validate(&user, true, &mut conn).await?; - user.reset_security_stamp(&conn).await?; - let save_result = user.save(&conn).await; + Device::delete_all_by_user(&user.uuid, &mut conn).await?; + user.reset_security_stamp(); + let save_result = user.save(&mut conn).await; - nt.send_logout(&user, None, &conn).await; - - Device::delete_all_by_user(&user.uuid, &conn).await?; + nt.send_logout(&user, None).await; save_result } @@ -942,7 +613,7 @@ struct EmailTokenData { } #[post("/accounts/email-token", data = "")] -async fn post_email_token(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +async fn post_email_token(data: Json, headers: Headers, mut conn: DbConn) -> EmptyResult { if !CONFIG.email_change_allowed() { err!("Email change is not allowed."); } @@ -954,22 +625,7 @@ async fn post_email_token(data: Json, headers: Headers, conn: Db err!("Invalid password") } - if let Some(existing_user) = User::find_by_mail(&data.new_email, &conn).await { - if CONFIG.mail_enabled() { - // check if existing_user has already registered - if existing_user.password_hash.is_empty() { - // inform an invited user about how to delete their temporary account if the - // request was done intentionally and they want to update their mail address - if let Err(e) = mail::send_change_email_invited(&data.new_email, &user.email).await { - error!("Error sending change-email-invited email: {e:#?}"); - } - } else { - // inform existing user about the failed attempt to change their mail address - if let Err(e) = mail::send_change_email_existing(&data.new_email, &user.email).await { - error!("Error sending change-email-existing email: {e:#?}"); - } - } - } + if User::find_by_mail(&data.new_email, &mut conn).await.is_some() { err!("Email already in use"); } @@ -981,15 +637,15 @@ async fn post_email_token(data: Json, headers: Headers, conn: Db if CONFIG.mail_enabled() { if let Err(e) = mail::send_change_email(&data.new_email, &token).await { - error!("Error sending change-email email: {e:#?}"); + error!("Error sending change-email email: {:#?}", e); } } else { - debug!("Email change request for user ({}) to email ({}) with token ({token})", user.uuid, data.new_email); + debug!("Email change request for user ({}) to email ({}) with token ({})", user.uuid, data.new_email, token); } user.email_new = Some(data.new_email); user.email_new_token = Some(token); - user.save(&conn).await + user.save(&mut conn).await } #[derive(Deserialize)] @@ -1004,7 +660,7 @@ struct ChangeEmailData { } #[post("/accounts/email", data = "")] -async fn post_email(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { +async fn post_email(data: Json, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { if !CONFIG.email_change_allowed() { err!("Email change is not allowed."); } @@ -1016,7 +672,7 @@ async fn post_email(data: Json, headers: Headers, conn: DbConn, err!("Invalid password") } - if User::find_by_mail(&data.new_email, &conn).await.is_some() { + if User::find_by_mail(&data.new_email, &mut conn).await.is_some() { err!("Email already in use"); } @@ -1048,11 +704,11 @@ async fn post_email(data: Json, headers: Headers, conn: DbConn, user.email_new = None; user.email_new_token = None; - user.set_password(&data.new_master_password_hash, Some(data.key), true, None, &conn).await?; + user.set_password(&data.new_master_password_hash, Some(data.key), true, None); - let save_result = user.save(&conn).await; + let save_result = user.save(&mut conn).await; - nt.send_logout(&user, None, &conn).await; + nt.send_logout(&user, None).await; save_result } @@ -1066,7 +722,7 @@ async fn post_verify_email(headers: Headers) -> EmptyResult { } if let Err(e) = mail::send_verify_email(&user.email, &user.uuid).await { - error!("Error sending verify_email email: {e:#?}"); + error!("Error sending verify_email email: {:#?}", e); } Ok(()) @@ -1075,29 +731,31 @@ async fn post_verify_email(headers: Headers) -> EmptyResult { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct VerifyEmailTokenData { - user_id: UserId, + user_id: String, token: String, } #[post("/accounts/verify-email-token", data = "")] -async fn post_verify_email_token(data: Json, conn: DbConn) -> EmptyResult { +async fn post_verify_email_token(data: Json, mut conn: DbConn) -> EmptyResult { let data: VerifyEmailTokenData = data.into_inner(); - let Some(mut user) = User::find_by_uuid(&data.user_id, &conn).await else { - err!("User doesn't exist") + let mut user = match User::find_by_uuid(&data.user_id, &mut conn).await { + Some(user) => user, + None => err!("User doesn't exist"), }; - let Ok(claims) = decode_verify_email(&data.token) else { - err!("Invalid claim") + let claims = match decode_verify_email(&data.token) { + Ok(claims) => claims, + Err(_) => err!("Invalid claim"), }; - if claims.sub != *user.uuid { + if claims.sub != user.uuid { err!("Invalid claim"); } user.verified_at = Some(Utc::now().naive_utc()); user.last_verifying_at = None; user.login_verify_count = 0; - if let Err(e) = user.save(&conn).await { - error!("Error saving email verification: {e:#?}"); + if let Err(e) = user.save(&mut conn).await { + error!("Error saving email verification: {:#?}", e); } Ok(()) @@ -1110,13 +768,13 @@ struct DeleteRecoverData { } #[post("/accounts/delete-recover", data = "")] -async fn post_delete_recover(data: Json, conn: DbConn) -> EmptyResult { +async fn post_delete_recover(data: Json, mut conn: DbConn) -> EmptyResult { let data: DeleteRecoverData = data.into_inner(); if CONFIG.mail_enabled() { - if let Some(user) = User::find_by_mail(&data.email, &conn).await { + if let Some(user) = User::find_by_mail(&data.email, &mut conn).await { if let Err(e) = mail::send_delete_account(&user.email, &user.uuid).await { - error!("Error sending delete account email: {e:#?}"); + error!("Error sending delete account email: {:#?}", e); } } Ok(()) @@ -1132,26 +790,27 @@ async fn post_delete_recover(data: Json, conn: DbConn) -> Emp #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct DeleteRecoverTokenData { - user_id: UserId, + user_id: String, token: String, } #[post("/accounts/delete-recover-token", data = "")] -async fn post_delete_recover_token(data: Json, conn: DbConn) -> EmptyResult { +async fn post_delete_recover_token(data: Json, mut conn: DbConn) -> EmptyResult { let data: DeleteRecoverTokenData = data.into_inner(); - let Ok(claims) = decode_delete(&data.token) else { - err!("Invalid claim") + let user = match User::find_by_uuid(&data.user_id, &mut conn).await { + Some(user) => user, + None => err!("User doesn't exist"), }; - let Some(user) = User::find_by_uuid(&data.user_id, &conn).await else { - err!("User doesn't exist") + let claims = match decode_delete(&data.token) { + Ok(claims) => claims, + Err(_) => err!("Invalid claim"), }; - - if claims.sub != *user.uuid { + if claims.sub != user.uuid { err!("Invalid claim"); } - user.delete(&conn).await + user.delete(&mut conn).await } #[post("/accounts/delete", data = "")] @@ -1160,13 +819,13 @@ async fn post_delete_account(data: Json, headers: Headers, co } #[delete("/accounts", data = "")] -async fn delete_account(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +async fn delete_account(data: Json, headers: Headers, mut conn: DbConn) -> EmptyResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - data.validate(&user, true, &conn).await?; + data.validate(&user, true, &mut conn).await?; - user.delete(&conn).await + user.delete(&mut conn).await } #[get("/accounts/revision-date")] @@ -1182,8 +841,8 @@ struct PasswordHintData { } #[post("/accounts/password-hint", data = "")] -async fn password_hint(data: Json, conn: DbConn) -> EmptyResult { - if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) { +async fn password_hint(data: Json, mut conn: DbConn) -> EmptyResult { + if !CONFIG.mail_enabled() && !CONFIG.show_password_hint() { err!("This server is not configured to provide password hints."); } @@ -1192,16 +851,17 @@ async fn password_hint(data: Json, conn: DbConn) -> EmptyResul let data: PasswordHintData = data.into_inner(); let email = &data.email; - match User::find_by_mail(email, &conn).await { + match User::find_by_mail(email, &mut conn).await { None => { // To prevent user enumeration, act as if the user exists. if CONFIG.mail_enabled() { // There is still a timing side channel here in that the code // paths that send mail take noticeably longer than ones that // don't. Add a randomized sleep to mitigate this somewhat. - use rand::{rngs::SmallRng, RngExt}; - let mut rng: SmallRng = rand::make_rng(); - let sleep_ms = rng.random_range(900..=1100) as u64; + use rand::{rngs::SmallRng, Rng, SeedableRng}; + let mut rng = SmallRng::from_entropy(); + let delta: i32 = 100; + let sleep_ms = (1_000 + rng.gen_range(-delta..=delta)) as u64; tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await; Ok(()) } else { @@ -1233,67 +893,54 @@ async fn prelogin(data: Json, conn: DbConn) -> Json { _prelogin(data, conn).await } -pub async fn _prelogin(data: Json, conn: DbConn) -> Json { +pub async fn _prelogin(data: Json, mut conn: DbConn) -> Json { let data: PreloginData = data.into_inner(); - let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await { + let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &mut conn).await { Some(user) => (user.client_kdf_type, user.client_kdf_iter, user.client_kdf_memory, user.client_kdf_parallelism), None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None), }; - Json(json!({ + let result = json!({ "kdf": kdf_type, "kdfIterations": kdf_iter, "kdfMemory": kdf_mem, "kdfParallelism": kdf_para, - })) + }); + + Json(result) } -// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs +// https://github.com/bitwarden/server/blob/master/src/Api/Models/Request/Accounts/SecretVerificationRequestModel.cs #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SecretVerificationRequest { master_password_hash: String, } -// Change the KDF Iterations if necessary -pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &DbConn) -> ApiResult<()> { - if user.password_iterations < CONFIG.password_iterations() { - user.password_iterations = CONFIG.password_iterations(); - user.set_password(pwd_hash, None, false, None, conn).await?; - - if let Err(e) = user.save(conn).await { - error!("Error updating user: {e:#?}"); - } - } - Ok(()) -} - #[post("/accounts/verify-password", data = "")] -async fn verify_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +fn verify_password(data: Json, headers: Headers) -> EmptyResult { let data: SecretVerificationRequest = data.into_inner(); - let mut user = headers.user; + let user = headers.user; if !user.check_valid_password(&data.master_password_hash) { err!("Invalid password") } - kdf_upgrade(&mut user, &data.master_password_hash, &conn).await?; - - Ok(Json(master_password_policy(&user, &conn).await)) + Ok(()) } -async fn _api_key(data: Json, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult { +async fn _api_key(data: Json, rotate: bool, headers: Headers, mut conn: DbConn) -> JsonResult { use crate::util::format_date; let data: PasswordOrOtpData = data.into_inner(); let mut user = headers.user; - data.validate(&user, true, &conn).await?; + data.validate(&user, true, &mut conn).await?; if rotate || user.api_key.is_none() { user.api_key = Some(crypto::generate_api_key()); - user.save(&conn).await.expect("Error saving API key"); + user.save(&mut conn).await.expect("Error saving API key"); } Ok(Json(json!({ @@ -1314,18 +961,17 @@ async fn rotate_api_key(data: Json, headers: Headers, conn: D } #[get("/devices/knowndevice")] -async fn get_known_device(device: KnownDevice, conn: DbConn) -> JsonResult { - let result = if let Some(user) = User::find_by_mail(&device.email, &conn).await { - Device::find_by_uuid_and_user(&device.uuid, &user.uuid, &conn).await.is_some() - } else { - false - }; +async fn get_known_device(device: KnownDevice, mut conn: DbConn) -> JsonResult { + let mut result = false; + if let Some(user) = User::find_by_mail(&device.email, &mut conn).await { + result = Device::find_by_uuid_and_user(&device.uuid, &user.uuid, &mut conn).await.is_some(); + } Ok(Json(json!(result))) } struct KnownDevice { email: String, - uuid: DeviceId, + uuid: String, } #[rocket::async_trait] @@ -1334,13 +980,11 @@ impl<'r> FromRequest<'r> for KnownDevice { async fn from_request(req: &'r Request<'_>) -> Outcome { let email = if let Some(email_b64) = req.headers().get_one("X-Request-Email") { - // Bitwarden seems to send padded Base64 strings since 2026.2.1 - // Since these values are not streamed and Headers are always split by newlines - // we can safely ignore padding here and remove any '=' appended. - let email_b64 = email_b64.trim_end_matches('='); - - let Ok(email_bytes) = data_encoding::BASE64URL_NOPAD.decode(email_b64.as_bytes()) else { - return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as base64url")); + let email_bytes = match data_encoding::BASE64URL_NOPAD.decode(email_b64.as_bytes()) { + Ok(bytes) => bytes, + Err(_) => { + return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as base64url")); + } }; match String::from_utf8(email_bytes) { Ok(email) => email, @@ -1353,7 +997,7 @@ impl<'r> FromRequest<'r> for KnownDevice { }; let uuid = if let Some(uuid) = req.headers().get_one("X-Device-Identifier") { - uuid.to_string().into() + uuid.to_string() } else { return Outcome::Error((Status::BadRequest, "X-Device-Identifier value is required")); }; @@ -1365,150 +1009,112 @@ impl<'r> FromRequest<'r> for KnownDevice { } } -#[get("/devices")] -async fn get_all_devices(headers: Headers, conn: DbConn) -> JsonResult { - let devices = Device::find_with_auth_request_by_user(&headers.user.uuid, &conn).await; - let devices = devices.iter().map(|device| device.to_json()).collect::>(); - - Ok(Json(json!({ - "data": devices, - "continuationToken": null, - "object": "list" - }))) -} - -#[get("/devices/identifier/")] -async fn get_device(device_id: DeviceId, headers: Headers, conn: DbConn) -> JsonResult { - let Some(device) = Device::find_by_uuid_and_user(&device_id, &headers.user.uuid, &conn).await else { - err!("No device found"); - }; - Ok(Json(device.to_json())) -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PushToken { push_token: String, } -#[post("/devices/identifier//token", data = "")] -async fn post_device_token(device_id: DeviceId, data: Json, headers: Headers, conn: DbConn) -> EmptyResult { - put_device_token(device_id, data, headers, conn).await +#[post("/devices/identifier//token", data = "")] +async fn post_device_token(uuid: &str, data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + put_device_token(uuid, data, headers, conn).await } -#[put("/devices/identifier//token", data = "")] -async fn put_device_token(device_id: DeviceId, data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +#[put("/devices/identifier//token", data = "")] +async fn put_device_token(uuid: &str, data: Json, headers: Headers, mut conn: DbConn) -> EmptyResult { let data = data.into_inner(); let token = data.push_token; - let Some(mut device) = Device::find_by_uuid_and_user(&headers.device.uuid, &headers.user.uuid, &conn).await else { - err!(format!("Error: device {device_id} should be present before a token can be assigned")) + let mut device = match Device::find_by_uuid_and_user(&headers.device.uuid, &headers.user.uuid, &mut conn).await { + Some(device) => device, + None => err!(format!("Error: device {uuid} should be present before a token can be assigned")), }; - // Check if the new token is the same as the registered token - // Although upstream seems to always register a device on login, we do not. - // Unless this causes issues, lets keep it this way, else we might need to also register on every login. - if device.push_token.as_ref() == Some(&token) { - debug!("Device {device_id} for user {} is already registered and token is identical", headers.user.uuid); - return Ok(()); + // if the device already has been registered + if device.is_registered() { + // check if the new token is the same as the registered token + if device.push_token.is_some() && device.push_token.unwrap() == token.clone() { + debug!("Device {} is already registered and token is the same", uuid); + return Ok(()); + } else { + // Try to unregister already registered device + unregister_push_device(device.push_uuid).await.ok(); + } + // clear the push_uuid + device.push_uuid = None; } - device.push_token = Some(token); - if let Err(e) = device.save(true, &conn).await { + if let Err(e) = device.save(&mut conn).await { err!(format!("An error occurred while trying to save the device push token: {e}")); } - register_push_device(&mut device, &conn).await?; + register_push_device(&mut device, &mut conn).await?; Ok(()) } -#[put("/devices/identifier//clear-token")] -async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult { +#[put("/devices/identifier//clear-token")] +async fn put_clear_device_token(uuid: &str, mut conn: DbConn) -> EmptyResult { // This only clears push token - // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215 - // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37 + // https://github.com/bitwarden/core/blob/master/src/Api/Controllers/DevicesController.cs#L109 + // https://github.com/bitwarden/core/blob/master/src/Core/Services/Implementations/DeviceService.cs#L37 // This is somehow not implemented in any app, added it in case it is required - // 2025: Also, it looks like it only clears the first found device upstream, which is probably faulty. - // This because currently multiple accounts could be on the same device/app and that would cause issues. - // Vaultwarden removes the push-token for all devices, but this probably means we should also unregister all these devices. if !CONFIG.push_enabled() { return Ok(()); } - if let Some(device) = Device::find_by_uuid(&device_id, &conn).await { - Device::clear_push_token_by_uuid(&device_id, &conn).await?; - unregister_push_device(device.push_uuid.as_ref()).await?; + if let Some(device) = Device::find_by_uuid(uuid, &mut conn).await { + Device::clear_push_token_by_uuid(uuid, &mut conn).await?; + unregister_push_device(device.push_uuid).await?; } Ok(()) } // On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere -#[post("/devices/identifier//clear-token")] -async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult { - put_clear_device_token(device_id, conn).await -} - -#[get("/tasks")] -fn get_tasks(_client_headers: ClientHeaders) -> JsonResult { - Ok(Json(json!({ - "data": [], - "object": "list" - }))) +#[post("/devices/identifier//clear-token")] +async fn post_clear_device_token(uuid: &str, conn: DbConn) -> EmptyResult { + put_clear_device_token(uuid, conn).await } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct AuthRequestRequest { access_code: String, - device_identifier: DeviceId, + device_identifier: String, email: String, public_key: String, - // Not used for now - // #[serde(alias = "type")] - // _type: i32, + #[serde(alias = "type")] + _type: i32, } #[post("/auth-requests", data = "")] async fn post_auth_request( data: Json, - client_headers: ClientHeaders, - conn: DbConn, + headers: ClientHeaders, + mut conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let data = data.into_inner(); - let Some(user) = User::find_by_mail(&data.email, &conn).await else { - err!("AuthRequest doesn't exist", "User not found") - }; - - // Validate device uuid and type - let device = match Device::find_by_uuid_and_user(&data.device_identifier, &user.uuid, &conn).await { - Some(device) if device.atype == client_headers.device_type => device, - _ => err!("AuthRequest doesn't exist", "Device verification failed"), + let user = match User::find_by_mail(&data.email, &mut conn).await { + Some(user) => user, + None => { + err!("AuthRequest doesn't exist") + } }; let mut auth_request = AuthRequest::new( user.uuid.clone(), data.device_identifier.clone(), - client_headers.device_type, - client_headers.ip.ip.to_string(), + headers.device_type, + headers.ip.ip.to_string(), data.access_code, data.public_key, ); - auth_request.save(&conn).await?; + auth_request.save(&mut conn).await?; - nt.send_auth_request(&user.uuid, &auth_request.uuid, &device, &conn).await; - - log_user_event( - EventType::UserRequestedDeviceApproval as i32, - &user.uuid, - client_headers.device_type, - &client_headers.ip.ip, - &conn, - ) - .await; + nt.send_auth_request(&user.uuid, &auth_request.uuid, &data.device_identifier, &mut conn).await; Ok(Json(json!({ "id": auth_request.uuid, @@ -1517,7 +1123,7 @@ async fn post_auth_request( "requestIpAddress": auth_request.request_ip, "key": null, "masterPasswordHash": null, - "creationDate": format_date(&auth_request.creation_date), + "creationDate": auth_request.creation_date.and_utc().to_rfc3339_opts(SecondsFormat::Micros, true), "responseDate": null, "requestApproved": false, "origin": CONFIG.domain_origin(), @@ -1525,164 +1131,137 @@ async fn post_auth_request( }))) } -#[get("/auth-requests/")] -async fn get_auth_request(auth_request_id: AuthRequestId, headers: Headers, conn: DbConn) -> JsonResult { - let Some(auth_request) = AuthRequest::find_by_uuid_and_user(&auth_request_id, &headers.user.uuid, &conn).await - else { - err!("AuthRequest doesn't exist", "Record not found or user uuid does not match") +#[get("/auth-requests/")] +async fn get_auth_request(uuid: &str, mut conn: DbConn) -> JsonResult { + let auth_request = match AuthRequest::find_by_uuid(uuid, &mut conn).await { + Some(auth_request) => auth_request, + None => { + err!("AuthRequest doesn't exist") + } }; - let response_date_utc = auth_request.response_date.map(|response_date| format_date(&response_date)); + let response_date_utc = auth_request + .response_date + .map(|response_date| response_date.and_utc().to_rfc3339_opts(SecondsFormat::Micros, true)); - Ok(Json(json!({ - "id": &auth_request_id, - "publicKey": auth_request.public_key, - "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), - "requestIpAddress": auth_request.request_ip, - "key": auth_request.enc_key, - "masterPasswordHash": auth_request.master_password_hash, - "creationDate": format_date(&auth_request.creation_date), - "responseDate": response_date_utc, - "requestApproved": auth_request.approved, - "origin": CONFIG.domain_origin(), - "object":"auth-request" - }))) + Ok(Json(json!( + { + "id": uuid, + "publicKey": auth_request.public_key, + "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), + "requestIpAddress": auth_request.request_ip, + "key": auth_request.enc_key, + "masterPasswordHash": auth_request.master_password_hash, + "creationDate": auth_request.creation_date.and_utc().to_rfc3339_opts(SecondsFormat::Micros, true), + "responseDate": response_date_utc, + "requestApproved": auth_request.approved, + "origin": CONFIG.domain_origin(), + "object":"auth-request" + } + ))) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct AuthResponseRequest { - device_identifier: DeviceId, + device_identifier: String, key: String, master_password_hash: Option, request_approved: bool, } -#[put("/auth-requests/", data = "")] +#[put("/auth-requests/", data = "")] async fn put_auth_request( - auth_request_id: AuthRequestId, + uuid: &str, data: Json, - headers: Headers, - conn: DbConn, + mut conn: DbConn, ant: AnonymousNotify<'_>, nt: Notify<'_>, ) -> JsonResult { let data = data.into_inner(); - let Some(mut auth_request) = AuthRequest::find_by_uuid_and_user(&auth_request_id, &headers.user.uuid, &conn).await - else { - err!("AuthRequest doesn't exist", "Record not found or user uuid does not match") + let mut auth_request: AuthRequest = match AuthRequest::find_by_uuid(uuid, &mut conn).await { + Some(auth_request) => auth_request, + None => { + err!("AuthRequest doesn't exist") + } }; - if headers.device.uuid != data.device_identifier { - err!("AuthRequest doesn't exist", "Device verification failed") - } - - if auth_request.approved.is_some() { - err!("An authentication request with the same device already exists") - } - - let response_date = Utc::now().naive_utc(); - let response_date_utc = format_date(&response_date); - - if data.request_approved { - auth_request.approved = Some(data.request_approved); - auth_request.enc_key = Some(data.key); - auth_request.master_password_hash = data.master_password_hash; - auth_request.response_device_id = Some(data.device_identifier.clone()); - auth_request.response_date = Some(response_date); - auth_request.save(&conn).await?; + auth_request.approved = Some(data.request_approved); + auth_request.enc_key = Some(data.key); + auth_request.master_password_hash = data.master_password_hash; + auth_request.response_device_id = Some(data.device_identifier.clone()); + auth_request.save(&mut conn).await?; + if auth_request.approved.unwrap_or(false) { ant.send_auth_response(&auth_request.user_uuid, &auth_request.uuid).await; - nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &headers.device, &conn).await; - - log_user_event( - EventType::OrganizationUserApprovedAuthRequest as i32, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; - } else { - // If denied, there's no reason to keep the request - auth_request.delete(&conn).await?; - log_user_event( - EventType::OrganizationUserRejectedAuthRequest as i32, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; + nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, data.device_identifier, &mut conn).await; } - Ok(Json(json!({ - "id": &auth_request_id, - "publicKey": auth_request.public_key, - "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), - "requestIpAddress": auth_request.request_ip, - "key": auth_request.enc_key, - "masterPasswordHash": auth_request.master_password_hash, - "creationDate": format_date(&auth_request.creation_date), - "responseDate": response_date_utc, - "requestApproved": auth_request.approved, - "origin": CONFIG.domain_origin(), - "object":"auth-request" - }))) + let response_date_utc = auth_request + .response_date + .map(|response_date| response_date.and_utc().to_rfc3339_opts(SecondsFormat::Micros, true)); + + Ok(Json(json!( + { + "id": uuid, + "publicKey": auth_request.public_key, + "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), + "requestIpAddress": auth_request.request_ip, + "key": auth_request.enc_key, + "masterPasswordHash": auth_request.master_password_hash, + "creationDate": auth_request.creation_date.and_utc().to_rfc3339_opts(SecondsFormat::Micros, true), + "responseDate": response_date_utc, + "requestApproved": auth_request.approved, + "origin": CONFIG.domain_origin(), + "object":"auth-request" + } + ))) } -#[get("/auth-requests//response?")] -async fn get_auth_request_response( - auth_request_id: AuthRequestId, - code: &str, - client_headers: ClientHeaders, - conn: DbConn, -) -> JsonResult { - let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else { - err!("AuthRequest doesn't exist", "User not found") +#[get("/auth-requests//response?")] +async fn get_auth_request_response(uuid: &str, code: &str, mut conn: DbConn) -> JsonResult { + let auth_request = match AuthRequest::find_by_uuid(uuid, &mut conn).await { + Some(auth_request) => auth_request, + None => { + err!("AuthRequest doesn't exist") + } }; - 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", "Invalid device, IP or code") + if !auth_request.check_access_code(code) { + err!("Access code invalid doesn't exist") } - let response_date_utc = auth_request.response_date.map(|response_date| format_date(&response_date)); + let response_date_utc = auth_request + .response_date + .map(|response_date| response_date.and_utc().to_rfc3339_opts(SecondsFormat::Micros, true)); - Ok(Json(json!({ - "id": &auth_request_id, - "publicKey": auth_request.public_key, - "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), - "requestIpAddress": auth_request.request_ip, - "key": auth_request.enc_key, - "masterPasswordHash": auth_request.master_password_hash, - "creationDate": format_date(&auth_request.creation_date), - "responseDate": response_date_utc, - "requestApproved": auth_request.approved, - "origin": CONFIG.domain_origin(), - "object":"auth-request" - }))) + Ok(Json(json!( + { + "id": uuid, + "publicKey": auth_request.public_key, + "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), + "requestIpAddress": auth_request.request_ip, + "key": auth_request.enc_key, + "masterPasswordHash": auth_request.master_password_hash, + "creationDate": auth_request.creation_date.and_utc().to_rfc3339_opts(SecondsFormat::Micros, true), + "responseDate": response_date_utc, + "requestApproved": auth_request.approved, + "origin": CONFIG.domain_origin(), + "object":"auth-request" + } + ))) } -// Now unused but not yet removed -// cf https://github.com/bitwarden/clients/blob/9b2fbdba1c028bf3394064609630d2ec224baefa/libs/common/src/services/api.service.ts#L245 #[get("/auth-requests")] -async fn get_auth_requests(headers: Headers, conn: DbConn) -> JsonResult { - get_auth_requests_pending(headers, conn).await -} - -#[get("/auth-requests/pending")] -async fn get_auth_requests_pending(headers: Headers, conn: DbConn) -> JsonResult { - let auth_requests = AuthRequest::find_by_user(&headers.user.uuid, &conn).await; +async fn get_auth_requests(headers: Headers, mut conn: DbConn) -> JsonResult { + let auth_requests = AuthRequest::find_by_user(&headers.user.uuid, &mut conn).await; Ok(Json(json!({ "data": auth_requests .iter() .filter(|request| request.approved.is_none()) .map(|request| { - let response_date_utc = request.response_date.map(|response_date| format_date(&response_date)); + let response_date_utc = request.response_date.map(|response_date| response_date.and_utc().to_rfc3339_opts(SecondsFormat::Micros, true)); json!({ "id": request.uuid, @@ -1691,7 +1270,7 @@ async fn get_auth_requests_pending(headers: Headers, conn: DbConn) -> JsonResult "requestIpAddress": request.request_ip, "key": request.enc_key, "masterPasswordHash": request.master_password_hash, - "creationDate": format_date(&request.creation_date), + "creationDate": request.creation_date.and_utc().to_rfc3339_opts(SecondsFormat::Micros, true), "responseDate": response_date_utc, "requestApproved": request.approved, "origin": CONFIG.domain_origin(), @@ -1705,9 +1284,9 @@ async fn get_auth_requests_pending(headers: Headers, conn: DbConn) -> JsonResult pub async fn purge_auth_requests(pool: DbPool) { debug!("Purging auth requests"); - if let Ok(conn) = pool.get().await { - AuthRequest::purge_expired_auth_requests(&conn).await; + if let Ok(mut conn) = pool.get().await { + AuthRequest::purge_expired_auth_requests(&mut conn).await; } else { - error!("Failed to get DB connection while purging auth requests") + error!("Failed to get DB connection while purging trashed ciphers") } } diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 43e555e2..94bdc485 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -10,21 +10,12 @@ use rocket::{ }; use serde_json::Value; -use crate::auth::ClientVersion; -use crate::util::{deser_opt_nonempty_str, save_temp_file, NumberOrString}; +use crate::util::NumberOrString; use crate::{ api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType}, - auth::{Headers, OrgIdGuard, OwnerHeaders}, - config::PathType, + auth::Headers, crypto, - db::{ - models::{ - Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, - CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership, - MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, - }, - DbConn, DbPool, - }, + db::{models::*, DbConn, DbPool}, CONFIG, }; @@ -85,9 +76,7 @@ pub fn routes() -> Vec { restore_cipher_put, restore_cipher_put_admin, restore_cipher_selected, - restore_cipher_selected_admin, - purge_org_vault, - purge_personal_vault, + delete_all, move_cipher_selected, move_cipher_selected_put, put_collections2_update, @@ -96,17 +85,13 @@ pub fn routes() -> Vec { post_collections_update, post_collections_admin, put_collections_admin, - archive_cipher_put, - archive_cipher_selected, - unarchive_cipher_put, - unarchive_cipher_selected, ] } pub async fn purge_trashed_ciphers(pool: DbPool) { debug!("Purging trashed ciphers"); - if let Ok(conn) = pool.get().await { - Cipher::purge_trash(&conn).await; + if let Ok(mut conn) = pool.get().await { + Cipher::purge_trash(&mut conn).await; } else { error!("Failed to get DB connection while purging trashed ciphers") } @@ -119,76 +104,45 @@ struct SyncData { } #[get("/sync?")] -async fn sync(data: SyncData, headers: Headers, client_version: Option, conn: DbConn) -> JsonResult { - let user_json = headers.user.to_json(&conn).await; +async fn sync(data: SyncData, headers: Headers, mut conn: DbConn) -> Json { + let user_json = headers.user.to_json(&mut conn).await; // Get all ciphers which are visible by the user - let mut ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &conn).await; + let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &mut conn).await; - // Filter out SSH keys if the client version is less than 2024.12.0 - let show_ssh_keys = if let Some(client_version) = client_version { - let ver_match = semver::VersionReq::parse(">=2024.12.0").unwrap(); - ver_match.matches(&client_version.0) - } else { - false - }; - if !show_ssh_keys { - ciphers.retain(|c| c.atype != 5); - } - - let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &conn).await; + let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &mut conn).await; // Lets generate the ciphers_json using all the gathered info let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { ciphers_json.push( - c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &conn).await?, + c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &mut conn) + .await, ); } - let collections = Collection::find_by_user_uuid(headers.user.uuid.clone(), &conn).await; + let collections = Collection::find_by_user_uuid(headers.user.uuid.clone(), &mut conn).await; let mut collections_json = Vec::with_capacity(collections.len()); for c in collections { - collections_json.push(c.to_json_details(&headers.user.uuid, Some(&cipher_sync_data), &conn).await); + collections_json.push(c.to_json_details(&headers.user.uuid, Some(&cipher_sync_data), &mut conn).await); } let folders_json: Vec = - Folder::find_by_user(&headers.user.uuid, &conn).await.iter().map(Folder::to_json).collect(); + Folder::find_by_user(&headers.user.uuid, &mut conn).await.iter().map(Folder::to_json).collect(); let sends_json: Vec = - Send::find_by_user(&headers.user.uuid, &conn).await.iter().map(Send::to_json).collect(); + Send::find_by_user(&headers.user.uuid, &mut conn).await.iter().map(Send::to_json).collect(); let policies_json: Vec = - OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect(); + OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &mut conn).await.iter().map(OrgPolicy::to_json).collect(); let domains_json = if data.exclude_domains { Value::Null } else { - api::core::_get_eq_domains(&headers, true).into_inner() + api::core::_get_eq_domains(headers, true).into_inner() }; - // This is very similar to the the userDecryptionOptions sent in connect/token, - // but as of 2025-12-19 they're both using different casing conventions. - let has_master_password = !headers.user.password_hash.is_empty(); - let master_password_unlock = if has_master_password { - json!({ - "kdf": { - "kdfType": headers.user.client_kdf_type, - "iterations": headers.user.client_kdf_iter, - "memory": headers.user.client_kdf_memory, - "parallelism": headers.user.client_kdf_parallelism - }, - // This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps. - // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 - "masterKeyEncryptedUserKey": headers.user.akey, - "masterKeyWrappedUserKey": headers.user.akey, - "salt": headers.user.email - }) - } else { - Value::Null - }; - - Ok(Json(json!({ + Json(json!({ "profile": user_json, "folders": folders_json, "collections": collections_json, @@ -196,67 +150,65 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option JsonResult { - let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &conn).await; - let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &conn).await; +async fn get_ciphers(headers: Headers, mut conn: DbConn) -> Json { + let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &mut conn).await; + let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &mut conn).await; let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { ciphers_json.push( - c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &conn).await?, + c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &mut conn) + .await, ); } - Ok(Json(json!({ + Json(json!({ "data": ciphers_json, "object": "list", "continuationToken": null - }))) + })) } -#[get("/ciphers/")] -async fn get_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") +#[get("/ciphers/")] +async fn get_cipher(uuid: &str, headers: Headers, mut conn: DbConn) -> JsonResult { + let cipher = match Cipher::find_by_uuid(uuid, &mut conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), }; - if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, &mut conn).await { err!("Cipher is not owned by user") } - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) } -#[get("/ciphers//admin")] -async fn get_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { +#[get("/ciphers//admin")] +async fn get_cipher_admin(uuid: &str, headers: Headers, conn: DbConn) -> JsonResult { // TODO: Implement this correctly - get_cipher(cipher_id, headers, conn).await + get_cipher(uuid, headers, conn).await } -#[get("/ciphers//details")] -async fn get_cipher_details(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { - get_cipher(cipher_id, headers, conn).await +#[get("/ciphers//details")] +async fn get_cipher_details(uuid: &str, headers: Headers, conn: DbConn) -> JsonResult { + get_cipher(uuid, headers, conn).await } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CipherData { // Id is optional as it is included only in bulk share - pub id: Option, + pub id: Option, // Folder id is not included in import - #[serde(default, deserialize_with = "deser_opt_nonempty_str")] - pub folder_id: Option, + folder_id: Option, // TODO: Some of these might appear all the time, no need for Option #[serde(alias = "organizationID")] - pub organization_id: Option, + pub organization_id: Option, key: Option, @@ -264,8 +216,7 @@ pub struct CipherData { Login = 1, SecureNote = 2, Card = 3, - Identity = 4, - SshKey = 5 + Identity = 4 */ pub r#type: i32, pub name: String, @@ -277,7 +228,6 @@ pub struct CipherData { secure_note: Option, card: Option, identity: Option, - ssh_key: Option, favorite: Option, reprompt: Option, @@ -288,7 +238,7 @@ pub struct CipherData { // 'Attachments' is unused, contains map of {id: filename} #[allow(dead_code)] attachments: Option, - attachments2: Option>, + attachments2: Option>, // The revision datetime (in ISO 8601 format) of the client's local copy // of the cipher. This is used to prevent a client from updating a cipher @@ -297,14 +247,12 @@ pub struct CipherData { // when using older client versions, or if the operation doesn't involve // updating an existing cipher. last_known_revision_date: Option, - archived_date: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PartialCipherData { - #[serde(default, deserialize_with = "deser_opt_nonempty_str")] - folder_id: Option, + folder_id: Option, favorite: bool, } @@ -328,19 +276,25 @@ async fn post_ciphers_admin(data: Json, headers: Headers, conn: async fn post_ciphers_create( data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let mut data: ShareCipherData = data.into_inner(); + // Check if there are one more more collections selected when this cipher is part of an organization. + // err if this is not the case before creating an empty cipher. + if data.cipher.organization_id.is_some() && data.collection_ids.is_empty() { + err!("You must select at least one collection."); + } + // 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?; + enforce_personal_ownership_policy(Some(&data.cipher), &headers, &mut conn).await?; 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?; + cipher.save(&mut conn).await?; // When cloning a cipher, the Bitwarden clients seem to set this field // based on the cipher being cloned (when creating a new cipher, it's set @@ -350,16 +304,12 @@ async fn post_ciphers_create( // or otherwise), we can just ignore this field entirely. data.cipher.last_known_revision_date = None; - let res = share_cipher_by_uuid(&cipher.uuid, data, &headers, &conn, &nt, None).await; - if res.is_err() { - cipher.delete(&conn).await?; - } - res + share_cipher_by_uuid(&cipher.uuid, data, &headers, &mut conn, &nt).await } /// Called when creating a new user-owned cipher. #[post("/ciphers", data = "")] -async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { +async fn post_ciphers(data: Json, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult { let mut data: CipherData = data.into_inner(); // The web/browser clients set this field to null as expected, but the @@ -369,9 +319,9 @@ async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt data.last_known_revision_date = None; 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?; + update_cipher_from_data(&mut cipher, data, &headers, None, &mut conn, &nt, UpdateType::SyncCipherCreate).await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) } /// Enforces the personal ownership policy on user-owned ciphers, if applicable. @@ -381,11 +331,15 @@ async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt /// allowed to delete or share such ciphers to an org, however. /// /// Ref: https://bitwarden.com/help/article/policies/#personal-ownership -async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: &Headers, conn: &DbConn) -> EmptyResult { +async fn enforce_personal_ownership_policy( + data: Option<&CipherData>, + headers: &Headers, + conn: &mut DbConn, +) -> EmptyResult { if data.is_none() || data.unwrap().organization_id.is_none() { - let user_id = &headers.user.uuid; + let user_uuid = &headers.user.uuid; let policy_type = OrgPolicyType::PersonalOwnership; - if OrgPolicy::is_applicable_to_user(user_id, policy_type, None, conn).await { + if OrgPolicy::is_applicable_to_user(user_uuid, policy_type, None, conn).await { err!("Due to an Enterprise Policy, you are restricted from saving items to your personal vault.") } } @@ -396,8 +350,8 @@ pub async fn update_cipher_from_data( cipher: &mut Cipher, data: CipherData, headers: &Headers, - shared_to_collections: Option>, - conn: &DbConn, + shared_to_collections: Option>, + conn: &mut DbConn, nt: &Notify<'_>, ut: UpdateType, ) -> EmptyResult { @@ -409,7 +363,7 @@ pub async fn update_cipher_from_data( if 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}"), + 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.") } @@ -433,11 +387,11 @@ pub async fn update_cipher_from_data( let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some(); if let Some(org_id) = data.organization_id { - match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await { + match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, conn).await { None => err!("You don't have permission to add item to organization"), - Some(member) => { + Some(org_user) => { if shared_to_collections.is_some() - || member.has_full_access() + || org_user.has_full_access() || cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { cipher.organization_uuid = Some(org_id); @@ -457,8 +411,13 @@ pub async fn update_cipher_from_data( } if let Some(ref folder_id) = data.folder_id { - if 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"); + match Folder::find_by_uuid(folder_id, conn).await { + Some(folder) => { + if folder.user_uuid != headers.user.uuid { + err!("Folder is not owned by user") + } + } + None => err!("Folder doesn't exist"), } } @@ -510,7 +469,6 @@ pub async fn update_cipher_from_data( 2 => data.secure_note, 3 => data.card, 4 => data.identity, - 5 => data.ssh_key, _ => err!("Invalid type"), }; @@ -533,22 +491,15 @@ pub async fn update_cipher_from_data( 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.reprompt = data.reprompt; cipher.save(conn).await?; cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?; cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?; - if let Some(dt_str) = data.archived_date { - 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 '{dt_str}': {err}"), - } - } - if ut != UpdateType::None { // Only log events for organizational ciphers - if let Some(org_id) = &cipher.organization_uuid { + if let Some(org_uuid) = &cipher.organization_uuid { let event_type = match (&ut, transfer_cipher) { (UpdateType::SyncCipherCreate, true) => EventType::CipherCreated, (UpdateType::SyncCipherUpdate, true) => EventType::CipherShared, @@ -558,7 +509,7 @@ pub async fn update_cipher_from_data( log_event( event_type as i32, &cipher.uuid, - org_id, + org_uuid, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -570,7 +521,7 @@ pub async fn update_cipher_from_data( ut, cipher, &cipher.update_users_revision(conn).await, - &headers.device, + &headers.device.uuid, shared_to_collections, conn, ) @@ -597,8 +548,13 @@ struct RelationsData { } #[post("/ciphers/import", data = "")] -async fn post_ciphers_import(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - enforce_personal_ownership_policy(None, &headers, &conn).await?; +async fn post_ciphers_import( + data: Json, + headers: Headers, + mut conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + enforce_personal_ownership_policy(None, &headers, &mut conn).await?; let data: ImportData = data.into_inner(); @@ -609,90 +565,85 @@ async fn post_ciphers_import(data: Json, headers: Headers, conn: DbC Cipher::validate_cipher_data(&data.ciphers)?; // Read and create the folders - let existing_folders: HashSet> = - Folder::find_by_user(&headers.user.uuid, &conn).await.into_iter().map(|f| Some(f.uuid)).collect(); - let mut folders: Vec = Vec::with_capacity(data.folders.len()); + let existing_folders: Vec = + Folder::find_by_user(&headers.user.uuid, &mut conn).await.into_iter().map(|f| f.uuid).collect(); + let mut folders: Vec = Vec::with_capacity(data.folders.len()); for folder in data.folders.into_iter() { - let folder_id = if existing_folders.contains(&folder.id) { + let folder_uuid = if folder.id.is_some() && existing_folders.contains(folder.id.as_ref().unwrap()) { folder.id.unwrap() } else { let mut new_folder = Folder::new(headers.user.uuid.clone(), folder.name); - new_folder.save(&conn).await?; + new_folder.save(&mut conn).await?; new_folder.uuid }; - folders.push(folder_id); + folders.push(folder_uuid); } // Read the relations between folders and ciphers - // Ciphers can only be in one folder at the same time let mut relations_map = HashMap::with_capacity(data.folder_relationships.len()); + for relation in data.folder_relationships { relations_map.insert(relation.key, relation.value); } // Read and create the ciphers for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() { - let folder_id = relations_map.get(&index).map(|i| folders[*i].clone()); - cipher_data.folder_id = folder_id; + let folder_uuid = relations_map.get(&index).map(|i| folders[*i].clone()); + cipher_data.folder_id = folder_uuid; 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?; + update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &mut conn, &nt, UpdateType::None).await?; } let mut user = headers.user; - user.update_revision(&conn).await?; - nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await; + user.update_revision(&mut conn).await?; + nt.send_user_update(UpdateType::SyncVault, &user).await; Ok(()) } /// Called when an org admin modifies an existing org cipher. -#[put("/ciphers//admin", data = "")] +#[put("/ciphers//admin", data = "")] async fn put_cipher_admin( - cipher_id: CipherId, + uuid: &str, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - put_cipher(cipher_id, data, headers, conn, nt).await + put_cipher(uuid, data, headers, conn, nt).await } -#[post("/ciphers//admin", data = "")] +#[post("/ciphers//admin", data = "")] async fn post_cipher_admin( - cipher_id: CipherId, + uuid: &str, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - post_cipher(cipher_id, data, headers, conn, nt).await + post_cipher(uuid, data, headers, conn, nt).await } -#[post("/ciphers/", data = "")] -async fn post_cipher( - cipher_id: CipherId, - data: Json, - headers: Headers, - conn: DbConn, - nt: Notify<'_>, -) -> JsonResult { - put_cipher(cipher_id, data, headers, conn, nt).await +#[post("/ciphers/", data = "")] +async fn post_cipher(uuid: &str, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { + put_cipher(uuid, data, headers, conn, nt).await } -#[put("/ciphers/", data = "")] +#[put("/ciphers/", data = "")] async fn put_cipher( - cipher_id: CipherId, + uuid: &str, data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let data: CipherData = data.into_inner(); - let Some(mut cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") + let mut cipher = match Cipher::find_by_uuid(uuid, &mut conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), }; // TODO: Check if only the folder ID or favorite status is being changed. @@ -700,84 +651,81 @@ async fn put_cipher( // cipher itself, so the user shouldn't need write access to change these. // Interestingly, upstream Bitwarden doesn't properly handle this either. - if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, &mut conn).await { err!("Cipher is not write accessible") } - update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?; + update_cipher_from_data(&mut cipher, data, &headers, None, &mut conn, &nt, UpdateType::SyncCipherUpdate).await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) } -#[post("/ciphers//partial", data = "")] -async fn post_cipher_partial( - cipher_id: CipherId, - data: Json, - headers: Headers, - conn: DbConn, -) -> JsonResult { - put_cipher_partial(cipher_id, data, headers, conn).await +#[post("/ciphers//partial", data = "")] +async fn post_cipher_partial(uuid: &str, data: Json, headers: Headers, conn: DbConn) -> JsonResult { + put_cipher_partial(uuid, data, headers, conn).await } // Only update the folder and favorite for the user, since this cipher is read-only -#[put("/ciphers//partial", data = "")] +#[put("/ciphers//partial", data = "")] async fn put_cipher_partial( - cipher_id: CipherId, + uuid: &str, data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, ) -> JsonResult { let data: PartialCipherData = data.into_inner(); - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher does not exist") + let cipher = match Cipher::find_by_uuid(uuid, &mut conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), }; - if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { - err!("Cipher does not exist", "Cipher is not accessible for the current user") - } - if let Some(ref folder_id) = data.folder_id { - if 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"); + match Folder::find_by_uuid(folder_id, &mut conn).await { + Some(folder) => { + if folder.user_uuid != headers.user.uuid { + err!("Folder is not owned by user") + } + } + None => err!("Folder doesn't exist"), } } // Move cipher - cipher.move_to_folder(data.folder_id.clone(), &headers.user.uuid, &conn).await?; + cipher.move_to_folder(data.folder_id.clone(), &headers.user.uuid, &mut conn).await?; // Update favorite - cipher.set_favorite(Some(data.favorite), &headers.user.uuid, &conn).await?; + cipher.set_favorite(Some(data.favorite), &headers.user.uuid, &mut conn).await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CollectionsAdminData { #[serde(alias = "CollectionIds")] - collection_ids: Vec, + collection_ids: Vec, } -#[put("/ciphers//collections_v2", data = "")] +#[put("/ciphers//collections_v2", data = "")] async fn put_collections2_update( - cipher_id: CipherId, + uuid: &str, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - post_collections2_update(cipher_id, data, headers, conn, nt).await + post_collections2_update(uuid, data, headers, conn, nt).await } -#[post("/ciphers//collections_v2", data = "")] +#[post("/ciphers//collections_v2", data = "")] async fn post_collections2_update( - cipher_id: CipherId, + uuid: &str, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - let cipher_details = post_collections_update(cipher_id, data, headers, conn, nt).await?; + let cipher_details = post_collections_update(uuid, data, headers, conn, nt).await?; Ok(Json(json!({ // AttachmentUploadDataResponseModel "object": "optionalCipherDetails", "unavailable": false, @@ -785,54 +733,51 @@ async fn post_collections2_update( }))) } -#[put("/ciphers//collections", data = "")] +#[put("/ciphers//collections", data = "")] async fn put_collections_update( - cipher_id: CipherId, + uuid: &str, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - post_collections_update(cipher_id, data, headers, conn, nt).await + post_collections_update(uuid, data, headers, conn, nt).await } -#[post("/ciphers//collections", data = "")] +#[post("/ciphers//collections", data = "")] async fn post_collections_update( - cipher_id: CipherId, + uuid: &str, data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let data: CollectionsAdminData = data.into_inner(); - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") + let cipher = match Cipher::find_by_uuid(uuid, &mut conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), }; - if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { - err!("Collection cannot be changed") + if !cipher.is_write_accessible_to_user(&headers.user.uuid, &mut conn).await { + err!("Cipher is not write accessible") } - let Some(ref org_uuid) = cipher.organization_uuid else { - err!("Cipher is not owned by an organization") - }; - - let posted_collections = HashSet::::from_iter(data.collection_ids); + let posted_collections = HashSet::::from_iter(data.collection_ids); let current_collections = - HashSet::::from_iter(cipher.get_collections(headers.user.uuid.clone(), &conn).await); + HashSet::::from_iter(cipher.get_collections(headers.user.uuid.clone(), &mut conn).await); for collection in posted_collections.symmetric_difference(¤t_collections) { - match Collection::find_by_uuid_and_org(collection, org_uuid, &conn).await { + match Collection::find_by_uuid(collection, &mut conn).await { None => err!("Invalid collection ID provided"), Some(collection) => { - if collection.is_writable_by_user(&headers.user.uuid, &conn).await { + if collection.is_writable_by_user(&headers.user.uuid, &mut conn).await { if posted_collections.contains(&collection.uuid) { // Add to collection - CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await?; + CollectionCipher::save(&cipher.uuid, &collection.uuid, &mut conn).await?; } else { // Remove from collection - CollectionCipher::delete(&cipher.uuid, &collection.uuid, &conn).await?; + CollectionCipher::delete(&cipher.uuid, &collection.uuid, &mut conn).await?; } } else { err!("No rights to modify the collection") @@ -844,75 +789,72 @@ async fn post_collections_update( nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, - &cipher.update_users_revision(&conn).await, - &headers.device, + &cipher.update_users_revision(&mut conn).await, + &headers.device.uuid, Some(Vec::from_iter(posted_collections)), - &conn, + &mut conn, ) .await; log_event( EventType::CipherUpdatedCollections as i32, &cipher.uuid, - org_uuid, + &cipher.organization_uuid.clone().unwrap(), &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) } -#[put("/ciphers//collections-admin", data = "")] +#[put("/ciphers//collections-admin", data = "")] async fn put_collections_admin( - cipher_id: CipherId, + uuid: &str, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - post_collections_admin(cipher_id, data, headers, conn, nt).await + post_collections_admin(uuid, data, headers, conn, nt).await } -#[post("/ciphers//collections-admin", data = "")] +#[post("/ciphers//collections-admin", data = "")] async fn post_collections_admin( - cipher_id: CipherId, + uuid: &str, data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let data: CollectionsAdminData = data.into_inner(); - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") + let cipher = match Cipher::find_by_uuid(uuid, &mut conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), }; - if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { - err!("Collection cannot be changed") + if !cipher.is_write_accessible_to_user(&headers.user.uuid, &mut conn).await { + err!("Cipher is not write accessible") } - let Some(ref org_uuid) = cipher.organization_uuid else { - err!("Cipher is not owned by an organization") - }; - - let posted_collections = HashSet::::from_iter(data.collection_ids); + let posted_collections = HashSet::::from_iter(data.collection_ids); let current_collections = - HashSet::::from_iter(cipher.get_admin_collections(headers.user.uuid.clone(), &conn).await); + HashSet::::from_iter(cipher.get_admin_collections(headers.user.uuid.clone(), &mut conn).await); for collection in posted_collections.symmetric_difference(¤t_collections) { - match Collection::find_by_uuid_and_org(collection, org_uuid, &conn).await { + match Collection::find_by_uuid(collection, &mut conn).await { None => err!("Invalid collection ID provided"), Some(collection) => { - if collection.is_writable_by_user(&headers.user.uuid, &conn).await { + if collection.is_writable_by_user(&headers.user.uuid, &mut conn).await { if posted_collections.contains(&collection.uuid) { // Add to collection - CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await?; + CollectionCipher::save(&cipher.uuid, &collection.uuid, &mut conn).await?; } else { // Remove from collection - CollectionCipher::delete(&cipher.uuid, &collection.uuid, &conn).await?; + CollectionCipher::delete(&cipher.uuid, &collection.uuid, &mut conn).await?; } } else { err!("No rights to modify the collection") @@ -924,21 +866,21 @@ async fn post_collections_admin( nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, - &cipher.update_users_revision(&conn).await, - &headers.device, + &cipher.update_users_revision(&mut conn).await, + &headers.device.uuid, Some(Vec::from_iter(posted_collections)), - &conn, + &mut conn, ) .await; log_event( EventType::CipherUpdatedCollections as i32, &cipher.uuid, - org_uuid, + &cipher.organization_uuid.unwrap(), &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await; @@ -951,47 +893,47 @@ struct ShareCipherData { #[serde(alias = "Cipher")] cipher: CipherData, #[serde(alias = "CollectionIds")] - collection_ids: Vec, + collection_ids: Vec, } -#[post("/ciphers//share", data = "")] +#[post("/ciphers//share", data = "")] async fn post_cipher_share( - cipher_id: CipherId, + uuid: &str, data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let data: ShareCipherData = data.into_inner(); - share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await + share_cipher_by_uuid(uuid, data, &headers, &mut conn, &nt).await } -#[put("/ciphers//share", data = "")] +#[put("/ciphers//share", data = "")] async fn put_cipher_share( - cipher_id: CipherId, + uuid: &str, data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let data: ShareCipherData = data.into_inner(); - share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await + share_cipher_by_uuid(uuid, data, &headers, &mut conn, &nt).await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ShareSelectedCipherData { ciphers: Vec, - collection_ids: Vec, + collection_ids: Vec, } #[put("/ciphers/share", data = "")] async fn put_cipher_share_selected( data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let mut data: ShareSelectedCipherData = data.into_inner(); @@ -1017,28 +959,22 @@ async fn put_cipher_share_selected( }; match shared_cipher_data.cipher.id.take() { - Some(id) => { - share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await? - } + Some(id) => share_cipher_by_uuid(&id, shared_cipher_data, &headers, &mut conn, &nt).await?, None => 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(()) } async fn share_cipher_by_uuid( - cipher_id: &CipherId, + uuid: &str, data: ShareCipherData, headers: &Headers, - conn: &DbConn, + conn: &mut DbConn, nt: &Notify<'_>, - override_ut: Option, ) -> JsonResult { - let mut cipher = match Cipher::find_by_uuid(cipher_id, conn).await { + let mut cipher = match Cipher::find_by_uuid(uuid, conn).await { Some(cipher) => { if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { cipher @@ -1051,9 +987,9 @@ async fn share_cipher_by_uuid( let mut shared_to_collections = vec![]; - if let Some(organization_id) = &data.cipher.organization_id { - for col_id in &data.collection_ids { - match Collection::find_by_uuid_and_org(col_id, organization_id, conn).await { + if let Some(organization_uuid) = &data.cipher.organization_id { + for uuid in &data.collection_ids { + match Collection::find_by_uuid_and_org(uuid, organization_uuid, conn).await { None => err!("Invalid collection ID provided"), Some(collection) => { if collection.is_writable_by_user(&headers.user.uuid, conn).await { @@ -1068,10 +1004,7 @@ 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() { + let ut = if data.cipher.last_known_revision_date.is_some() { UpdateType::SyncCipherUpdate } else { UpdateType::SyncCipherCreate @@ -1079,7 +1012,7 @@ async fn share_cipher_by_uuid( 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?)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await)) } /// v2 API for downloading an attachment. This just redirects the client to @@ -1088,23 +1021,19 @@ async fn share_cipher_by_uuid( /// Upstream added this v2 API to support direct download of attachments from /// their object storage service. For self-hosted instances, it basically just /// redirects to the same location as before the v2 API. -#[get("/ciphers//attachment/")] -async fn get_attachment( - cipher_id: CipherId, - attachment_id: AttachmentId, - headers: Headers, - conn: DbConn, -) -> JsonResult { - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") +#[get("/ciphers//attachment/")] +async fn get_attachment(uuid: &str, attachment_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult { + let cipher = match Cipher::find_by_uuid(uuid, &mut conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), }; - if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, &mut conn).await { err!("Cipher is not accessible") } - match Attachment::find_by_id(&attachment_id, &conn).await { - Some(attachment) if cipher_id == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host).await?)), + match Attachment::find_by_id(attachment_id, &mut conn).await { + Some(attachment) if uuid == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host))), Some(_) => err!("Attachment doesn't belong to cipher"), None => err!("Attachment doesn't exist"), } @@ -1128,18 +1057,19 @@ enum FileUploadType { /// 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. /// For self-hosted instances, it's another API on the local instance. -#[post("/ciphers//attachment/v2", data = "")] +#[post("/ciphers//attachment/v2", data = "")] async fn post_attachment_v2( - cipher_id: CipherId, + uuid: &str, data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, ) -> JsonResult { - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") + let cipher = match Cipher::find_by_uuid(uuid, &mut conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, &mut conn).await { err!("Cipher is not write accessible") } @@ -1152,9 +1082,9 @@ async fn post_attachment_v2( 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"); + attachment.save(&mut conn).await.expect("Error saving attachment"); - let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid); + let url = format!("/ciphers/{}/attachment/{}", cipher.uuid, attachment_id); let response_key = match data.admin_request { Some(b) if b => "cipherMiniResponse", _ => "cipherResponse", @@ -1165,7 +1095,7 @@ async fn post_attachment_v2( "attachmentId": attachment_id, "url": url, "fileUploadType": FileUploadType::Direct as i32, - response_key: cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?, + response_key: cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await, }))) } @@ -1185,13 +1115,13 @@ struct UploadData<'f> { /// database record, which is passed in as `attachment`. async fn save_attachment( mut attachment: Option, - cipher_id: CipherId, + cipher_uuid: &str, data: Form>, headers: &Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> Result<(Cipher, DbConn), crate::error::Error> { - let data = data.into_inner(); + let mut data = data.into_inner(); let Some(size) = data.data.len().to_i64() else { err!("Attachment data size overflow"); @@ -1200,11 +1130,12 @@ async fn save_attachment( err!("Attachment size can't be negative") } - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") + let cipher = match Cipher::find_by_uuid(cipher_uuid, &mut conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, &mut conn).await { err!("Cipher is not write accessible") } @@ -1215,11 +1146,11 @@ async fn save_attachment( Some(a) => a.file_size, // v2 API }; - let size_limit = if let Some(ref user_id) = cipher.user_uuid { + let size_limit = if let Some(ref user_uuid) = 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 already_used = Attachment::size_by_user(user_uuid, &mut conn).await; let left = limit_kb .checked_mul(1024) .and_then(|l| l.checked_sub(already_used)) @@ -1237,11 +1168,11 @@ async fn save_attachment( } None => None, } - } else if let Some(ref org_id) = cipher.organization_uuid { + } else if let Some(ref org_uuid) = 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 already_used = Attachment::size_by_org(org_uuid, &mut conn).await; let left = limit_kb .checked_mul(1024) .and_then(|l| l.checked_sub(already_used)) @@ -1292,10 +1223,10 @@ async fn save_attachment( if size != attachment.file_size { // Update the attachment with the actual file size. attachment.file_size = size; - attachment.save(&conn).await.expect("Error updating attachment"); + attachment.save(&mut conn).await.expect("Error updating attachment"); } } else { - attachment.delete(&conn).await.ok(); + attachment.delete(&mut conn).await.ok(); err!(format!("Attachment size mismatch (expected within [{min_size}, {max_size}], got {size})")); } @@ -1314,31 +1245,37 @@ async fn save_attachment( err!("No attachment key provided") } let attachment = - Attachment::new(file_id.clone(), cipher_id.clone(), encrypted_filename.unwrap(), size, data.key); - attachment.save(&conn).await.expect("Error saving attachment"); + Attachment::new(file_id.clone(), String::from(cipher_uuid), encrypted_filename.unwrap(), size, data.key); + attachment.save(&mut conn).await.expect("Error saving attachment"); } - save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?; + let folder_path = tokio::fs::canonicalize(&CONFIG.attachments_folder()).await?.join(cipher_uuid); + let file_path = folder_path.join(&file_id); + tokio::fs::create_dir_all(&folder_path).await?; + + if let Err(_err) = data.data.persist_to(&file_path).await { + data.data.move_copy_to(file_path).await? + } nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, - &cipher.update_users_revision(&conn).await, - &headers.device, + &cipher.update_users_revision(&mut conn).await, + &headers.device.uuid, None, - &conn, + &mut conn, ) .await; - if let Some(org_id) = &cipher.organization_uuid { + if let Some(org_uuid) = &cipher.organization_uuid { log_event( EventType::CipherAttachmentCreated as i32, &cipher.uuid, - org_id, + org_uuid, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await; } @@ -1348,32 +1285,32 @@ async fn save_attachment( /// v2 API for uploading the actual data content of an attachment. /// This route needs a rank specified so that Rocket prioritizes the -/// /ciphers//attachment/v2 route, which would otherwise conflict +/// /ciphers//attachment/v2 route, which would otherwise conflict /// with this one. -#[post("/ciphers//attachment/", format = "multipart/form-data", data = "", rank = 1)] +#[post("/ciphers//attachment/", format = "multipart/form-data", data = "", rank = 1)] async fn post_attachment_v2_data( - cipher_id: CipherId, - attachment_id: AttachmentId, + uuid: &str, + attachment_id: &str, data: Form>, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - let attachment = match Attachment::find_by_id(&attachment_id, &conn).await { - Some(attachment) if cipher_id == attachment.cipher_uuid => Some(attachment), + let attachment = match Attachment::find_by_id(attachment_id, &mut conn).await { + Some(attachment) if uuid == attachment.cipher_uuid => Some(attachment), Some(_) => err!("Attachment doesn't belong to cipher"), None => err!("Attachment doesn't exist"), }; - save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; + save_attachment(attachment, uuid, data, &headers, conn, nt).await?; Ok(()) } /// Legacy API for creating an attachment associated with a cipher. -#[post("/ciphers//attachment", format = "multipart/form-data", data = "")] +#[post("/ciphers//attachment", format = "multipart/form-data", data = "")] async fn post_attachment( - cipher_id: CipherId, + uuid: &str, data: Form>, headers: Headers, conn: DbConn, @@ -1383,112 +1320,111 @@ async fn post_attachment( // the attachment database record as well as saving the data to disk. let attachment = None; - let (cipher, conn) = save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; + let (cipher, mut conn) = save_attachment(attachment, uuid, data, &headers, conn, nt).await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) } -#[post("/ciphers//attachment-admin", format = "multipart/form-data", data = "")] +#[post("/ciphers//attachment-admin", format = "multipart/form-data", data = "")] async fn post_attachment_admin( - cipher_id: CipherId, + uuid: &str, data: Form>, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - post_attachment(cipher_id, data, headers, conn, nt).await + post_attachment(uuid, data, headers, conn, nt).await } -#[post("/ciphers//attachment//share", format = "multipart/form-data", data = "")] +#[post("/ciphers//attachment//share", format = "multipart/form-data", data = "")] async fn post_attachment_share( - cipher_id: CipherId, - attachment_id: AttachmentId, + uuid: &str, + attachment_id: &str, data: Form>, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - _delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await?; - post_attachment(cipher_id, data, headers, conn, nt).await + _delete_cipher_attachment_by_id(uuid, attachment_id, &headers, &mut conn, &nt).await?; + post_attachment(uuid, data, headers, conn, nt).await } -#[post("/ciphers//attachment//delete-admin")] +#[post("/ciphers//attachment//delete-admin")] async fn delete_attachment_post_admin( - cipher_id: CipherId, - attachment_id: AttachmentId, + uuid: &str, + attachment_id: &str, headers: Headers, conn: DbConn, nt: Notify<'_>, -) -> JsonResult { - delete_attachment(cipher_id, attachment_id, headers, conn, nt).await +) -> EmptyResult { + delete_attachment(uuid, attachment_id, headers, conn, nt).await } -#[post("/ciphers//attachment//delete")] +#[post("/ciphers//attachment//delete")] async fn delete_attachment_post( - cipher_id: CipherId, - attachment_id: AttachmentId, + uuid: &str, + attachment_id: &str, headers: Headers, conn: DbConn, nt: Notify<'_>, -) -> JsonResult { - delete_attachment(cipher_id, attachment_id, headers, conn, nt).await +) -> EmptyResult { + delete_attachment(uuid, attachment_id, headers, conn, nt).await } -#[delete("/ciphers//attachment/")] +#[delete("/ciphers//attachment/")] async fn delete_attachment( - cipher_id: CipherId, - attachment_id: AttachmentId, + uuid: &str, + attachment_id: &str, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, -) -> JsonResult { - _delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await +) -> EmptyResult { + _delete_cipher_attachment_by_id(uuid, attachment_id, &headers, &mut conn, &nt).await } -#[delete("/ciphers//attachment//admin")] +#[delete("/ciphers//attachment//admin")] async fn delete_attachment_admin( - cipher_id: CipherId, - attachment_id: AttachmentId, + uuid: &str, + attachment_id: &str, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, -) -> JsonResult { - _delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await +) -> EmptyResult { + _delete_cipher_attachment_by_id(uuid, attachment_id, &headers, &mut conn, &nt).await } -#[post("/ciphers//delete")] -async fn delete_cipher_post(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await +#[post("/ciphers//delete")] +async fn delete_cipher_post(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { + _delete_cipher_by_uuid(uuid, &headers, &mut conn, false, &nt).await // permanent delete } -#[post("/ciphers//delete-admin")] -async fn delete_cipher_post_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await +#[post("/ciphers//delete-admin")] +async fn delete_cipher_post_admin(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { + _delete_cipher_by_uuid(uuid, &headers, &mut conn, false, &nt).await // permanent delete } -#[put("/ciphers//delete")] -async fn delete_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await +#[put("/ciphers//delete")] +async fn delete_cipher_put(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { + _delete_cipher_by_uuid(uuid, &headers, &mut conn, true, &nt).await // soft delete } -#[put("/ciphers//delete-admin")] -async fn delete_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await - // soft delete +#[put("/ciphers//delete-admin")] +async fn delete_cipher_put_admin(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { + _delete_cipher_by_uuid(uuid, &headers, &mut conn, true, &nt).await } -#[delete("/ciphers/")] -async fn delete_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await +#[delete("/ciphers/")] +async fn delete_cipher(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { + _delete_cipher_by_uuid(uuid, &headers, &mut conn, false, &nt).await // permanent delete } -#[delete("/ciphers//admin")] -async fn delete_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await +#[delete("/ciphers//admin")] +async fn delete_cipher_admin(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { + _delete_cipher_by_uuid(uuid, &headers, &mut conn, false, &nt).await // permanent delete } @@ -1499,8 +1435,7 @@ async fn delete_cipher_selected( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await - // permanent delete + _delete_multiple_ciphers(data, headers, conn, false, nt).await // permanent delete } #[post("/ciphers/delete", data = "")] @@ -1510,8 +1445,7 @@ async fn delete_cipher_selected_post( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await - // permanent delete + _delete_multiple_ciphers(data, headers, conn, false, nt).await // permanent delete } #[put("/ciphers/delete", data = "")] @@ -1521,8 +1455,7 @@ async fn delete_cipher_selected_put( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await - // soft delete + _delete_multiple_ciphers(data, headers, conn, true, nt).await // soft delete } #[delete("/ciphers/admin", data = "")] @@ -1532,8 +1465,7 @@ async fn delete_cipher_selected_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await - // permanent delete + _delete_multiple_ciphers(data, headers, conn, false, nt).await // permanent delete } #[post("/ciphers/delete-admin", data = "")] @@ -1543,8 +1475,7 @@ async fn delete_cipher_selected_post_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await - // permanent delete + _delete_multiple_ciphers(data, headers, conn, false, nt).await // permanent delete } #[put("/ciphers/delete-admin", data = "")] @@ -1554,97 +1485,79 @@ async fn delete_cipher_selected_put_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await - // soft delete + _delete_multiple_ciphers(data, headers, conn, true, nt).await // soft delete } -#[put("/ciphers//restore")] -async fn restore_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - _restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await +#[put("/ciphers//restore")] +async fn restore_cipher_put(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult { + _restore_cipher_by_uuid(uuid, &headers, &mut conn, &nt).await } -#[put("/ciphers//restore-admin")] -async fn restore_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - _restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await -} - -#[put("/ciphers/restore-admin", data = "")] -async fn restore_cipher_selected_admin( - data: Json, - headers: Headers, - conn: DbConn, - nt: Notify<'_>, -) -> JsonResult { - _restore_multiple_ciphers(data, &headers, &conn, &nt).await +#[put("/ciphers//restore-admin")] +async fn restore_cipher_put_admin(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult { + _restore_cipher_by_uuid(uuid, &headers, &mut conn, &nt).await } #[put("/ciphers/restore", data = "")] async fn restore_cipher_selected( data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - _restore_multiple_ciphers(data, &headers, &conn, &nt).await + _restore_multiple_ciphers(data, &headers, &mut conn, &nt).await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MoveCipherData { - #[serde(default, deserialize_with = "deser_opt_nonempty_str")] - folder_id: Option, - ids: Vec, + folder_id: Option, + ids: Vec, } #[post("/ciphers/move", data = "")] async fn move_cipher_selected( data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let data = data.into_inner(); - let user_id = &headers.user.uuid; + let user_uuid = headers.user.uuid; if let Some(ref folder_id) = data.folder_id { - if Folder::find_by_uuid_and_user(folder_id, user_id, &conn).await.is_none() { - err!("Invalid folder", "Folder does not exist or belongs to another user"); + match Folder::find_by_uuid(folder_id, &mut conn).await { + Some(folder) => { + if folder.user_uuid != user_uuid { + err!("Folder is not owned by user") + } + } + None => err!("Folder doesn't exist"), } } - let cipher_count = data.ids.len(); - let mut single_cipher: Option = None; + for uuid in data.ids { + let cipher = match Cipher::find_by_uuid(&uuid, &mut conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), + }; - // TODO: Convert this to use a single query (or at least less) to update all items - // 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(); - for cipher in accessible_ciphers { - cipher.move_to_folder(data.folder_id.clone(), user_id, &conn).await?; - if cipher_count == 1 { - single_cipher = Some(cipher); + if !cipher.is_accessible_to_user(&user_uuid, &mut conn).await { + err!("Cipher is not accessible by user") } - } - if let Some(cipher) = single_cipher { + // Move cipher + cipher.move_to_folder(data.folder_id.clone(), &user_uuid, &mut conn).await?; + nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, - std::slice::from_ref(user_id), - &headers.device, + &[user_uuid.clone()], + &headers.device.uuid, None, - &conn, + &mut conn, ) .await; - } else { - // Multi move 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; - } - - if cipher_count != accessible_ciphers_count { - err!(format!( - "Not all ciphers are moved! {accessible_ciphers_count} of the selected {cipher_count} were moved." - )) } Ok(()) @@ -1661,172 +1574,120 @@ async fn move_cipher_selected_put( } #[derive(FromForm)] -struct OrganizationIdData { +struct OrganizationId { #[field(name = "organizationId")] - org_id: OrganizationId, + org_id: String, } -// Use the OrgIdGuard here, to ensure there an organization id present. -// If there is no organization id present, it should be forwarded to purge_personal_vault. -// This guard needs to be the first argument, else OwnerHeaders will be triggered which will logout the user. #[post("/ciphers/purge?", data = "")] -async fn purge_org_vault( - _org_id_guard: OrgIdGuard, - organization: OrganizationIdData, - data: Json, - headers: OwnerHeaders, - conn: DbConn, - nt: Notify<'_>, -) -> EmptyResult { - if organization.org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - - let data: PasswordOrOtpData = data.into_inner(); - let user = headers.user; - - data.validate(&user, true, &conn).await?; - - match Membership::find_confirmed_by_user_and_org(&user.uuid, &organization.org_id, &conn).await { - Some(member) if member.atype == MembershipType::Owner => { - Cipher::delete_all_by_organization(&organization.org_id, &conn).await?; - nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await; - - log_event( - EventType::OrganizationPurgedVault as i32, - &organization.org_id, - &organization.org_id, - &user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; - - Ok(()) - } - _ => err!("You don't have permission to purge the organization vault"), - } -} - -#[post("/ciphers/purge", data = "")] -async fn purge_personal_vault( +async fn delete_all( + organization: Option, data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let data: PasswordOrOtpData = data.into_inner(); let mut user = headers.user; - data.validate(&user, true, &conn).await?; + data.validate(&user, true, &mut conn).await?; - for cipher in Cipher::find_owned_by_user(&user.uuid, &conn).await { - cipher.delete(&conn).await?; + match organization { + Some(org_data) => { + // Organization ID in query params, purging organization vault + match UserOrganization::find_by_user_and_org(&user.uuid, &org_data.org_id, &mut conn).await { + None => err!("You don't have permission to purge the organization vault"), + Some(user_org) => { + if user_org.atype == UserOrgType::Owner { + Cipher::delete_all_by_organization(&org_data.org_id, &mut conn).await?; + nt.send_user_update(UpdateType::SyncVault, &user).await; + + log_event( + EventType::OrganizationPurgedVault as i32, + &org_data.org_id, + &org_data.org_id, + &user.uuid, + headers.device.atype, + &headers.ip.ip, + &mut conn, + ) + .await; + + Ok(()) + } else { + err!("You don't have permission to purge the organization vault"); + } + } + } + } + None => { + // No organization ID in query params, purging user vault + // Delete ciphers and their attachments + for cipher in Cipher::find_owned_by_user(&user.uuid, &mut conn).await { + cipher.delete(&mut conn).await?; + } + + // Delete folders + for f in Folder::find_by_user(&user.uuid, &mut conn).await { + f.delete(&mut conn).await?; + } + + user.update_revision(&mut conn).await?; + nt.send_user_update(UpdateType::SyncVault, &user).await; + + Ok(()) + } } - - for f in Folder::find_by_user(&user.uuid, &conn).await { - f.delete(&conn).await?; - } - - user.update_revision(&conn).await?; - nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await; - - Ok(()) -} - -#[put("/ciphers//archive")] -async fn archive_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - archive_cipher(&cipher_id, &headers, false, &conn, &nt).await -} - -#[put("/ciphers/archive", data = "")] -async fn archive_cipher_selected( - data: Json, - headers: Headers, - conn: DbConn, - nt: Notify<'_>, -) -> JsonResult { - archive_multiple_ciphers(data, &headers, &conn, &nt).await -} - -#[put("/ciphers//unarchive")] -async fn unarchive_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - unarchive_cipher(&cipher_id, &headers, false, &conn, &nt).await -} - -#[put("/ciphers/unarchive", data = "")] -async fn unarchive_cipher_selected( - data: Json, - headers: Headers, - conn: DbConn, - nt: Notify<'_>, -) -> JsonResult { - unarchive_multiple_ciphers(data, &headers, &conn, &nt).await -} - -#[derive(PartialEq)] -pub enum CipherDeleteOptions { - SoftSingle, - SoftMulti, - HardSingle, - HardMulti, } async fn _delete_cipher_by_uuid( - cipher_id: &CipherId, + uuid: &str, headers: &Headers, - conn: &DbConn, - delete_options: &CipherDeleteOptions, + conn: &mut DbConn, + soft_delete: bool, nt: &Notify<'_>, ) -> EmptyResult { - let Some(mut cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { - err!("Cipher doesn't exist") + let mut cipher = match Cipher::find_by_uuid(uuid, conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), }; if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { err!("Cipher can't be deleted by user") } - if *delete_options == CipherDeleteOptions::SoftSingle || *delete_options == CipherDeleteOptions::SoftMulti { + if soft_delete { cipher.deleted_at = Some(Utc::now().naive_utc()); cipher.save(conn).await?; - if *delete_options == CipherDeleteOptions::SoftSingle { - nt.send_cipher_update( - UpdateType::SyncCipherUpdate, - &cipher, - &cipher.update_users_revision(conn).await, - &headers.device, - None, - conn, - ) - .await; - } + nt.send_cipher_update( + UpdateType::SyncCipherUpdate, + &cipher, + &cipher.update_users_revision(conn).await, + &headers.device.uuid, + None, + conn, + ) + .await; } else { cipher.delete(conn).await?; - if *delete_options == CipherDeleteOptions::HardSingle { - nt.send_cipher_update( - UpdateType::SyncLoginDelete, - &cipher, - &cipher.update_users_revision(conn).await, - &headers.device, - None, - conn, - ) - .await; - } + nt.send_cipher_update( + UpdateType::SyncCipherDelete, + &cipher, + &cipher.update_users_revision(conn).await, + &headers.device.uuid, + None, + conn, + ) + .await; } - if let Some(org_id) = cipher.organization_uuid { - let event_type = if *delete_options == CipherDeleteOptions::SoftSingle - || *delete_options == CipherDeleteOptions::SoftMulti - { - EventType::CipherSoftDeleted as i32 - } else { - EventType::CipherDeleted as i32 + if let Some(org_uuid) = cipher.organization_uuid { + let event_type = match soft_delete { + true => EventType::CipherSoftDeleted as i32, + false => EventType::CipherDeleted as i32, }; - log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn) + log_event(event_type, &cipher.uuid, &org_uuid, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn) .await; } @@ -1836,39 +1697,31 @@ async fn _delete_cipher_by_uuid( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CipherIdsData { - ids: Vec, + ids: Vec, } async fn _delete_multiple_ciphers( data: Json, headers: Headers, - conn: DbConn, - delete_options: CipherDeleteOptions, + mut conn: DbConn, + soft_delete: bool, nt: Notify<'_>, ) -> EmptyResult { let data = data.into_inner(); - for cipher_id in data.ids { - if let error @ Err(_) = _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &delete_options, &nt).await { + for uuid in data.ids { + if let error @ Err(_) = _delete_cipher_by_uuid(&uuid, &headers, &mut conn, soft_delete, &nt).await { return error; }; } - // Multi delete 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(()) } -async fn _restore_cipher_by_uuid( - cipher_id: &CipherId, - headers: &Headers, - multi_restore: bool, - conn: &DbConn, - nt: &Notify<'_>, -) -> JsonResult { - let Some(mut cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { - err!("Cipher doesn't exist") +async fn _restore_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &mut DbConn, nt: &Notify<'_>) -> JsonResult { + let mut cipher = match Cipher::find_by_uuid(uuid, conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), }; if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { @@ -1878,23 +1731,21 @@ async fn _restore_cipher_by_uuid( cipher.deleted_at = None; cipher.save(conn).await?; - if !multi_restore { - nt.send_cipher_update( - UpdateType::SyncCipherUpdate, - &cipher, - &cipher.update_users_revision(conn).await, - &headers.device, - None, - conn, - ) - .await; - } + nt.send_cipher_update( + UpdateType::SyncCipherUpdate, + &cipher, + &cipher.update_users_revision(conn).await, + &headers.device.uuid, + None, + conn, + ) + .await; - if let Some(org_id) = &cipher.organization_uuid { + if let Some(org_uuid) = &cipher.organization_uuid { log_event( EventType::CipherRestored as i32, &cipher.uuid.clone(), - org_id, + org_uuid, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -1903,28 +1754,25 @@ async fn _restore_cipher_by_uuid( .await; } - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await)) } async fn _restore_multiple_ciphers( data: Json, headers: &Headers, - conn: &DbConn, + conn: &mut DbConn, nt: &Notify<'_>, ) -> JsonResult { let data = data.into_inner(); let mut ciphers: Vec = Vec::new(); - for cipher_id in data.ids { - match _restore_cipher_by_uuid(&cipher_id, headers, true, conn, nt).await { + for uuid in data.ids { + match _restore_cipher_by_uuid(&uuid, headers, conn, nt).await { Ok(json) => ciphers.push(json.into_inner()), err => return err, } } - // Multi move 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": ciphers, "object": "list", @@ -1933,22 +1781,24 @@ async fn _restore_multiple_ciphers( } async fn _delete_cipher_attachment_by_id( - cipher_id: &CipherId, - attachment_id: &AttachmentId, + uuid: &str, + attachment_id: &str, headers: &Headers, - conn: &DbConn, + conn: &mut DbConn, nt: &Notify<'_>, -) -> JsonResult { - let Some(attachment) = Attachment::find_by_id(attachment_id, conn).await else { - err!("Attachment doesn't exist") +) -> EmptyResult { + let attachment = match Attachment::find_by_id(attachment_id, conn).await { + Some(attachment) => attachment, + None => err!("Attachment doesn't exist"), }; - if &attachment.cipher_uuid != cipher_id { + if attachment.cipher_uuid != uuid { err!("Attachment from other cipher") } - let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { - err!("Cipher doesn't exist") + let cipher = match Cipher::find_by_uuid(uuid, conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), }; if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { @@ -1961,17 +1811,17 @@ async fn _delete_cipher_attachment_by_id( UpdateType::SyncCipherUpdate, &cipher, &cipher.update_users_revision(conn).await, - &headers.device, + &headers.device.uuid, None, conn, ) .await; - if let Some(ref org_id) = cipher.organization_uuid { + if let Some(org_uuid) = cipher.organization_uuid { log_event( EventType::CipherAttachmentDeleted as i32, &cipher.uuid, - org_id, + &org_uuid, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -1979,124 +1829,7 @@ async fn _delete_cipher_attachment_by_id( ) .await; } - let cipher_json = cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?; - Ok(Json(json!({"cipher":cipher_json}))) -} - -async fn archive_cipher( - cipher_id: &CipherId, - headers: &Headers, - multi_archive: bool, - conn: &DbConn, - nt: &Notify<'_>, -) -> JsonResult { - let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { - err!("Cipher doesn't exist") - }; - - if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await { - err!("Cipher is not accessible for the current user") - } - - cipher.set_archived_at(Utc::now().naive_utc(), &headers.user.uuid, conn).await?; - - if !multi_archive { - nt.send_cipher_update( - UpdateType::SyncCipherUpdate, - &cipher, - &cipher.update_users_revision(conn).await, - &headers.device, - None, - conn, - ) - .await; - } - - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) -} - -async fn unarchive_cipher( - cipher_id: &CipherId, - headers: &Headers, - multi_unarchive: bool, - conn: &DbConn, - nt: &Notify<'_>, -) -> JsonResult { - let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { - err!("Cipher doesn't exist") - }; - - if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await { - err!("Cipher is not accessible for the current user") - } - - cipher.unarchive(&headers.user.uuid, conn).await?; - - if !multi_unarchive { - nt.send_cipher_update( - UpdateType::SyncCipherUpdate, - &cipher, - &cipher.update_users_revision(conn).await, - &headers.device, - None, - conn, - ) - .await; - } - - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) -} - -async fn archive_multiple_ciphers( - data: Json, - headers: &Headers, - conn: &DbConn, - nt: &Notify<'_>, -) -> JsonResult { - let data = data.into_inner(); - - let mut ciphers: Vec = Vec::new(); - for cipher_id in data.ids { - match archive_cipher(&cipher_id, headers, true, conn, nt).await { - Ok(json) => ciphers.push(json.into_inner()), - err => return err, - } - } - - // Multi archive does 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": ciphers, - "object": "list", - "continuationToken": null - }))) -} - -async fn unarchive_multiple_ciphers( - data: Json, - headers: &Headers, - conn: &DbConn, - nt: &Notify<'_>, -) -> JsonResult { - let data = data.into_inner(); - - let mut ciphers: Vec = Vec::new(); - for cipher_id in data.ids { - match unarchive_cipher(&cipher_id, headers, true, conn, nt).await { - Ok(json) => ciphers.push(json.into_inner()), - err => return err, - } - } - - // Multi unarchive does 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": ciphers, - "object": "list", - "continuationToken": null - }))) + Ok(()) } /// This will hold all the necessary data to improve a full sync of all the ciphers @@ -2104,15 +1837,14 @@ async fn unarchive_multiple_ciphers( /// It will prevent the so called N+1 SQL issue by running just a few queries which will hold all the data needed. /// This will not improve the speed of a single cipher.to_json() call that much, so better not to use it for those calls. pub struct CipherSyncData { - pub cipher_attachments: HashMap>, - pub cipher_folders: HashMap, - pub cipher_favorites: HashSet, - pub cipher_collections: HashMap>, - pub cipher_archives: HashMap, - pub members: HashMap, - pub user_collections: HashMap, - pub user_collections_groups: HashMap, - pub user_group_full_access_for_organizations: HashSet, + pub cipher_attachments: HashMap>, + pub cipher_folders: HashMap, + pub cipher_favorites: HashSet, + pub cipher_collections: HashMap>, + pub user_organizations: HashMap, + pub user_collections: HashMap, + pub user_collections_groups: HashMap, + pub user_group_full_access_for_organizations: HashSet, } #[derive(Eq, PartialEq)] @@ -2122,96 +1854,80 @@ pub enum CipherSyncType { } impl CipherSyncData { - pub async fn new(user_id: &UserId, sync_type: CipherSyncType, conn: &DbConn) -> Self { - let cipher_folders: HashMap; - let cipher_favorites: HashSet; - let cipher_archives: HashMap; + pub async fn new(user_uuid: &str, sync_type: CipherSyncType, conn: &mut DbConn) -> Self { + let cipher_folders: HashMap; + let cipher_favorites: HashSet; match sync_type { - // User Sync supports Folders, Favorites, and Archives + // User Sync supports Folders and Favorites CipherSyncType::User => { // Generate a HashMap with the Cipher UUID as key and the Folder UUID as value - cipher_folders = FolderCipher::find_by_user(user_id, conn).await.into_iter().collect(); + cipher_folders = FolderCipher::find_by_user(user_uuid, conn).await.into_iter().collect(); // Generate a HashSet of all the Cipher UUID's which are marked as favorite - cipher_favorites = Favorite::get_all_cipher_uuid_by_user(user_id, conn).await.into_iter().collect(); - - // Generate a HashMap with the Cipher UUID as key and the archived date time as value - cipher_archives = Archive::find_by_user(user_id, conn).await.into_iter().collect(); + cipher_favorites = Favorite::get_all_cipher_uuid_by_user(user_uuid, conn).await.into_iter().collect(); } - // Organization Sync does not support Folders, Favorites, or Archives. + // Organization Sync does not support Folders and Favorites. // If these are set, it will cause issues in the web-vault. CipherSyncType::Organization => { cipher_folders = HashMap::with_capacity(0); cipher_favorites = HashSet::with_capacity(0); - cipher_archives = HashMap::with_capacity(0); } } // Generate a list of Cipher UUID's containing a Vec with one or more Attachment records - let orgs = Membership::get_orgs_by_user(user_id, conn).await; - let attachments = Attachment::find_all_by_user_and_orgs(user_id, &orgs, conn).await; - let mut cipher_attachments: HashMap> = HashMap::with_capacity(attachments.len()); + let user_org_uuids = UserOrganization::get_org_uuid_by_user(user_uuid, conn).await; + let attachments = Attachment::find_all_by_user_and_orgs(user_uuid, &user_org_uuids, conn).await; + let mut cipher_attachments: HashMap> = HashMap::with_capacity(attachments.len()); for attachment in attachments { cipher_attachments.entry(attachment.cipher_uuid.clone()).or_default().push(attachment); } // Generate a HashMap with the Cipher UUID as key and one or more Collection UUID's - let user_cipher_collections = Cipher::get_collections_with_cipher_by_user(user_id.clone(), conn).await; - let mut cipher_collections: HashMap> = + let user_cipher_collections = Cipher::get_collections_with_cipher_by_user(user_uuid.to_string(), conn).await; + let mut cipher_collections: HashMap> = HashMap::with_capacity(user_cipher_collections.len()); for (cipher, collection) in user_cipher_collections { 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) + // Generate a HashMap with the Organization UUID as key and the UserOrganization record + let user_organizations: HashMap = UserOrganization::find_by_user(user_uuid, conn) .await .into_iter() - .map(|m| (m.org_uuid.clone(), m)) + .map(|uo| (uo.org_uuid.clone(), uo)) .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) + let user_collections: HashMap = CollectionUser::find_by_user(user_uuid, conn) .await .into_iter() .map(|uc| (uc.collection_uuid.clone(), uc)) .collect(); // Generate a HashMap with the collections_uuid as key and the CollectionGroup record - let user_collections_groups: HashMap = if CONFIG.org_groups_enabled() { - CollectionGroup::find_by_user(user_id, conn).await.into_iter().fold( - HashMap::new(), - |mut combined_permissions, cg| { - combined_permissions - .entry(cg.collections_uuid.clone()) - .and_modify(|existing| { - // Combine permissions: take the most permissive settings. - existing.read_only &= cg.read_only; // false if ANY group allows write - existing.hide_passwords &= cg.hide_passwords; // false if ANY group allows password view - existing.manage |= cg.manage; // true if ANY group allows manage - }) - .or_insert(cg); - combined_permissions - }, - ) + let user_collections_groups: HashMap = if CONFIG.org_groups_enabled() { + CollectionGroup::find_by_user(user_uuid, conn) + .await + .into_iter() + .map(|collection_group| (collection_group.collections_uuid.clone(), collection_group)) + .collect() } else { HashMap::new() }; - // Get all organizations that the given user has full access to via group assignment - let user_group_full_access_for_organizations: HashSet = if CONFIG.org_groups_enabled() { - Group::get_orgs_by_user_with_full_access(user_id, conn).await.into_iter().collect() + // Get all organizations that the user has full access to via group assignment + let user_group_full_access_for_organizations: HashSet = if CONFIG.org_groups_enabled() { + Group::gather_user_organizations_full_access(user_uuid, conn).await.into_iter().collect() } else { HashSet::new() }; Self { - cipher_archives, cipher_attachments, cipher_folders, cipher_favorites, cipher_collections, - members, + user_organizations, user_collections, user_collections_groups, user_group_full_access_for_organizations, diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 29a15c8d..1c29b774 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -8,13 +8,7 @@ use crate::{ EmptyResult, JsonResult, }, auth::{decode_emergency_access_invite, Headers}, - db::{ - models::{ - Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, - Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, - }, - DbConn, DbPool, - }, + db::{models::*, DbConn, DbPool}, mail, util::NumberOrString, CONFIG, @@ -46,15 +40,28 @@ pub fn routes() -> Vec { // region get #[get("/emergency-access/trusted")] -async fn get_contacts(headers: Headers, conn: DbConn) -> Json { - let emergency_access_list = if CONFIG.emergency_access_allowed() { - EmergencyAccess::find_all_by_grantor_uuid(&headers.user.uuid, &conn).await - } else { - Vec::new() - }; +async fn get_contacts(headers: Headers, mut conn: DbConn) -> Json { + if !CONFIG.emergency_access_allowed() { + return Json(json!({ + "data": [{ + "id": "", + "status": 2, + "type": 0, + "waitTimeDays": 0, + "granteeId": "", + "email": "", + "name": "NOTE: Emergency Access is disabled!", + "object": "emergencyAccessGranteeDetails", + + }], + "object": "list", + "continuationToken": null + })); + } + let emergency_access_list = EmergencyAccess::find_all_by_grantor_uuid(&headers.user.uuid, &mut conn).await; let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len()); for ea in emergency_access_list { - if let Some(grantee) = ea.to_json_grantee_details(&conn).await { + if let Some(grantee) = ea.to_json_grantee_details(&mut conn).await { emergency_access_list_json.push(grantee) } } @@ -67,15 +74,15 @@ async fn get_contacts(headers: Headers, conn: DbConn) -> Json { } #[get("/emergency-access/granted")] -async fn get_grantees(headers: Headers, conn: DbConn) -> Json { +async fn get_grantees(headers: Headers, mut conn: DbConn) -> Json { let emergency_access_list = if CONFIG.emergency_access_allowed() { - EmergencyAccess::find_all_by_grantee_uuid(&headers.user.uuid, &conn).await + EmergencyAccess::find_all_by_grantee_uuid(&headers.user.uuid, &mut conn).await } else { Vec::new() }; let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len()); for ea in emergency_access_list { - emergency_access_list_json.push(ea.to_json_grantor_details(&conn).await); + emergency_access_list_json.push(ea.to_json_grantor_details(&mut conn).await); } Json(json!({ @@ -86,12 +93,12 @@ async fn get_grantees(headers: Headers, conn: DbConn) -> Json { } #[get("/emergency-access/")] -async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { +async fn get_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; - match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await { + match EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await { Some(emergency_access) => Ok(Json( - emergency_access.to_json_grantee_details(&conn).await.expect("Grantee user should exist but does not!"), + emergency_access.to_json_grantee_details(&mut conn).await.expect("Grantee user should exist but does not!"), )), None => err!("Emergency access not valid."), } @@ -111,7 +118,7 @@ struct EmergencyAccessUpdateData { #[put("/emergency-access/", data = "")] async fn put_emergency_access( - emer_id: EmergencyAccessId, + emer_id: &str, data: Json, headers: Headers, conn: DbConn, @@ -121,20 +128,20 @@ async fn put_emergency_access( #[post("/emergency-access/", data = "")] async fn post_emergency_access( - emer_id: EmergencyAccessId, + emer_id: &str, data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, ) -> JsonResult { check_emergency_access_enabled()?; let data: EmergencyAccessUpdateData = data.into_inner(); - 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.") - }; + let mut emergency_access = + match EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await { + Some(emergency_access) => emergency_access, + None => err!("Emergency access not valid."), + }; let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) { Some(new_type) => new_type as i32, @@ -147,7 +154,7 @@ async fn post_emergency_access( emergency_access.key_encrypted = data.key_encrypted; } - emergency_access.save(&conn).await?; + emergency_access.save(&mut conn).await?; Ok(Json(emergency_access.to_json())) } @@ -156,12 +163,12 @@ async fn post_emergency_access( // region delete #[delete("/emergency-access/")] -async fn delete_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> EmptyResult { +async fn delete_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> EmptyResult { check_emergency_access_enabled()?; let emergency_access = match ( - EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await, - EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &conn).await, + EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await, + EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &headers.user.uuid, &mut conn).await, ) { (Some(grantor_emer), None) => { info!("Grantor deleted emergency access {emer_id}"); @@ -174,12 +181,12 @@ async fn delete_emergency_access(emer_id: EmergencyAccessId, headers: Headers, c _ => err!("Emergency access not valid."), }; - emergency_access.delete(&conn).await?; + emergency_access.delete(&mut conn).await?; Ok(()) } #[post("/emergency-access//delete")] -async fn post_delete_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> EmptyResult { +async fn post_delete_emergency_access(emer_id: &str, headers: Headers, conn: DbConn) -> EmptyResult { delete_emergency_access(emer_id, headers, conn).await } @@ -196,7 +203,7 @@ struct EmergencyAccessInviteData { } #[post("/emergency-access/invite", data = "")] -async fn send_invite(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +async fn send_invite(data: Json, headers: Headers, mut conn: DbConn) -> EmptyResult { check_emergency_access_enabled()?; let data: EmergencyAccessInviteData = data.into_inner(); @@ -217,10 +224,10 @@ async fn send_invite(data: Json, headers: Headers, co err!("You can not set yourself as an emergency contact.") } - let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await { + let (grantee_user, new_user) = match User::find_by_mail(&email, &mut conn).await { None => { if !CONFIG.invitations_allowed() { - err!(format!("Grantee user does not exist: {email}")) + err!(format!("Grantee user does not exist: {}", &email)) } if !CONFIG.is_email_domain_allowed(&email) { @@ -229,11 +236,11 @@ async fn send_invite(data: Json, headers: Headers, co if !CONFIG.mail_enabled() { let invitation = Invitation::new(&email); - invitation.save(&conn).await?; + invitation.save(&mut conn).await?; } - let mut user = User::new(&email, None); - user.save(&conn).await?; + let mut user = User::new(email.clone()); + user.save(&mut conn).await?; (user, true) } Some(user) if user.password_hash.is_empty() => (user, true), @@ -244,7 +251,7 @@ async fn send_invite(data: Json, headers: Headers, co &grantor_user.uuid, &grantee_user.uuid, &grantee_user.email, - &conn, + &mut conn, ) .await .is_some() @@ -254,45 +261,47 @@ async fn send_invite(data: Json, headers: Headers, co 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?; + new_emergency_access.save(&mut conn).await?; if CONFIG.mail_enabled() { mail::send_emergency_access_invite( &new_emergency_access.email.expect("Grantee email does not exists"), - grantee_user.uuid, - new_emergency_access.uuid, + &grantee_user.uuid, + &new_emergency_access.uuid, &grantor_user.name, &grantor_user.email, ) .await?; } else if !new_user { // if mail is not enabled immediately accept the invitation for existing users - new_emergency_access.accept_invite(&grantee_user.uuid, &email, &conn).await?; + new_emergency_access.accept_invite(&grantee_user.uuid, &email, &mut conn).await?; } Ok(()) } #[post("/emergency-access//reinvite")] -async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> EmptyResult { +async fn resend_invite(emer_id: &str, headers: Headers, mut conn: DbConn) -> EmptyResult { check_emergency_access_enabled()?; - 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.") - }; + let mut emergency_access = + match EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; if emergency_access.status != EmergencyAccessStatus::Invited as i32 { err!("The grantee user is already accepted or confirmed to the organization"); } - let Some(email) = emergency_access.email.clone() else { - err!("Email not valid.") + let email = match emergency_access.email.clone() { + Some(email) => email, + None => err!("Email not valid."), }; - let Some(grantee_user) = User::find_by_mail(&email, &conn).await else { - err!("Grantee user not found.") + let grantee_user = match User::find_by_mail(&email, &mut conn).await { + Some(user) => user, + None => err!("Grantee user not found."), }; let grantor_user = headers.user; @@ -300,18 +309,18 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbCon if CONFIG.mail_enabled() { mail::send_emergency_access_invite( &email, - grantor_user.uuid, - emergency_access.uuid, + &grantor_user.uuid, + &emergency_access.uuid, &grantor_user.name, &grantor_user.email, ) .await?; } else if !grantee_user.password_hash.is_empty() { // accept the invitation for existing user - emergency_access.accept_invite(&grantee_user.uuid, &email, &conn).await?; - } else if CONFIG.invitations_allowed() && Invitation::find_by_mail(&email, &conn).await.is_none() { + emergency_access.accept_invite(&grantee_user.uuid, &email, &mut conn).await?; + } else if CONFIG.invitations_allowed() && Invitation::find_by_mail(&email, &mut conn).await.is_none() { let invitation = Invitation::new(&email); - invitation.save(&conn).await?; + invitation.save(&mut conn).await?; } Ok(()) @@ -324,12 +333,7 @@ struct AcceptData { } #[post("/emergency-access//accept", data = "")] -async fn accept_invite( - emer_id: EmergencyAccessId, - data: Json, - headers: Headers, - conn: DbConn, -) -> EmptyResult { +async fn accept_invite(emer_id: &str, data: Json, headers: Headers, mut conn: DbConn) -> EmptyResult { check_emergency_access_enabled()?; let data: AcceptData = data.into_inner(); @@ -342,9 +346,9 @@ async fn accept_invite( err!("Claim email does not match current users email") } - let grantee_user = match User::find_by_mail(&claims.email, &conn).await { + let grantee_user = match User::find_by_mail(&claims.email, &mut conn).await { Some(user) => { - Invitation::take(&claims.email, &conn).await; + Invitation::take(&claims.email, &mut conn).await; user } None => err!("Invited user not found"), @@ -352,22 +356,23 @@ async fn accept_invite( // We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database. // The uuid of the grantee gets stored once accepted. - let Some(mut emergency_access) = - EmergencyAccess::find_by_uuid_and_grantee_email(&emer_id, &headers.user.email, &conn).await - else { - err!("Emergency access not valid.") - }; + let mut emergency_access = + match EmergencyAccess::find_by_uuid_and_grantee_email(emer_id, &headers.user.email, &mut conn).await { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; // get grantor user to send Accepted email - let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { - err!("Grantor user not found.") + let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await { + Some(user) => user, + None => err!("Grantor user not found."), }; if emer_id == claims.emer_id && grantor_user.name == claims.grantor_name && grantor_user.email == claims.grantor_email { - emergency_access.accept_invite(&grantee_user.uuid, &grantee_user.email, &conn).await?; + emergency_access.accept_invite(&grantee_user.uuid, &grantee_user.email, &mut conn).await?; if CONFIG.mail_enabled() { mail::send_emergency_access_invite_accepted(&grantor_user.email, &grantee_user.email).await?; @@ -387,10 +392,10 @@ struct ConfirmData { #[post("/emergency-access//confirm", data = "")] async fn confirm_emergency_access( - emer_id: EmergencyAccessId, + emer_id: &str, data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, ) -> JsonResult { check_emergency_access_enabled()?; @@ -398,11 +403,11 @@ async fn confirm_emergency_access( let data: ConfirmData = data.into_inner(); let key = data.key; - let Some(mut emergency_access) = - EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &confirming_user.uuid, &conn).await - else { - err!("Emergency access not valid.") - }; + let mut emergency_access = + match EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &confirming_user.uuid, &mut conn).await { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; if emergency_access.status != EmergencyAccessStatus::Accepted as i32 || emergency_access.grantor_uuid != confirming_user.uuid @@ -410,20 +415,22 @@ async fn confirm_emergency_access( err!("Emergency access not valid.") } - let Some(grantor_user) = User::find_by_uuid(&confirming_user.uuid, &conn).await else { - err!("Grantor user not found.") + let grantor_user = match User::find_by_uuid(&confirming_user.uuid, &mut conn).await { + Some(user) => user, + None => err!("Grantor user not found."), }; if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() { - let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else { - err!("Grantee user not found.") + let grantee_user = match User::find_by_uuid(grantee_uuid, &mut conn).await { + Some(user) => user, + None => err!("Grantee user not found."), }; emergency_access.status = EmergencyAccessStatus::Confirmed as i32; emergency_access.key_encrypted = Some(key); emergency_access.email = None; - emergency_access.save(&conn).await?; + emergency_access.save(&mut conn).await?; if CONFIG.mail_enabled() { mail::send_emergency_access_invite_confirmed(&grantee_user.email, &grantor_user.name).await?; @@ -439,22 +446,23 @@ async fn confirm_emergency_access( // region access emergency access #[post("/emergency-access//initiate")] -async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { +async fn initiate_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; let initiating_user = headers.user; - 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.") - }; + let mut emergency_access = + match EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &initiating_user.uuid, &mut conn).await { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 { err!("Emergency access not valid.") } - let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { - err!("Grantor user not found.") + let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await { + Some(user) => user, + None => err!("Grantor user not found."), }; let now = Utc::now().naive_utc(); @@ -462,7 +470,7 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers, emergency_access.updated_at = now; emergency_access.recovery_initiated_at = Some(now); emergency_access.last_notification_at = Some(now); - emergency_access.save(&conn).await?; + emergency_access.save(&mut conn).await?; if CONFIG.mail_enabled() { mail::send_emergency_access_recovery_initiated( @@ -477,30 +485,32 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers, } #[post("/emergency-access//approve")] -async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { +async fn approve_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; - 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.") - }; + let mut emergency_access = + match EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 { err!("Emergency access not valid.") } - let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else { - err!("Grantor user not found.") + let grantor_user = match User::find_by_uuid(&headers.user.uuid, &mut conn).await { + Some(user) => user, + None => err!("Grantor user not found."), }; if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() { - let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else { - err!("Grantee user not found.") + let grantee_user = match User::find_by_uuid(grantee_uuid, &mut conn).await { + Some(user) => user, + None => err!("Grantee user not found."), }; emergency_access.status = EmergencyAccessStatus::RecoveryApproved as i32; - emergency_access.save(&conn).await?; + emergency_access.save(&mut conn).await?; if CONFIG.mail_enabled() { mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await?; @@ -512,14 +522,14 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers, } #[post("/emergency-access//reject")] -async fn reject_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { +async fn reject_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; - 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.") - }; + let mut emergency_access = + match EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 && emergency_access.status != EmergencyAccessStatus::RecoveryApproved as i32 @@ -528,12 +538,13 @@ async fn reject_emergency_access(emer_id: EmergencyAccessId, headers: Headers, c } if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() { - let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else { - err!("Grantee user not found.") + let grantee_user = match User::find_by_uuid(grantee_uuid, &mut conn).await { + Some(user) => user, + None => err!("Grantee user not found."), }; emergency_access.status = EmergencyAccessStatus::Confirmed as i32; - emergency_access.save(&conn).await?; + emergency_access.save(&mut conn).await?; if CONFIG.mail_enabled() { mail::send_emergency_access_recovery_rejected(&grantee_user.email, &headers.user.name).await?; @@ -549,21 +560,21 @@ async fn reject_emergency_access(emer_id: EmergencyAccessId, headers: Headers, c // region action #[post("/emergency-access//view")] -async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { +async fn view_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; - let Some(emergency_access) = - EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &conn).await - else { - err!("Emergency access not valid.") - }; + let emergency_access = + match EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &headers.user.uuid, &mut conn).await { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; if !is_valid_request(&emergency_access, &headers.user.uuid, EmergencyAccessType::View) { err!("Emergency access not valid.") } - let ciphers = Cipher::find_owned_by_user(&emergency_access.grantor_uuid, &conn).await; - let cipher_sync_data = CipherSyncData::new(&emergency_access.grantor_uuid, CipherSyncType::User, &conn).await; + let ciphers = Cipher::find_owned_by_user(&emergency_access.grantor_uuid, &mut conn).await; + let cipher_sync_data = CipherSyncData::new(&emergency_access.grantor_uuid, CipherSyncType::User, &mut conn).await; let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { @@ -573,9 +584,9 @@ async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, con &emergency_access.grantor_uuid, Some(&cipher_sync_data), CipherSyncType::User, - &conn, + &mut conn, ) - .await?, + .await, ); } @@ -587,22 +598,23 @@ async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, con } #[post("/emergency-access//takeover")] -async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { +async fn takeover_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; let requesting_user = headers.user; - let Some(emergency_access) = - EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &requesting_user.uuid, &conn).await - else { - err!("Emergency access not valid.") - }; + let emergency_access = + match EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &requesting_user.uuid, &mut conn).await { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; if !is_valid_request(&emergency_access, &requesting_user.uuid, EmergencyAccessType::Takeover) { err!("Emergency access not valid.") } - let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { - err!("Grantor user not found.") + let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await { + Some(user) => user, + None => err!("Grantor user not found."), }; let result = json!({ @@ -626,10 +638,10 @@ struct EmergencyAccessPasswordData { #[post("/emergency-access//password", data = "")] async fn password_emergency_access( - emer_id: EmergencyAccessId, + emer_id: &str, data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, ) -> EmptyResult { check_emergency_access_enabled()?; @@ -638,31 +650,32 @@ async fn password_emergency_access( //let key = &data.Key; let requesting_user = headers.user; - let Some(emergency_access) = - EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &requesting_user.uuid, &conn).await - else { - err!("Emergency access not valid.") - }; + let emergency_access = + match EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &requesting_user.uuid, &mut conn).await { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; if !is_valid_request(&emergency_access, &requesting_user.uuid, EmergencyAccessType::Takeover) { err!("Emergency access not valid.") } - let Some(mut grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { - err!("Grantor user not found.") + let mut grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await { + Some(user) => user, + None => err!("Grantor user not found."), }; // change grantor_user password - grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?; - grantor_user.save(&conn).await?; + grantor_user.set_password(new_master_password_hash, Some(data.key), true, None); + grantor_user.save(&mut conn).await?; // Disable TwoFactor providers since they will otherwise block logins - TwoFactor::delete_all_by_user(&grantor_user.uuid, &conn).await?; + TwoFactor::delete_all_by_user(&grantor_user.uuid, &mut conn).await?; // Remove grantor from all organisations unless Owner - for member in Membership::find_any_state_by_user(&grantor_user.uuid, &conn).await { - if member.atype != MembershipType::Owner as i32 { - member.delete(&conn).await?; + for user_org in UserOrganization::find_any_state_by_user(&grantor_user.uuid, &mut conn).await { + if user_org.atype != UserOrgType::Owner as i32 { + user_org.delete(&mut conn).await?; } } Ok(()) @@ -671,23 +684,24 @@ async fn password_emergency_access( // endregion #[get("/emergency-access//policies")] -async fn policies_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { +async fn policies_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult { let requesting_user = headers.user; - let Some(emergency_access) = - EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &requesting_user.uuid, &conn).await - else { - err!("Emergency access not valid.") - }; + let emergency_access = + match EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &requesting_user.uuid, &mut conn).await { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; if !is_valid_request(&emergency_access, &requesting_user.uuid, EmergencyAccessType::Takeover) { err!("Emergency access not valid.") } - let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { - err!("Grantor user not found.") + let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await { + Some(user) => user, + None => err!("Grantor user not found."), }; - let policies = OrgPolicy::find_confirmed_by_user(&grantor_user.uuid, &conn); + let policies = OrgPolicy::find_confirmed_by_user(&grantor_user.uuid, &mut conn); let policies_json: Vec = policies.await.iter().map(OrgPolicy::to_json).collect(); Ok(Json(json!({ @@ -699,11 +713,11 @@ async fn policies_emergency_access(emer_id: EmergencyAccessId, headers: Headers, fn is_valid_request( emergency_access: &EmergencyAccess, - requesting_user_id: &UserId, + requesting_user_uuid: &str, requested_access_type: EmergencyAccessType, ) -> bool { emergency_access.grantee_uuid.is_some() - && emergency_access.grantee_uuid.as_ref().unwrap() == requesting_user_id + && emergency_access.grantee_uuid.as_ref().unwrap() == requesting_user_uuid && emergency_access.status == EmergencyAccessStatus::RecoveryApproved as i32 && emergency_access.atype == requested_access_type as i32 } @@ -721,8 +735,8 @@ pub async fn emergency_request_timeout_job(pool: DbPool) { return; } - if let Ok(conn) = pool.get().await { - let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; + if let Ok(mut conn) = pool.get().await { + let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&mut conn).await; if emergency_access_list.is_empty() { debug!("No emergency request timeout to approve"); @@ -736,18 +750,18 @@ pub async fn emergency_request_timeout_job(pool: DbPool) { 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) + emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &mut conn) .await .expect("Unable to update emergency access status"); 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"); + User::find_by_uuid(&emer.grantor_uuid, &mut 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) + User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &mut conn) .await .expect("Grantee user not found"); @@ -776,8 +790,8 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) { return; } - if let Ok(conn) = pool.get().await { - let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; + if let Ok(mut conn) = pool.get().await { + let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&mut conn).await; if emergency_access_list.is_empty() { debug!("No emergency request reminder notification to send"); @@ -798,18 +812,18 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) { 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) + emer.update_last_notification_date_and_save(&now, &mut conn) .await .expect("Unable to update emergency access notification date"); 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"); + User::find_by_uuid(&emer.grantor_uuid, &mut 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) + User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &mut conn) .await .expect("Grantee user not found"); diff --git a/src/api/core/events.rs b/src/api/core/events.rs index d1612255..484094f5 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -8,7 +8,7 @@ use crate::{ api::{EmptyResult, JsonResult}, auth::{AdminHeaders, Headers}, db::{ - models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId}, + models::{Cipher, Event, UserOrganization}, DbConn, DbPool, }, util::parse_date, @@ -29,13 +29,9 @@ struct EventRange { continuation_token: Option, } -// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87 +// Upstream: https://github.com/bitwarden/server/blob/9ecf69d9cabce732cf2c57976dd9afa5728578fb/src/Api/Controllers/EventsController.cs#LL84C35-L84C41 #[get("/organizations//events?")] -async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - +async fn get_org_events(org_id: &str, data: EventRange, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult { // Return an empty vec when we org events are disabled. // This prevents client errors let events_json: Vec = if !CONFIG.org_events_enabled() { @@ -48,7 +44,7 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin parse_date(&data.end) }; - Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn) + Event::find_by_organization_uuid(org_id, &start_date, &end_date, &mut conn) .await .iter() .map(|e| e.to_json()) @@ -63,14 +59,14 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin } #[get("/ciphers//events?")] -async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult { +async fn get_cipher_events(cipher_id: &str, data: EventRange, headers: Headers, mut conn: DbConn) -> JsonResult { // Return an empty vec when we org events are disabled. // This prevents client errors let events_json: Vec = if !CONFIG.org_events_enabled() { Vec::with_capacity(0) } else { let mut events_json = Vec::with_capacity(0); - if Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await { + if UserOrganization::user_has_ge_admin_access_to_cipher(&headers.user.uuid, cipher_id, &mut conn).await { let start_date = parse_date(&data.start); let end_date = if let Some(before_date) = &data.continuation_token { parse_date(before_date) @@ -78,7 +74,7 @@ async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Heade parse_date(&data.end) }; - events_json = Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn) + events_json = Event::find_by_cipher_uuid(cipher_id, &start_date, &end_date, &mut conn) .await .iter() .map(|e| e.to_json()) @@ -94,17 +90,14 @@ async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Heade }))) } -#[get("/organizations//users//events?")] +#[get("/organizations//users//events?")] async fn get_user_events( - org_id: OrganizationId, - member_id: MembershipId, + org_id: &str, + user_org_id: &str, data: EventRange, - headers: AdminHeaders, - conn: DbConn, + _headers: AdminHeaders, + mut conn: DbConn, ) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } // Return an empty vec when we org events are disabled. // This prevents client errors let events_json: Vec = if !CONFIG.org_events_enabled() { @@ -117,7 +110,7 @@ async fn get_user_events( parse_date(&data.end) }; - Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn) + Event::find_by_org_and_user_org(org_id, user_org_id, &start_date, &end_date, &mut conn) .await .iter() .map(|e| e.to_json()) @@ -159,15 +152,15 @@ struct EventCollection { date: String, // Optional - cipher_id: Option, - organization_id: Option, + cipher_id: Option, + organization_id: Option, } // Upstream: -// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Events/Controllers/CollectController.cs -// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs +// https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Events/Controllers/CollectController.cs +// https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Core/Services/Implementations/EventService.cs #[post("/collect", format = "application/json", data = "")] -async fn post_events_collect(data: Json>, headers: Headers, conn: DbConn) -> EmptyResult { +async fn post_events_collect(data: Json>, headers: Headers, mut conn: DbConn) -> EmptyResult { if !CONFIG.org_events_enabled() { return Ok(()); } @@ -182,38 +175,38 @@ async fn post_events_collect(data: Json>, headers: Headers, headers.device.atype, Some(event_date), &headers.ip.ip, - &conn, + &mut conn, ) .await; } 1600..=1699 => { - if let Some(org_id) = &event.organization_id { + if let Some(org_uuid) = &event.organization_id { _log_event( event.r#type, - org_id, - org_id, + org_uuid, + org_uuid, &headers.user.uuid, headers.device.atype, Some(event_date), &headers.ip.ip, - &conn, + &mut conn, ) .await; } } _ => { if let Some(cipher_uuid) = &event.cipher_id { - if let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await { - if let Some(org_id) = cipher.organization_uuid { + if let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &mut conn).await { + if let Some(org_uuid) = cipher.organization_uuid { _log_event( event.r#type, cipher_uuid, - &org_id, + &org_uuid, &headers.user.uuid, headers.device.atype, Some(event_date), &headers.ip.ip, - &conn, + &mut conn, ) .await; } @@ -225,39 +218,38 @@ async fn post_events_collect(data: Json>, headers: Headers, Ok(()) } -pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { +pub async fn log_user_event(event_type: i32, user_uuid: &str, device_type: i32, ip: &IpAddr, conn: &mut DbConn) { if !CONFIG.org_events_enabled() { return; } - _log_user_event(event_type, user_id, device_type, None, ip, conn).await; + _log_user_event(event_type, user_uuid, device_type, None, ip, conn).await; } async fn _log_user_event( event_type: i32, - user_id: &UserId, + user_uuid: &str, device_type: i32, event_date: Option, ip: &IpAddr, - conn: &DbConn, + conn: &mut DbConn, ) { - let memberships = Membership::find_confirmed_by_user(user_id, conn).await; - let mut events: Vec = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org + let orgs = UserOrganization::get_org_uuid_by_user(user_uuid, conn).await; + let mut events: Vec = Vec::with_capacity(orgs.len() + 1); // We need an event per org and one without an org - // Upstream saves the event also without any org_id. + // Upstream saves the event also without any org_uuid. let mut event = Event::new(event_type, event_date); - event.user_uuid = Some(user_id.clone()); - event.act_user_uuid = Some(user_id.clone()); + event.user_uuid = Some(String::from(user_uuid)); + event.act_user_uuid = Some(String::from(user_uuid)); event.device_type = Some(device_type); event.ip_address = Some(ip.to_string()); events.push(event); // For each org a user is a member of store these events per org - for membership in memberships { + for org_uuid in orgs { let mut event = Event::new(event_type, event_date); - event.user_uuid = Some(user_id.clone()); - event.org_uuid = Some(membership.org_uuid); - event.org_user_uuid = Some(membership.uuid); - event.act_user_uuid = Some(user_id.clone()); + event.user_uuid = Some(String::from(user_uuid)); + event.org_uuid = Some(org_uuid); + event.act_user_uuid = Some(String::from(user_uuid)); event.device_type = Some(device_type); event.ip_address = Some(ip.to_string()); events.push(event); @@ -269,28 +261,28 @@ async fn _log_user_event( pub async fn log_event( event_type: i32, source_uuid: &str, - org_id: &OrganizationId, - act_user_id: &UserId, + org_uuid: &str, + act_user_uuid: &str, device_type: i32, ip: &IpAddr, - conn: &DbConn, + conn: &mut DbConn, ) { if !CONFIG.org_events_enabled() { return; } - _log_event(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await; + _log_event(event_type, source_uuid, org_uuid, act_user_uuid, device_type, None, ip, conn).await; } #[allow(clippy::too_many_arguments)] async fn _log_event( event_type: i32, source_uuid: &str, - org_id: &OrganizationId, - act_user_id: &UserId, + org_uuid: &str, + act_user_uuid: &str, device_type: i32, event_date: Option, ip: &IpAddr, - conn: &DbConn, + conn: &mut DbConn, ) { // Create a new empty event let mut event = Event::new(event_type, event_date); @@ -298,31 +290,31 @@ async fn _log_event( // 1000..=1099 Are user events, they need to be logged via log_user_event() // Cipher Events 1100..=1199 => { - event.cipher_uuid = Some(source_uuid.to_string().into()); + event.cipher_uuid = Some(String::from(source_uuid)); } // Collection Events 1300..=1399 => { - event.collection_uuid = Some(source_uuid.to_string().into()); + event.collection_uuid = Some(String::from(source_uuid)); } // Group Events 1400..=1499 => { - event.group_uuid = Some(source_uuid.to_string().into()); + event.group_uuid = Some(String::from(source_uuid)); } // Org User Events 1500..=1599 => { - event.org_user_uuid = Some(source_uuid.to_string().into()); + event.org_user_uuid = Some(String::from(source_uuid)); } // 1600..=1699 Are organizational events, and they do not need the source_uuid // Policy Events 1700..=1799 => { - event.policy_uuid = Some(source_uuid.to_string().into()); + event.policy_uuid = Some(String::from(source_uuid)); } // Ignore others _ => {} } - event.org_uuid = Some(org_id.clone()); - event.act_user_uuid = Some(act_user_id.clone()); + event.org_uuid = Some(String::from(org_uuid)); + event.act_user_uuid = Some(String::from(act_user_uuid)); event.device_type = Some(device_type); event.ip_address = Some(ip.to_string()); event.save(conn).await.unwrap_or(()); @@ -335,8 +327,8 @@ pub async fn event_cleanup_job(pool: DbPool) { return; } - if let Ok(conn) = pool.get().await { - Event::clean_events(&conn).await.ok(); + if let Ok(mut conn) = pool.get().await { + Event::clean_events(&mut conn).await.ok(); } else { error!("Failed to get DB connection while trying to cleanup the events table") } diff --git a/src/api/core/folders.rs b/src/api/core/folders.rs index 1b3fd714..9766d7a1 100644 --- a/src/api/core/folders.rs +++ b/src/api/core/folders.rs @@ -4,11 +4,7 @@ use serde_json::Value; use crate::{ api::{EmptyResult, JsonResult, Notify, UpdateType}, auth::Headers, - db::{ - models::{Folder, FolderId}, - DbConn, - }, - util::deser_opt_nonempty_str, + db::{models::*, DbConn}, }; pub fn routes() -> Vec { @@ -16,8 +12,8 @@ pub fn routes() -> Vec { } #[get("/folders")] -async fn get_folders(headers: Headers, conn: DbConn) -> Json { - let folders = Folder::find_by_user(&headers.user.uuid, &conn).await; +async fn get_folders(headers: Headers, mut conn: DbConn) -> Json { + let folders = Folder::find_by_user(&headers.user.uuid, &mut conn).await; let folders_json: Vec = folders.iter().map(Folder::to_json).collect(); Json(json!({ @@ -27,81 +23,90 @@ async fn get_folders(headers: Headers, conn: DbConn) -> Json { })) } -#[get("/folders/")] -async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> JsonResult { - match Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await { - Some(folder) => Ok(Json(folder.to_json())), - _ => err!("Invalid folder", "Folder does not exist or belongs to another user"), +#[get("/folders/")] +async fn get_folder(uuid: &str, headers: Headers, mut conn: DbConn) -> JsonResult { + let folder = match Folder::find_by_uuid(uuid, &mut conn).await { + Some(folder) => folder, + _ => err!("Invalid folder"), + }; + + if folder.user_uuid != headers.user.uuid { + err!("Folder belongs to another user") } + + Ok(Json(folder.to_json())) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct FolderData { pub name: String, - #[serde(default, deserialize_with = "deser_opt_nonempty_str")] - pub id: Option, + pub id: Option, } #[post("/folders", data = "")] -async fn post_folders(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { +async fn post_folders(data: Json, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult { let data: FolderData = data.into_inner(); let mut folder = Folder::new(headers.user.uuid, data.name); - folder.save(&conn).await?; - nt.send_folder_update(UpdateType::SyncFolderCreate, &folder, &headers.device, &conn).await; + folder.save(&mut conn).await?; + nt.send_folder_update(UpdateType::SyncFolderCreate, &folder, &headers.device.uuid, &mut conn).await; Ok(Json(folder.to_json())) } -#[post("/folders/", data = "")] -async fn post_folder( - folder_id: FolderId, - data: Json, - headers: Headers, - conn: DbConn, - nt: Notify<'_>, -) -> JsonResult { - put_folder(folder_id, data, headers, conn, nt).await +#[post("/folders/", data = "")] +async fn post_folder(uuid: &str, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { + put_folder(uuid, data, headers, conn, nt).await } -#[put("/folders/", data = "")] +#[put("/folders/", data = "")] async fn put_folder( - folder_id: FolderId, + uuid: &str, data: Json, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let data: FolderData = data.into_inner(); - let Some(mut folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await else { - err!("Invalid folder", "Folder does not exist or belongs to another user") + let mut folder = match Folder::find_by_uuid(uuid, &mut conn).await { + Some(folder) => folder, + _ => err!("Invalid folder"), }; + if folder.user_uuid != headers.user.uuid { + err!("Folder belongs to another user") + } + folder.name = data.name; - folder.save(&conn).await?; - nt.send_folder_update(UpdateType::SyncFolderUpdate, &folder, &headers.device, &conn).await; + folder.save(&mut conn).await?; + nt.send_folder_update(UpdateType::SyncFolderUpdate, &folder, &headers.device.uuid, &mut conn).await; Ok(Json(folder.to_json())) } -#[post("/folders//delete")] -async fn delete_folder_post(folder_id: FolderId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_folder(folder_id, headers, conn, nt).await +#[post("/folders//delete")] +async fn delete_folder_post(uuid: &str, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { + delete_folder(uuid, headers, conn, nt).await } -#[delete("/folders/")] -async fn delete_folder(folder_id: FolderId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - let Some(folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await else { - err!("Invalid folder", "Folder does not exist or belongs to another user") +#[delete("/folders/")] +async fn delete_folder(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { + let folder = match Folder::find_by_uuid(uuid, &mut conn).await { + Some(folder) => folder, + _ => err!("Invalid folder"), }; - // Delete the actual folder entry - folder.delete(&conn).await?; + if folder.user_uuid != headers.user.uuid { + err!("Folder belongs to another user") + } - nt.send_folder_update(UpdateType::SyncFolderDelete, &folder, &headers.device, &conn).await; + // Delete the actual folder entry + folder.delete(&mut conn).await?; + + nt.send_folder_update(UpdateType::SyncFolderDelete, &folder, &headers.device.uuid, &mut conn).await; Ok(()) } diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index ad9002fd..ee5db190 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -18,7 +18,7 @@ pub use sends::purge_sends; pub fn routes() -> Vec { let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_domains]; let mut hibp_routes = routes![hibp_breach]; - let mut meta_routes = routes![alive, now, version, config, get_api_webauthn]; + let mut meta_routes = routes![alive, now, version, config]; let mut routes = Vec::new(); routes.append(&mut accounts::routes()); @@ -50,17 +50,12 @@ pub fn events_routes() -> Vec { use rocket::{serde::json::Json, serde::json::Value, Catcher, Route}; use crate::{ - api::{EmptyResult, JsonResult, Notify, UpdateType}, + api::{JsonResult, Notify, UpdateType}, auth::Headers, - db::{ - models::{Membership, MembershipStatus, OrgPolicy, Organization, User}, - DbConn, - }, + db::DbConn, error::Error, http_client::make_http_request, - mail, - util::{parse_experimental_client_feature_flags, FeatureFlagFilter}, - CONFIG, + util::parse_experimental_client_feature_flags, }; #[derive(Debug, Serialize, Deserialize)] @@ -75,11 +70,11 @@ const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json"); #[get("/settings/domains")] fn get_eq_domains(headers: Headers) -> Json { - _get_eq_domains(&headers, false) + _get_eq_domains(headers, false) } -fn _get_eq_domains(headers: &Headers, no_excluded: bool) -> Json { - let user = &headers.user; +fn _get_eq_domains(headers: Headers, no_excluded: bool) -> Json { + let user = headers.user; use serde_json::from_str; let equivalent_domains: Vec> = from_str(&user.equivalent_domains).unwrap(); @@ -110,7 +105,12 @@ struct EquivDomainData { } #[post("/settings/domains", data = "")] -async fn post_eq_domains(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { +async fn post_eq_domains( + data: Json, + headers: Headers, + mut conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { let data: EquivDomainData = data.into_inner(); let excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default(); @@ -122,9 +122,9 @@ async fn post_eq_domains(data: Json, headers: Headers, conn: Db user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_string()); user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_string()); - user.save(&conn).await?; + user.save(&mut conn).await?; - nt.send_user_update(UpdateType::SyncSettings, &user, headers.device.push_uuid.as_ref(), &conn).await; + nt.send_user_update(UpdateType::SyncSettings, &user).await; Ok(Json(json!({}))) } @@ -135,13 +135,12 @@ async fn put_eq_domains(data: Json, headers: Headers, conn: DbC } #[get("/hibp/breach?")] -async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult { - let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect(); - if let Some(api_key) = CONFIG.hibp_api_key() { - let url = format!( - "https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false" - ); +async fn hibp_breach(username: &str) -> JsonResult { + let url = format!( + "https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false" + ); + if let Some(api_key) = crate::CONFIG.hibp_api_key() { let res = make_http_request(Method::GET, &url)?.header("hibp-api-key", api_key).send().await?; // If we get a 404, return a 404, it means no breached accounts @@ -184,47 +183,29 @@ fn version() -> Json<&'static str> { Json(crate::VERSION.unwrap_or_default()) } -#[get("/webauthn")] -fn get_api_webauthn(_headers: Headers) -> Json { - // Prevent a 404 error, which also causes key-rotation issues - // It looks like this is used when login with passkeys is enabled, which Vaultwarden does not (yet) support - // An empty list/data also works fine - Json(json!({ - "object": "list", - "data": [], - "continuationToken": null - })) -} - #[get("/config")] fn config() -> Json { - let domain = CONFIG.domain(); - // Official available feature flags can be found here: - // Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135 - // Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12 - // 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 - let mut feature_states = parse_experimental_client_feature_flags( - &CONFIG.experimental_client_feature_flags(), - FeatureFlagFilter::ValidOnly, - ); - feature_states.insert("pm-19148-innovation-archive".to_string(), true); + let domain = crate::CONFIG.domain(); + let mut feature_states = + parse_experimental_client_feature_flags(&crate::CONFIG.experimental_client_feature_flags()); + // Force the new key rotation feature + feature_states.insert("key-rotation-improvements".to_string(), true); + feature_states.insert("flexible-collections-v-1".to_string(), false); Json(json!({ // Note: The clients use this version to handle backwards compatibility concerns // This means they expect a version that closely matches the Bitwarden server version // We should make sure that we keep this updated when we support the new server features // Version history: - // - Individual cipher key encryption: 2024.2.0 - // - Mobile app support for MasterPasswordUnlockData: 2025.8.0 - "version": "2025.12.0", + // - Individual cipher key encryption: 2023.9.1 + "version": "2024.2.0", "gitHash": option_env!("GIT_REV"), "server": { "name": "Vaultwarden", "url": "https://github.com/dani-garcia/vaultwarden" }, "settings": { - "disableUserRegistration": CONFIG.is_signup_disabled() + "disableUserRegistration": !crate::CONFIG.signups_allowed() && crate::CONFIG.signups_domains_whitelist().is_empty(), }, "environment": { "vault": domain, @@ -232,12 +213,6 @@ fn config() -> Json { "identity": format!("{domain}/identity"), "notifications": format!("{domain}/notifications"), "sso": "", - "cloudRegion": null, - }, - // Bitwarden uses this for the self-hosted servers to indicate the default push technology - "push": { - "pushTechnology": 0, - "vapidPublicKey": null }, "featureStates": feature_states, "object": "config", @@ -258,34 +233,3 @@ fn api_not_found() -> Json { } })) } - -async fn accept_org_invite( - user: &User, - mut member: Membership, - reset_password_key: Option, - conn: &DbConn, -) -> EmptyResult { - if member.status != MembershipStatus::Invited as i32 { - err!("User already accepted the invitation"); - } - - member.status = MembershipStatus::Accepted as i32; - member.reset_password_key = reset_password_key; - - // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type - OrgPolicy::check_user_allowed(&member, "join", conn).await?; - - member.save(conn).await?; - - if CONFIG.mail_enabled() { - let org = match Organization::find_by_uuid(&member.org_uuid, conn).await { - Some(org) => org, - None => err!("Organization not found."), - }; - // User was invited to an organization, so they must be confirmed manually after acceptance - mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name) - .await?; - } - - Ok(()) -} diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 31311a65..402e7617 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -4,23 +4,15 @@ use rocket::Route; use serde_json::Value; use std::collections::{HashMap, HashSet}; -use crate::api::admin::FAKE_ADMIN_UUID; use crate::{ api::{ - core::{accept_org_invite, log_event, two_factor, CipherSyncData, CipherSyncType}, + core::{log_event, two_factor, CipherSyncData, CipherSyncType}, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, }, - auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders}, - db::{ - models::{ - Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, - Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, - OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, - }, - DbConn, - }, + auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders}, + db::{models::*, DbConn}, + error::Error, mail, - sso::FAKE_SSO_IDENTIFIER, util::{convert_json_key_lcase_first, NumberOrString}, CONFIG, }; @@ -37,10 +29,12 @@ pub fn routes() -> Vec { get_org_collections_details, get_org_collection_detail, get_collection_users, + put_collection_users, put_organization, post_organization, post_organization_collections, - post_bulk_access_collections, + delete_organization_collection_user, + post_organization_collection_delete_user, post_organization_collection_update, put_organization_collection_update, delete_organization_collection, @@ -48,39 +42,42 @@ pub fn routes() -> Vec { bulk_delete_organization_collections, post_bulk_collections, get_org_details, - get_org_domain_sso_verified, - get_members, + get_org_users, send_invite, - reinvite_member, - bulk_reinvite_members, + reinvite_user, + bulk_reinvite_user, confirm_invite, bulk_confirm_invite, accept_invite, - get_org_user_mini_details, get_user, - edit_member, - put_member, - delete_member, - bulk_delete_member, + edit_user, + put_organization_user, + delete_user, + bulk_delete_user, + post_delete_user, post_org_import, list_policies, list_policies_token, - get_dummy_master_password_policy, - get_master_password_policy, get_policy, put_policy, - put_policy_vnext, + get_organization_tax, get_plans, + get_plans_all, + get_plans_tax_rates, + import, post_org_keys, get_organization_keys, get_organization_public_key, bulk_public_keys, - revoke_member, - bulk_revoke_members, - restore_member, - bulk_restore_members, + deactivate_organization_user, + bulk_deactivate_organization_user, + revoke_organization_user, + bulk_revoke_organization_user, + activate_organization_user, + bulk_activate_organization_user, + restore_organization_user, + bulk_restore_organization_user, get_groups, - get_groups_details, post_groups, get_group, put_group, @@ -89,19 +86,19 @@ pub fn routes() -> Vec { delete_group, post_delete_group, bulk_delete_groups, - get_group_members, - put_group_members, - post_delete_group_member, + get_group_users, + put_group_users, + get_user_groups, + post_user_groups, + put_user_groups, + delete_group_user, + post_delete_group_user, put_reset_password_enrollment, get_reset_password_details, put_reset_password, get_org_export, api_key, rotate_api_key, - get_billing_metadata, - get_billing_warnings, - get_auto_enroll_status, - get_self_host_billing_metadata, ] } @@ -126,48 +123,19 @@ struct OrganizationUpdateData { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct FullCollectionData { +struct NewCollectionData { name: String, - groups: Vec, - users: Vec, - id: Option, + groups: Vec, + users: Vec, external_id: Option, } -impl FullCollectionData { - pub async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { - 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, 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, org_id)) - } - - Ok(()) - } -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct CollectionGroupData { +struct NewCollectionObjectData { hide_passwords: bool, - id: GroupId, + id: String, read_only: bool, - manage: bool, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CollectionMembershipData { - hide_passwords: bool, - id: MembershipId, - read_only: bool, - manage: bool, } #[derive(Deserialize)] @@ -179,73 +147,65 @@ struct OrgKeyData { #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] -struct BulkGroupIds { - ids: Vec, -} - -#[derive(Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -struct BulkMembershipIds { - ids: Vec, +struct OrgBulkIds { + ids: Vec, } #[post("/organizations", data = "")] -async fn create_organization(headers: Headers, data: Json, conn: DbConn) -> JsonResult { +async fn create_organization(headers: Headers, data: Json, mut conn: DbConn) -> JsonResult { if !CONFIG.is_org_creation_allowed(&headers.user.email) { err!("User not allowed to create organizations") } - if OrgPolicy::is_applicable_to_user(&headers.user.uuid, OrgPolicyType::SingleOrg, None, &conn).await { + if OrgPolicy::is_applicable_to_user(&headers.user.uuid, OrgPolicyType::SingleOrg, None, &mut conn).await { err!( "You may not create an organization. You belong to an organization which has a policy that prohibits you from being a member of any other organization." ) } let data: OrgData = data.into_inner(); - let (private_key, public_key) = if let Some(keys) = data.keys { + let (private_key, public_key) = if data.keys.is_some() { + let keys: OrgKeyData = data.keys.unwrap(); (Some(keys.encrypted_private_key), Some(keys.public_key)) } else { (None, None) }; - let org = Organization::new(data.name, &data.billing_email, private_key, public_key); - let mut member = Membership::new(headers.user.uuid, org.uuid.clone(), None); + let org = Organization::new(data.name, data.billing_email, private_key, public_key); + let mut user_org = UserOrganization::new(headers.user.uuid, org.uuid.clone()); let collection = Collection::new(org.uuid.clone(), data.collection_name, None); - member.akey = data.key; - member.access_all = true; - member.atype = MembershipType::Owner as i32; - member.status = MembershipStatus::Confirmed as i32; + user_org.akey = data.key; + user_org.access_all = true; + user_org.atype = UserOrgType::Owner as i32; + user_org.status = UserOrgStatus::Confirmed as i32; - org.save(&conn).await?; - member.save(&conn).await?; - collection.save(&conn).await?; + org.save(&mut conn).await?; + user_org.save(&mut conn).await?; + collection.save(&mut conn).await?; Ok(Json(org.to_json())) } #[delete("/organizations/", data = "")] async fn delete_organization( - org_id: OrganizationId, + org_id: &str, data: Json, headers: OwnerHeaders, - conn: DbConn, + mut conn: DbConn, ) -> EmptyResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } let data: PasswordOrOtpData = data.into_inner(); - data.validate(&headers.user, true, &conn).await?; + data.validate(&headers.user, true, &mut conn).await?; - match Organization::find_by_uuid(&org_id, &conn).await { + match Organization::find_by_uuid(org_id, &mut conn).await { None => err!("Organization not found"), - Some(org) => org.delete(&conn).await, + Some(org) => org.delete(&mut conn).await, } } #[post("/organizations//delete", data = "")] async fn post_delete_organization( - org_id: OrganizationId, + org_id: &str, data: Json, headers: OwnerHeaders, conn: DbConn, @@ -254,38 +214,35 @@ async fn post_delete_organization( } #[post("/organizations//leave")] -async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> EmptyResult { - if headers.membership.status != MembershipStatus::Confirmed as i32 { - err!("You need to be a Member of the Organization to call this endpoint") +async fn leave_organization(org_id: &str, headers: Headers, mut conn: DbConn) -> EmptyResult { + match UserOrganization::find_by_user_and_org(&headers.user.uuid, org_id, &mut conn).await { + None => err!("User not part of organization"), + Some(user_org) => { + if user_org.atype == UserOrgType::Owner + && UserOrganization::count_confirmed_by_org_and_type(org_id, UserOrgType::Owner, &mut conn).await <= 1 + { + err!("The last owner can't leave") + } + + log_event( + EventType::OrganizationUserRemoved as i32, + &user_org.uuid, + org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &mut conn, + ) + .await; + + user_org.delete(&mut conn).await + } } - let membership = headers.membership; - - if membership.atype == MembershipType::Owner - && Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 - { - err!("The last owner can't leave") - } - - log_event( - EventType::OrganizationUserLeft as i32, - &membership.uuid, - &org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; - - membership.delete(&conn).await } #[get("/organizations/")] -async fn get_organization(org_id: OrganizationId, headers: OwnerHeaders, conn: DbConn) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - match Organization::find_by_uuid(&org_id, &conn).await { +async fn get_organization(org_id: &str, _headers: OwnerHeaders, mut conn: DbConn) -> JsonResult { + match Organization::find_by_uuid(org_id, &mut conn).await { Some(organization) => Ok(Json(organization.to_json())), None => err!("Can't find organization details"), } @@ -293,7 +250,7 @@ async fn get_organization(org_id: OrganizationId, headers: OwnerHeaders, conn: D #[put("/organizations/", data = "")] async fn put_organization( - org_id: OrganizationId, + org_id: &str, headers: OwnerHeaders, data: Json, conn: DbConn, @@ -303,34 +260,31 @@ async fn put_organization( #[post("/organizations/", data = "")] async fn post_organization( - org_id: OrganizationId, + org_id: &str, headers: OwnerHeaders, data: Json, - conn: DbConn, + mut conn: DbConn, ) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - let data: OrganizationUpdateData = data.into_inner(); - let Some(mut org) = Organization::find_by_uuid(&org_id, &conn).await else { - err!("Organization not found") + let mut org = match Organization::find_by_uuid(org_id, &mut conn).await { + Some(organization) => organization, + None => err!("Can't find organization details"), }; org.name = data.name; - org.billing_email = data.billing_email.to_lowercase(); + org.billing_email = data.billing_email; - org.save(&conn).await?; + org.save(&mut conn).await?; log_event( EventType::OrganizationUpdated as i32, - org_id.as_ref(), - &org_id, + org_id, + org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await; @@ -339,10 +293,10 @@ async fn post_organization( // GET /api/collections?writeOnly=false #[get("/collections")] -async fn get_user_collections(headers: Headers, conn: DbConn) -> Json { +async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json { Json(json!({ "data": - Collection::find_by_user_uuid(headers.user.uuid, &conn).await + Collection::find_by_user_uuid(headers.user.uuid, &mut conn).await .iter() .map(Collection::to_json) .collect::(), @@ -351,126 +305,60 @@ async fn get_user_collections(headers: Headers, conn: DbConn) -> Json { })) } -// Called during the SSO enrollment -// The `identifier` should be the value returned by `get_org_domain_sso_verified` -// The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it -#[get("/organizations//auto-enroll-status")] -async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn) -> JsonResult { - let org = if identifier == FAKE_SSO_IDENTIFIER { - match Membership::find_main_user_org(&headers.user.uuid, &conn).await { - Some(member) => Organization::find_by_uuid(&member.org_uuid, &conn).await, - None => None, - } - } else { - Organization::find_by_uuid(&identifier.into(), &conn).await - }; - - let (id, identifier, rp_auto_enroll) = match org { - None => (identifier.to_string(), identifier.to_string(), false), - Some(org) => ( - org.uuid.to_string(), - org.uuid.to_string(), - OrgPolicy::org_is_reset_password_auto_enroll(&org.uuid, &conn).await, - ), - }; - - Ok(Json(json!({ - "id": id, - "identifier": identifier, - "resetPasswordEnabled": rp_auto_enroll, - }))) -} - #[get("/organizations//collections")] -async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { - if org_id != headers.membership.org_uuid { - err!("Organization not found", "Organization id's do not match"); - } - - if !headers.membership.has_full_access() { - err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); - } - - Ok(Json(json!({ - "data": _get_org_collections(&org_id, &conn).await, +async fn get_org_collections(org_id: &str, _headers: ManagerHeadersLoose, mut conn: DbConn) -> Json { + Json(json!({ + "data": _get_org_collections(org_id, &mut conn).await, "object": "list", "continuationToken": null, - }))) + })) } #[get("/organizations//collections/details")] -async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { - if org_id != headers.membership.org_uuid { - err!("Organization not found", "Organization id's do not match"); - } +async fn get_org_collections_details(org_id: &str, headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult { + let mut data = Vec::new(); - let Some(member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else { - err!("User is not part of organization") + let user_org = match UserOrganization::find_by_user_and_org(&headers.user.uuid, org_id, &mut conn).await { + Some(u) => u, + None => err!("User is not part of organization"), }; // get all collection memberships for the current organization - 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 - let membership_type: HashMap = - Membership::find_confirmed_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect(); + let coll_users = CollectionUser::find_by_organization(org_id, &mut conn).await; // 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() - || (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&org_id, &member.uuid, &conn).await); + let has_full_access_to_org = user_org.access_all + || (CONFIG.org_groups_enabled() + && GroupUser::has_full_access_by_member(org_id, &user_org.uuid, &mut conn).await); - // Get all admins, owners and managers who can manage/access all - // Those are currently not listed in the col_users but need to be listed too. - let manage_all_members: Vec = Membership::find_confirmed_and_manage_all_by_org(&org_id, &conn) - .await - .into_iter() - .map(|member| { - json!({ - "id": member.uuid, - "readOnly": false, - "hidePasswords": false, - "manage": true, - }) - }) - .collect(); - - let mut data = Vec::new(); - for col in Collection::find_by_organization(&org_id, &conn).await { + for col in Collection::find_by_organization(org_id, &mut conn).await { // check whether the current user has access to the given collection let assigned = has_full_access_to_org - || CollectionUser::has_access_to_collection_by_user(&col.uuid, &member.user_uuid, &conn).await + || CollectionUser::has_access_to_collection_by_user(&col.uuid, &user_org.user_uuid, &mut conn).await || (CONFIG.org_groups_enabled() - && GroupUser::has_access_to_collection_by_member(&col.uuid, &member.uuid, &conn).await); - - // If the user is a manager, and is not assigned to this collection, skip this and continue with the next collection - if !assigned { - continue; - } + && GroupUser::has_access_to_collection_by_member(&col.uuid, &user_org.uuid, &mut conn).await); // get the users assigned directly to the given collection - let mut users: Vec = col_users + let users: Vec = coll_users .iter() - .filter(|collection_member| collection_member.collection_uuid == col.uuid) - .map(|collection_member| { - collection_member.to_json_details_for_member( - *membership_type.get(&collection_member.membership_uuid).unwrap_or(&(MembershipType::User as i32)), - ) - }) + .filter(|collection_user| collection_user.collection_uuid == col.uuid) + .map(|collection_user| SelectionReadOnly::to_collection_user_details_read_only(collection_user).to_json()) .collect(); - users.extend_from_slice(&manage_all_members); // get the group details for the given collection let groups: Vec = if CONFIG.org_groups_enabled() { - CollectionGroup::find_by_collection(&col.uuid, &conn) + CollectionGroup::find_by_collection(&col.uuid, &mut conn) .await .iter() - .map(|collection_group| collection_group.to_json_details_for_group()) + .map(|collection_group| { + SelectionReadOnly::to_collection_group_details_read_only(collection_group).to_json() + }) .collect() } else { Vec::with_capacity(0) }; - let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await; + let mut json_object = col.to_json_details(&headers.user.uuid, None, &mut conn).await; json_object["assigned"] = json!(assigned); json_object["users"] = json!(users); json_object["groups"] = json!(groups); @@ -486,174 +374,99 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea }))) } -async fn _get_org_collections(org_id: &OrganizationId, conn: &DbConn) -> Value { +async fn _get_org_collections(org_id: &str, conn: &mut DbConn) -> Value { Collection::find_by_organization(org_id, conn).await.iter().map(Collection::to_json).collect::() } #[post("/organizations//collections", data = "")] async fn post_organization_collections( - org_id: OrganizationId, + org_id: &str, headers: ManagerHeadersLoose, - data: Json, - conn: DbConn, + data: Json, + mut conn: DbConn, ) -> JsonResult { - if org_id != headers.membership.org_uuid { - err!("Organization not found", "Organization id's do not match"); - } - let data: FullCollectionData = data.into_inner(); - data.validate(&org_id, &conn).await?; + let data: NewCollectionData = data.into_inner(); - if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all { - err!("You don't have permission to create collections") - } + let org = match Organization::find_by_uuid(org_id, &mut conn).await { + Some(organization) => organization, + None => err!("Can't find organization details"), + }; - let collection = Collection::new(org_id.clone(), data.name, data.external_id); - collection.save(&conn).await?; + let collection = Collection::new(org.uuid, data.name, data.external_id); + collection.save(&mut conn).await?; log_event( EventType::CollectionCreated as i32, &collection.uuid, - &org_id, + org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await; for group in data.groups { - CollectionGroup::new(collection.uuid.clone(), group.id, group.read_only, group.hide_passwords, group.manage) - .save(&org_id, &conn) + CollectionGroup::new(collection.uuid.clone(), group.id, group.read_only, group.hide_passwords) + .save(&mut conn) .await?; } for user in data.users { - let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else { - err!("User is not part of organization") + let org_user = match UserOrganization::find_by_uuid(&user.id, &mut conn).await { + Some(u) => u, + None => err!("User is not part of organization"), }; - if member.access_all { + if org_user.access_all { continue; } - CollectionUser::save( - &member.user_uuid, - &collection.uuid, - user.read_only, - user.hide_passwords, - user.manage, - &conn, - ) - .await?; + CollectionUser::save(&org_user.user_uuid, &collection.uuid, user.read_only, user.hide_passwords, &mut conn) + .await?; } - Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await)) -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct BulkCollectionAccessData { - collection_ids: Vec, - groups: Vec, - users: Vec, -} - -#[post("/organizations//collections/bulk-access", data = "", rank = 1)] -async fn post_bulk_access_collections( - org_id: OrganizationId, - headers: ManagerHeadersLoose, - data: Json, - conn: DbConn, -) -> EmptyResult { - if org_id != headers.membership.org_uuid { - err!("Organization not found", "Organization id's do not match"); - } - let data: BulkCollectionAccessData = data.into_inner(); - - if Organization::find_by_uuid(&org_id, &conn).await.is_none() { - err!("Can't find organization details") - }; - - for col_id in data.collection_ids { - let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { - err!("Collection not found") - }; - - if !collection.is_manageable_by_user(&headers.membership.user_uuid, &conn).await { - err!("Collection not found", "The current user isn't a manager for this collection") - } - - // update collection modification date - collection.save(&conn).await?; - - log_event( - EventType::CollectionUpdated as i32, - &collection.uuid, - &org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; - - CollectionGroup::delete_all_by_collection(&col_id, &org_id, &conn).await?; - for group in &data.groups { - CollectionGroup::new(col_id.clone(), group.id.clone(), group.read_only, group.hide_passwords, group.manage) - .save(&org_id, &conn) - .await?; - } - - CollectionUser::delete_all_by_collection(&col_id, &conn).await?; - for user in &data.users { - let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else { - err!("User is not part of organization") - }; - - if member.access_all { - continue; - } - - CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &conn) - .await?; - } + if headers.org_user.atype == UserOrgType::Manager && !headers.org_user.access_all { + CollectionUser::save(&headers.org_user.user_uuid, &collection.uuid, false, false, &mut conn).await?; } - Ok(()) + Ok(Json(collection.to_json())) } #[put("/organizations//collections/", data = "")] async fn put_organization_collection_update( - org_id: OrganizationId, - col_id: CollectionId, + org_id: &str, + col_id: &str, headers: ManagerHeaders, - data: Json, + data: Json, conn: DbConn, ) -> JsonResult { post_organization_collection_update(org_id, col_id, headers, data, conn).await } -#[post("/organizations//collections/", data = "", rank = 2)] +#[post("/organizations//collections/", data = "")] async fn post_organization_collection_update( - org_id: OrganizationId, - col_id: CollectionId, + org_id: &str, + col_id: &str, headers: ManagerHeaders, - data: Json, - conn: DbConn, + data: Json, + mut conn: DbConn, ) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); + let data: NewCollectionData = data.into_inner(); + + let org = match Organization::find_by_uuid(org_id, &mut conn).await { + Some(organization) => organization, + None => err!("Can't find organization details"), + }; + + let mut collection = match Collection::find_by_uuid(col_id, &mut conn).await { + Some(collection) => collection, + None => err!("Collection not found"), + }; + + if collection.org_uuid != org.uuid { + err!("Collection is not owned by organization"); } - let data: FullCollectionData = data.into_inner(); - data.validate(&org_id, &conn).await?; - - if Organization::find_by_uuid(&org_id, &conn).await.is_none() { - err!("Can't find organization details") - }; - - let Some(mut collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { - err!("Collection not found") - }; collection.name = data.name; collection.external_id = match data.external_id { @@ -661,144 +474,195 @@ async fn post_organization_collection_update( _ => None, }; - collection.save(&conn).await?; + collection.save(&mut conn).await?; log_event( EventType::CollectionUpdated as i32, &collection.uuid, - &org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; - - CollectionGroup::delete_all_by_collection(&col_id, &org_id, &conn).await?; - - for group in data.groups { - CollectionGroup::new(col_id.clone(), group.id, group.read_only, group.hide_passwords, group.manage) - .save(&org_id, &conn) - .await?; - } - - CollectionUser::delete_all_by_collection(&col_id, &conn).await?; - - for user in data.users { - let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else { - err!("User is not part of organization") - }; - - if member.access_all { - continue; - } - - CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &conn) - .await?; - } - - Ok(Json(collection.to_json_details(&headers.user.uuid, None, &conn).await)) -} - -async fn _delete_organization_collection( - org_id: &OrganizationId, - col_id: &CollectionId, - headers: &ManagerHeaders, - conn: &DbConn, -) -> EmptyResult { - if org_id != &headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - let Some(collection) = Collection::find_by_uuid_and_org(col_id, org_id, conn).await else { - err!("Collection not found", "Collection does not exist or does not belong to this organization") - }; - log_event( - EventType::CollectionDeleted as i32, - &collection.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - conn, + &mut conn, ) .await; - collection.delete(conn).await + + CollectionGroup::delete_all_by_collection(col_id, &mut conn).await?; + + for group in data.groups { + CollectionGroup::new(String::from(col_id), group.id, group.read_only, group.hide_passwords) + .save(&mut conn) + .await?; + } + + CollectionUser::delete_all_by_collection(col_id, &mut conn).await?; + + for user in data.users { + let org_user = match UserOrganization::find_by_uuid(&user.id, &mut conn).await { + Some(u) => u, + None => err!("User is not part of organization"), + }; + + if org_user.access_all { + continue; + } + + CollectionUser::save(&org_user.user_uuid, col_id, user.read_only, user.hide_passwords, &mut conn).await?; + } + + Ok(Json(collection.to_json_details(&headers.user.uuid, None, &mut conn).await)) +} + +#[delete("/organizations//collections//user/")] +async fn delete_organization_collection_user( + org_id: &str, + col_id: &str, + org_user_id: &str, + _headers: AdminHeaders, + mut conn: DbConn, +) -> EmptyResult { + let collection = match Collection::find_by_uuid(col_id, &mut conn).await { + None => err!("Collection not found"), + Some(collection) => { + if collection.org_uuid == org_id { + collection + } else { + err!("Collection and Organization id do not match") + } + } + }; + + match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, &mut conn).await { + None => err!("User not found in organization"), + Some(user_org) => { + match CollectionUser::find_by_collection_and_user(&collection.uuid, &user_org.user_uuid, &mut conn).await { + None => err!("User not assigned to collection"), + Some(col_user) => col_user.delete(&mut conn).await, + } + } + } +} + +#[post("/organizations//collections//delete-user/")] +async fn post_organization_collection_delete_user( + org_id: &str, + col_id: &str, + org_user_id: &str, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + delete_organization_collection_user(org_id, col_id, org_user_id, headers, conn).await +} + +async fn _delete_organization_collection( + org_id: &str, + col_id: &str, + headers: &ManagerHeaders, + conn: &mut DbConn, +) -> EmptyResult { + match Collection::find_by_uuid(col_id, conn).await { + None => err!("Collection not found"), + Some(collection) => { + if collection.org_uuid == org_id { + log_event( + EventType::CollectionDeleted as i32, + &collection.uuid, + org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + conn, + ) + .await; + collection.delete(conn).await + } else { + err!("Collection and Organization id do not match") + } + } + } } #[delete("/organizations//collections/")] async fn delete_organization_collection( - org_id: OrganizationId, - col_id: CollectionId, + org_id: &str, + col_id: &str, headers: ManagerHeaders, - conn: DbConn, + mut conn: DbConn, ) -> EmptyResult { - _delete_organization_collection(&org_id, &col_id, &headers, &conn).await + _delete_organization_collection(org_id, col_id, &headers, &mut conn).await } -#[post("/organizations//collections//delete")] +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct DeleteCollectionData { + #[allow(dead_code)] + id: String, + #[allow(dead_code)] + org_id: String, +} + +#[post("/organizations//collections//delete", data = "<_data>")] async fn post_organization_collection_delete( - org_id: OrganizationId, - col_id: CollectionId, + org_id: &str, + col_id: &str, headers: ManagerHeaders, - conn: DbConn, + _data: Json, + mut conn: DbConn, ) -> EmptyResult { - _delete_organization_collection(&org_id, &col_id, &headers, &conn).await + _delete_organization_collection(org_id, col_id, &headers, &mut conn).await } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct BulkCollectionIds { - ids: Vec, + ids: Vec, } #[delete("/organizations//collections", data = "")] async fn bulk_delete_organization_collections( - org_id: OrganizationId, + org_id: &str, headers: ManagerHeadersLoose, data: Json, - conn: DbConn, + mut conn: DbConn, ) -> EmptyResult { - if org_id != headers.membership.org_uuid { - err!("Organization not found", "Organization id's do not match"); - } let data: BulkCollectionIds = data.into_inner(); let collections = data.ids; - let headers = ManagerHeaders::from_loose(headers, &collections, &conn).await?; + let headers = ManagerHeaders::from_loose(headers, &collections, &mut conn).await?; for col_id in collections { - _delete_organization_collection(&org_id, &col_id, &headers, &conn).await? + _delete_organization_collection(org_id, &col_id, &headers, &mut conn).await? } Ok(()) } -#[get("/organizations//collections//details")] +#[get("/organizations//collections//details")] async fn get_org_collection_detail( - org_id: OrganizationId, - col_id: CollectionId, + org_id: &str, + coll_id: &str, headers: ManagerHeaders, - conn: DbConn, + mut conn: DbConn, ) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - match Collection::find_by_uuid_and_user(&col_id, headers.user.uuid.clone(), &conn).await { + match Collection::find_by_uuid_and_user(coll_id, headers.user.uuid.clone(), &mut conn).await { None => err!("Collection not found"), Some(collection) => { if collection.org_uuid != org_id { err!("Collection is not owned by organization") } - let Some(member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else { - err!("User is not part of organization") + let user_org = match UserOrganization::find_by_user_and_org(&headers.user.uuid, org_id, &mut conn).await { + Some(u) => u, + None => err!("User is not part of organization"), }; let groups: Vec = if CONFIG.org_groups_enabled() { - CollectionGroup::find_by_collection(&collection.uuid, &conn) + CollectionGroup::find_by_collection(&collection.uuid, &mut conn) .await .iter() - .map(|collection_group| collection_group.to_json_details_for_group()) + .map(|collection_group| { + SelectionReadOnly::to_collection_group_details_read_only(collection_group).to_json() + }) .collect() } else { // The Bitwarden clients seem to call this API regardless of whether groups are enabled, @@ -806,30 +670,18 @@ async fn get_org_collection_detail( Vec::with_capacity(0) }; - // Generate a HashMap to get the correct MembershipType per user to determine the manage permission - // We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser - let membership_type: HashMap = Membership::find_confirmed_by_org(&org_id, &conn) - .await - .into_iter() - .map(|m| (m.uuid, m.atype)) - .collect(); - let users: Vec = - CollectionUser::find_by_org_and_coll_swap_user_uuid_with_member_uuid(&org_id, &collection.uuid, &conn) + CollectionUser::find_by_collection_swap_user_uuid_with_org_user_uuid(&collection.uuid, &mut conn) .await .iter() - .map(|collection_member| { - collection_member.to_json_details_for_member( - *membership_type - .get(&collection_member.membership_uuid) - .unwrap_or(&(MembershipType::User as i32)), - ) + .map(|collection_user| { + SelectionReadOnly::to_collection_user_details_read_only(collection_user).to_json() }) .collect(); - let assigned = Collection::can_access_collection(&member, &collection.uuid, &conn).await; + let assigned = Collection::can_access_collection(&user_org, &collection.uuid, &mut conn).await; - let mut json_object = collection.to_json_details(&headers.user.uuid, None, &conn).await; + let mut json_object = collection.to_json_details(&headers.user.uuid, None, &mut conn).await; json_object["assigned"] = json!(assigned); json_object["users"] = json!(users); json_object["groups"] = json!(groups); @@ -840,89 +692,92 @@ async fn get_org_collection_detail( } } -#[get("/organizations//collections//users")] -async fn get_collection_users( - org_id: OrganizationId, - col_id: CollectionId, - headers: ManagerHeaders, - conn: DbConn, -) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } +#[get("/organizations//collections//users")] +async fn get_collection_users(org_id: &str, coll_id: &str, _headers: ManagerHeaders, mut conn: DbConn) -> JsonResult { // Get org and collection, check that collection is from org - let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { - err!("Collection not found in Organization") + let collection = match Collection::find_by_uuid_and_org(coll_id, org_id, &mut conn).await { + None => err!("Collection not found in Organization"), + Some(collection) => collection, }; - let mut member_list = Vec::new(); - for col_user in CollectionUser::find_by_collection(&collection.uuid, &conn).await { - member_list.push( - Membership::find_by_user_and_org(&col_user.user_uuid, &org_id, &conn) + let mut user_list = Vec::new(); + for col_user in CollectionUser::find_by_collection(&collection.uuid, &mut conn).await { + user_list.push( + UserOrganization::find_by_user_and_org(&col_user.user_uuid, org_id, &mut conn) .await .unwrap() .to_json_user_access_restrictions(&col_user), ); } - Ok(Json(json!(member_list))) + Ok(Json(json!(user_list))) +} + +#[put("/organizations//collections//users", data = "")] +async fn put_collection_users( + org_id: &str, + coll_id: &str, + data: Json>, + _headers: ManagerHeaders, + mut conn: DbConn, +) -> EmptyResult { + // Get org and collection, check that collection is from org + if Collection::find_by_uuid_and_org(coll_id, org_id, &mut conn).await.is_none() { + err!("Collection not found in Organization") + } + + // Delete all the user-collections + CollectionUser::delete_all_by_collection(coll_id, &mut conn).await?; + + // And then add all the received ones (except if the user has access_all) + for d in data.iter() { + let user = match UserOrganization::find_by_uuid(&d.id, &mut conn).await { + Some(u) => u, + None => err!("User is not part of organization"), + }; + + if user.access_all { + continue; + } + + CollectionUser::save(&user.user_uuid, coll_id, d.read_only, d.hide_passwords, &mut conn).await?; + } + + Ok(()) } #[derive(FromForm)] struct OrgIdData { #[field(name = "organizationId")] - organization_id: OrganizationId, + organization_id: String, } #[get("/ciphers/organization-details?")] -async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { - if data.organization_id != headers.membership.org_uuid { - err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code); - } - - if !headers.membership.has_full_access() { - err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); +async fn get_org_details(data: OrgIdData, headers: Headers, mut conn: DbConn) -> JsonResult { + if UserOrganization::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &mut conn) + .await + .is_none() + { + err_code!("Resource not found.", rocket::http::Status::NotFound.code); } Ok(Json(json!({ - "data": _get_org_details(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?, + "data": _get_org_details(&data.organization_id, &headers.host, &headers.user.uuid, &mut conn).await, "object": "list", "continuationToken": null, }))) } -async fn _get_org_details( - org_id: &OrganizationId, - host: &str, - user_id: &UserId, - conn: &DbConn, -) -> Result { +async fn _get_org_details(org_id: &str, host: &str, user_uuid: &str, conn: &mut DbConn) -> Value { let ciphers = Cipher::find_by_org(org_id, conn).await; - let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; + let cipher_sync_data = CipherSyncData::new(user_uuid, CipherSyncType::Organization, conn).await; let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { - ciphers_json.push(c.to_json(host, user_id, Some(&cipher_sync_data), CipherSyncType::Organization, conn).await?); + ciphers_json + .push(c.to_json(host, user_uuid, Some(&cipher_sync_data), CipherSyncType::Organization, conn).await); } - Ok(json!(ciphers_json)) -} - -// Returning a Domain/Organization here allow to prefill it and prevent prompting the user -// So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere -// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier` -#[post("/organizations/domain/sso/verified")] -fn get_org_domain_sso_verified() -> JsonResult { - // Always return a dummy value, no matter if SSO is enabled or not - Ok(Json(json!({ - "object": "list", - "data": [{ - "organizationIdentifier": FAKE_SSO_IDENTIFIER, - // These appear to be unused - "organizationName": FAKE_SSO_IDENTIFIER, - "domainName": CONFIG.domain() - }], - "continuationToken": null - }))) + json!(ciphers_json) } #[derive(FromForm)] @@ -934,47 +789,36 @@ struct GetOrgUserData { } #[get("/organizations//users?")] -async fn get_members( +async fn get_org_users( data: GetOrgUserData, - org_id: OrganizationId, - headers: ManagerHeadersLoose, - conn: DbConn, -) -> JsonResult { - if org_id != headers.membership.org_uuid { - err!("Organization not found", "Organization id's do not match"); - } + org_id: &str, + _headers: ManagerHeadersLoose, + mut conn: DbConn, +) -> Json { let mut users_json = Vec::new(); - for u in Membership::find_by_org(&org_id, &conn).await { + for u in UserOrganization::find_by_org(org_id, &mut conn).await { users_json.push( u.to_json_user_details( data.include_collections.unwrap_or(false), data.include_groups.unwrap_or(false), - &conn, + &mut conn, ) .await, ); } - Ok(Json(json!({ + Json(json!({ "data": users_json, "object": "list", "continuationToken": null, - }))) + })) } #[post("/organizations//keys", data = "")] -async fn post_org_keys( - org_id: OrganizationId, - data: Json, - headers: AdminHeaders, - conn: DbConn, -) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } +async fn post_org_keys(org_id: &str, data: Json, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult { let data: OrgKeyData = data.into_inner(); - let mut org = match Organization::find_by_uuid(&org_id, &conn).await { + let mut org = match Organization::find_by_uuid(org_id, &mut conn).await { Some(organization) => { if organization.private_key.is_some() && organization.public_key.is_some() { err!("Organization Keys already exist") @@ -987,7 +831,7 @@ async fn post_org_keys( org.private_key = Some(data.encrypted_private_key); org.public_key = Some(data.public_key); - org.save(&conn).await?; + org.save(&mut conn).await?; Ok(Json(json!({ "object": "organizationKeys", @@ -996,74 +840,41 @@ async fn post_org_keys( }))) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CollectionData { + id: String, + read_only: bool, + hide_passwords: bool, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct InviteData { emails: Vec, - groups: Vec, + groups: Vec, r#type: NumberOrString, collections: Option>, #[serde(default)] - permissions: HashMap, -} - -impl InviteData { - async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { - 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, 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, org_id)) - } - - Ok(()) - } + access_all: bool, } #[post("/organizations//users/invite", data = "")] -async fn send_invite( - org_id: OrganizationId, - data: Json, - headers: AdminHeaders, - conn: DbConn, -) -> EmptyResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } +async fn send_invite(org_id: &str, data: Json, headers: AdminHeaders, mut conn: DbConn) -> EmptyResult { let data: InviteData = data.into_inner(); - 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 - // The from_str() will convert the custom role type into a manager role type - let raw_type = &data.r#type.into_string(); - // Membership::from_str will convert custom (4) to manager (3) - let new_type = match MembershipType::from_str(raw_type) { + let new_type = match UserOrgType::from_str(&data.r#type.into_string()) { Some(new_type) => new_type as i32, None => err!("Invalid type"), }; - if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner { + if new_type != UserOrgType::User && headers.org_user_type != UserOrgType::Owner { err!("Only Owners can invite Managers, Admins or Owners") } - // 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 - || (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))); - - let mut user_created: bool = false; for email in data.emails.iter() { - let mut member_status = MembershipStatus::Invited as i32; - let user = match User::find_by_mail(email, &conn).await { + let mut user_org_status = UserOrgStatus::Invited as i32; + let user = match User::find_by_mail(email, &mut conn).await { None => { if !CONFIG.invitations_allowed() { err!(format!("User does not exist: {email}")) @@ -1074,74 +885,37 @@ async fn send_invite( } if !CONFIG.mail_enabled() { - Invitation::new(email).save(&conn).await?; + let invitation = Invitation::new(email); + invitation.save(&mut conn).await?; } - let mut new_user = User::new(email, None); - new_user.save(&conn).await?; - user_created = true; - new_user + let mut user = User::new(email.clone()); + user.save(&mut conn).await?; + user } Some(user) => { - if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { + if UserOrganization::find_by_user_and_org(&user.uuid, org_id, &mut conn).await.is_some() { err!(format!("User already in organization: {email}")) } else { // automatically accept existing users if mail is disabled if !CONFIG.mail_enabled() && !user.password_hash.is_empty() { - member_status = MembershipStatus::Accepted as i32; + user_org_status = UserOrgStatus::Accepted as i32; } user } } }; - let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); - new_member.access_all = access_all; - new_member.atype = new_type; - new_member.status = member_status; - new_member.save(&conn).await?; - - if CONFIG.mail_enabled() { - let org_name = match Organization::find_by_uuid(&org_id, &conn).await { - Some(org) => org.name, - None => err!("Error looking up organization"), - }; - - if let Err(e) = mail::send_invite( - &user, - org_id.clone(), - new_member.uuid.clone(), - &org_name, - Some(headers.user.email.clone()), - ) - .await - { - // Upon error delete the user, invite and org member records when needed - if user_created { - user.delete(&conn).await?; - } else { - new_member.delete(&conn).await?; - } - - err!(format!("Error sending invite: {e:?} ")); - } - } - - log_event( - EventType::OrganizationUserInvited as i32, - &new_member.uuid, - &org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; + let mut new_user = UserOrganization::new(user.uuid.clone(), String::from(org_id)); + let access_all = data.access_all; + new_user.access_all = access_all; + new_user.atype = new_type; + new_user.status = user_org_status; // If no accessAll, add the collections received if !access_all { for col in data.collections.iter().flatten() { - match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await { + match Collection::find_by_uuid_and_org(&col.id, org_id, &mut conn).await { None => err!("Collection not found in Organization"), Some(collection) => { CollectionUser::save( @@ -1149,8 +923,7 @@ async fn send_invite( &collection.uuid, col.read_only, col.hide_passwords, - col.manage, - &conn, + &mut conn, ) .await?; } @@ -1158,9 +931,38 @@ async fn send_invite( } } - for group_id in data.groups.iter() { - let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone()); - group_entry.save(&conn).await?; + new_user.save(&mut conn).await?; + + for group in data.groups.iter() { + let mut group_entry = GroupUser::new(String::from(group), user.uuid.clone()); + group_entry.save(&mut conn).await?; + } + + log_event( + EventType::OrganizationUserInvited as i32, + &new_user.uuid, + org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &mut conn, + ) + .await; + + if CONFIG.mail_enabled() { + let org_name = match Organization::find_by_uuid(org_id, &mut conn).await { + Some(org) => org.name, + None => err!("Error looking up organization"), + }; + + mail::send_invite( + &user, + Some(String::from(org_id)), + Some(new_user.uuid), + &org_name, + Some(headers.user.email.clone()), + ) + .await?; } } @@ -1168,20 +970,17 @@ async fn send_invite( } #[post("/organizations//users/reinvite", data = "")] -async fn bulk_reinvite_members( - org_id: OrganizationId, - data: Json, +async fn bulk_reinvite_user( + org_id: &str, + data: Json, headers: AdminHeaders, - conn: DbConn, -) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - let data: BulkMembershipIds = data.into_inner(); + mut conn: DbConn, +) -> Json { + let data: OrgBulkIds = data.into_inner(); let mut bulk_response = Vec::new(); - for member_id in data.ids { - let err_msg = match _reinvite_member(&org_id, &member_id, &headers.user.email, &conn).await { + for org_user_id in data.ids { + let err_msg = match _reinvite_user(org_id, &org_user_id, &headers.user.email, &mut conn).await { Ok(_) => String::new(), Err(e) => format!("{e:?}"), }; @@ -1189,48 +988,37 @@ async fn bulk_reinvite_members( bulk_response.push(json!( { "object": "OrganizationBulkConfirmResponseModel", - "id": member_id, + "id": org_user_id, "error": err_msg } )) } - Ok(Json(json!({ + Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null - }))) + })) } -#[post("/organizations//users//reinvite")] -async fn reinvite_member( - org_id: OrganizationId, - member_id: MembershipId, - headers: AdminHeaders, - conn: DbConn, -) -> EmptyResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - _reinvite_member(&org_id, &member_id, &headers.user.email, &conn).await +#[post("/organizations//users//reinvite")] +async fn reinvite_user(org_id: &str, user_org: &str, headers: AdminHeaders, mut conn: DbConn) -> EmptyResult { + _reinvite_user(org_id, user_org, &headers.user.email, &mut conn).await } -async fn _reinvite_member( - org_id: &OrganizationId, - member_id: &MembershipId, - invited_by_email: &str, - conn: &DbConn, -) -> EmptyResult { - let Some(member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { - err!("The user hasn't been invited to the organization.") +async fn _reinvite_user(org_id: &str, user_org: &str, invited_by_email: &str, conn: &mut DbConn) -> EmptyResult { + let user_org = match UserOrganization::find_by_uuid(user_org, conn).await { + Some(user_org) => user_org, + None => err!("The user hasn't been invited to the organization."), }; - if member.status != MembershipStatus::Invited as i32 { + if user_org.status != UserOrgStatus::Invited as i32 { err!("The user is already accepted or confirmed to the organization") } - let Some(user) = User::find_by_uuid(&member.user_uuid, conn).await else { - err!("User not found.") + let user = match User::find_by_uuid(&user_org.user_uuid, conn).await { + Some(user) => user, + None => err!("User not found."), }; if !CONFIG.invitations_allowed() && user.password_hash.is_empty() { @@ -1243,15 +1031,22 @@ async fn _reinvite_member( }; if CONFIG.mail_enabled() { - mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(invited_by_email.to_string())).await?; + mail::send_invite( + &user, + Some(org_id.to_string()), + Some(user_org.uuid), + &org_name, + Some(invited_by_email.to_string()), + ) + .await?; } else if user.password_hash.is_empty() { let invitation = Invitation::new(&user.email); invitation.save(conn).await?; } else { - Invitation::take(&user.email, conn).await; - let mut member = member; - member.status = MembershipStatus::Accepted as i32; - member.save(conn).await?; + let _ = Invitation::take(&user.email, conn).await; + let mut user_org = user_org; + user_org.status = UserOrgStatus::Accepted as i32; + user_org.save(conn).await?; } Ok(()) @@ -1264,56 +1059,76 @@ struct AcceptData { reset_password_key: Option, } -#[post("/organizations//users//accept", data = "")] -async fn accept_invite( - org_id: OrganizationId, - member_id: MembershipId, - data: Json, - headers: Headers, - conn: DbConn, -) -> EmptyResult { - // The web-vault passes org_id and member_id in the URL, but we are just reading them from the JWT instead +#[post("/organizations//users/<_org_user_id>/accept", data = "")] +async fn accept_invite(org_id: &str, _org_user_id: &str, data: Json, mut conn: DbConn) -> EmptyResult { + // The web-vault passes org_id and org_user_id in the URL, but we are just reading them from the JWT instead let data: AcceptData = data.into_inner(); let claims = decode_invite(&data.token)?; - // Don't allow other users from accepting an invitation. - if !claims.email.eq(&headers.user.email) { - err!("Invitation was issued to a different account", "Claim does not match user_id") + match User::find_by_mail(&claims.email, &mut conn).await { + Some(user) => { + Invitation::take(&claims.email, &mut conn).await; + + if let (Some(user_org), Some(org)) = (&claims.user_org_id, &claims.org_id) { + let mut user_org = match UserOrganization::find_by_uuid_and_org(user_org, org, &mut conn).await { + Some(user_org) => user_org, + None => err!("Error accepting the invitation"), + }; + + if user_org.status != UserOrgStatus::Invited as i32 { + err!("User already accepted the invitation") + } + + let master_password_required = OrgPolicy::org_is_reset_password_auto_enroll(org, &mut conn).await; + if data.reset_password_key.is_none() && master_password_required { + err!("Reset password key is required, but not provided."); + } + + // This check is also done at accept_invite(), _confirm_invite, _activate_user(), edit_user(), admin::update_user_org_type + // It returns different error messages per function. + if user_org.atype < UserOrgType::Admin { + match OrgPolicy::is_user_allowed(&user_org.user_uuid, org_id, false, &mut conn).await { + Ok(_) => {} + Err(OrgPolicyErr::TwoFactorMissing) => { + if CONFIG.email_2fa_auto_fallback() { + two_factor::email::activate_email_2fa(&user, &mut conn).await?; + } else { + err!("You cannot join this organization until you enable two-step login on your user account"); + } + } + Err(OrgPolicyErr::SingleOrgEnforced) => { + err!("You cannot join this organization because you are a member of an organization which forbids it"); + } + } + } + + user_org.status = UserOrgStatus::Accepted as i32; + + if master_password_required { + user_org.reset_password_key = data.reset_password_key; + } + + user_org.save(&mut conn).await?; + } + } + None => err!("Invited user not found"), } - // If a claim org_id does not match the one in from the URI, something is wrong. - if !claims.org_id.eq(&org_id) { - err!("Error accepting the invitation", "Claim does not match the org_id") - } - - // If a claim does not have a member_id or it does not match the one in from the URI, something is wrong. - if !claims.member_id.eq(&member_id) { - err!("Error accepting the invitation", "Claim does not match the member_id") - } - - let member_id = &claims.member_id; - Invitation::take(&claims.email, &conn).await; - - // skip invitation logic when we were invited via the /admin panel - if **member_id != FAKE_ADMIN_UUID { - let Some(mut membership) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &conn).await else { - err!("Error accepting the invitation") + if CONFIG.mail_enabled() { + let mut org_name = CONFIG.invitation_org_name(); + if let Some(org_id) = &claims.org_id { + org_name = match Organization::find_by_uuid(org_id, &mut conn).await { + Some(org) => org.name, + None => err!("Organization not found."), + }; }; - - let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&membership.org_uuid, &conn).await { - true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."), - true => data.reset_password_key, - false => None, - }; - - // In case the user was invited before the mail was saved in db. - membership.invited_by_email = membership.invited_by_email.or(claims.invited_by_email); - - accept_org_invite(&headers.user, membership, reset_password_key, &conn).await?; - } else if CONFIG.mail_enabled() { - // User was invited from /admin, so they are automatically confirmed - let org_name = CONFIG.invitation_org_name(); - mail::send_invite_confirmed(&claims.email, &org_name).await?; + if let Some(invited_by_email) = &claims.invited_by_email { + // User was invited to an organization, so they must be confirmed manually after acceptance + mail::send_invite_accepted(&claims.email, invited_by_email, &org_name).await?; + } else { + // User was invited from /admin, so they are automatically confirmed + mail::send_invite_confirmed(&claims.email, &org_name).await?; + } } Ok(()) @@ -1322,7 +1137,7 @@ async fn accept_invite( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ConfirmData { - id: Option, + id: Option, key: Option, } @@ -1334,24 +1149,21 @@ struct BulkConfirmData { #[post("/organizations//users/confirm", data = "")] async fn bulk_confirm_invite( - org_id: OrganizationId, + org_id: &str, data: Json, headers: AdminHeaders, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, -) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } +) -> Json { let data = data.into_inner(); let mut bulk_response = Vec::new(); match data.keys { Some(keys) => { for invite in keys { - let member_id = invite.id.unwrap(); + let org_user_id = invite.id.unwrap_or_default(); let user_key = invite.key.unwrap_or_default(); - let err_msg = match _confirm_invite(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { + let err_msg = match _confirm_invite(org_id, &org_user_id, &user_key, &headers, &mut conn, &nt).await { Ok(_) => String::new(), Err(e) => format!("{e:?}"), }; @@ -1359,7 +1171,7 @@ async fn bulk_confirm_invite( bulk_response.push(json!( { "object": "OrganizationBulkConfirmResponseModel", - "id": member_id, + "id": org_user_id, "error": err_msg } )); @@ -1368,63 +1180,76 @@ async fn bulk_confirm_invite( None => error!("No keys to confirm"), } - Ok(Json(json!({ + Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null - }))) + })) } -#[post("/organizations//users//confirm", data = "")] +#[post("/organizations//users//confirm", data = "")] async fn confirm_invite( - org_id: OrganizationId, - member_id: MembershipId, + org_id: &str, + org_user_id: &str, data: Json, headers: AdminHeaders, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let data = data.into_inner(); let user_key = data.key.unwrap_or_default(); - _confirm_invite(&org_id, &member_id, &user_key, &headers, &conn, &nt).await + _confirm_invite(org_id, org_user_id, &user_key, &headers, &mut conn, &nt).await } async fn _confirm_invite( - org_id: &OrganizationId, - member_id: &MembershipId, + org_id: &str, + org_user_id: &str, key: &str, headers: &AdminHeaders, - conn: &DbConn, + conn: &mut DbConn, nt: &Notify<'_>, ) -> EmptyResult { - if org_id != &headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - if key.is_empty() || member_id.is_empty() { + if key.is_empty() || org_user_id.is_empty() { err!("Key or UserId is not set, unable to process request"); } - let Some(mut member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { - err!("The specified user isn't a member of the organization") + let mut user_to_confirm = match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, conn).await { + Some(user) => user, + None => err!("The specified user isn't a member of the organization"), }; - if member_to_confirm.atype != MembershipType::User && headers.membership_type != MembershipType::Owner { + if user_to_confirm.atype != UserOrgType::User && headers.org_user_type != UserOrgType::Owner { err!("Only Owners can confirm Managers, Admins or Owners") } - if member_to_confirm.status != MembershipStatus::Accepted as i32 { + if user_to_confirm.status != UserOrgStatus::Accepted as i32 { err!("User in invalid state") } - member_to_confirm.status = MembershipStatus::Confirmed as i32; - member_to_confirm.akey = key.to_string(); + // This check is also done at accept_invite(), _confirm_invite, _activate_user(), edit_user(), admin::update_user_org_type + // It returns different error messages per function. + if user_to_confirm.atype < UserOrgType::Admin { + match OrgPolicy::is_user_allowed(&user_to_confirm.user_uuid, org_id, true, conn).await { + Ok(_) => {} + Err(OrgPolicyErr::TwoFactorMissing) => { + if CONFIG.email_2fa_auto_fallback() { + two_factor::email::find_and_activate_email_2fa(&user_to_confirm.user_uuid, conn).await?; + } else { + err!("You cannot confirm this user because they have not setup 2FA"); + } + } + Err(OrgPolicyErr::SingleOrgEnforced) => { + err!("You cannot confirm this user because they are a member of an organization which forbids it"); + } + } + } - // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type - OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?; + user_to_confirm.status = UserOrgStatus::Confirmed as i32; + user_to_confirm.akey = key.to_string(); log_event( EventType::OrganizationUserConfirmed as i32, - &member_to_confirm.uuid, + &user_to_confirm.uuid, org_id, &headers.user.uuid, headers.device.atype, @@ -1438,58 +1263,41 @@ async fn _confirm_invite( Some(org) => org.name, None => err!("Error looking up organization."), }; - let address = match User::find_by_uuid(&member_to_confirm.user_uuid, conn).await { + let address = match User::find_by_uuid(&user_to_confirm.user_uuid, conn).await { Some(user) => user.email, None => err!("Error looking up user."), }; mail::send_invite_confirmed(&address, &org_name).await?; } - let save_result = member_to_confirm.save(conn).await; + let save_result = user_to_confirm.save(conn).await; - if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await { - nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await; + if let Some(user) = User::find_by_uuid(&user_to_confirm.user_uuid, conn).await { + nt.send_user_update(UpdateType::SyncOrgKeys, &user).await; } save_result } -#[get("/organizations//users/mini-details", rank = 1)] -async fn get_org_user_mini_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { - if org_id != headers.membership.org_uuid { - err!("Organization not found", "Organization id's do not match"); - } - let mut members_json = Vec::new(); - for m in Membership::find_by_org(&org_id, &conn).await { - members_json.push(m.to_json_mini_details(&conn).await); - } - - Ok(Json(json!({ - "data": members_json, - "object": "list", - "continuationToken": null, - }))) -} - -#[get("/organizations//users/?", rank = 2)] +#[get("/organizations//users/?")] async fn get_user( - org_id: OrganizationId, - member_id: MembershipId, + org_id: &str, + org_user_id: &str, data: GetOrgUserData, - headers: AdminHeaders, - conn: DbConn, + _headers: AdminHeaders, + mut conn: DbConn, ) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - let Some(user) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { - err!("The specified user isn't a member of the organization") + let user = match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, &mut conn).await { + Some(user) => user, + None => err!("The specified user isn't a member of the organization"), }; // In this case, when groups are requested we also need to include collections. // Else these will not be shown in the interface, and could lead to missing collections when saved. let include_groups = data.include_groups.unwrap_or(false); - Ok(Json(user.to_json_user_details(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await)) + Ok(Json( + user.to_json_user_details(data.include_collections.unwrap_or(include_groups), include_groups, &mut conn).await, + )) } #[derive(Deserialize)] @@ -1497,102 +1305,101 @@ async fn get_user( struct EditUserData { r#type: NumberOrString, collections: Option>, - groups: Option>, + groups: Option>, #[serde(default)] - permissions: HashMap, + access_all: bool, } -#[put("/organizations//users/", data = "", rank = 1)] -async fn put_member( - org_id: OrganizationId, - member_id: MembershipId, +#[put("/organizations//users/", data = "", rank = 1)] +async fn put_organization_user( + org_id: &str, + org_user_id: &str, data: Json, headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { - edit_member(org_id, member_id, data, headers, conn).await + edit_user(org_id, org_user_id, data, headers, conn).await } -#[post("/organizations//users/", data = "", rank = 1)] -async fn edit_member( - org_id: OrganizationId, - member_id: MembershipId, +#[post("/organizations//users/", data = "", rank = 1)] +async fn edit_user( + org_id: &str, + org_user_id: &str, data: Json, headers: AdminHeaders, - conn: DbConn, + mut conn: DbConn, ) -> EmptyResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } 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 - // The from_str() will convert the custom role type into a manager role type - let raw_type = &data.r#type.into_string(); - // MembershipType::from_str will convert custom (4) to manager (3) - let Some(new_type) = MembershipType::from_str(raw_type) else { - err!("Invalid type") + let new_type = match UserOrgType::from_str(&data.r#type.into_string()) { + Some(new_type) => new_type, + None => err!("Invalid type"), }; - // 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 - || (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))); - - 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 mut user_to_edit = match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, &mut conn).await { + Some(user) => user, + None => err!("The specified user isn't member of the organization"), }; - if new_type != member_to_edit.atype - && (member_to_edit.atype >= MembershipType::Admin || new_type >= MembershipType::Admin) - && headers.membership_type != MembershipType::Owner + if new_type != user_to_edit.atype + && (user_to_edit.atype >= UserOrgType::Admin || new_type >= UserOrgType::Admin) + && headers.org_user_type != UserOrgType::Owner { err!("Only Owners can grant and remove Admin or Owner privileges") } - if member_to_edit.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { + if user_to_edit.atype == UserOrgType::Owner && headers.org_user_type != UserOrgType::Owner { err!("Only Owners can edit Owner users") } - if member_to_edit.atype == MembershipType::Owner - && new_type != MembershipType::Owner - && member_to_edit.status == MembershipStatus::Confirmed as i32 + if user_to_edit.atype == UserOrgType::Owner + && new_type != UserOrgType::Owner + && user_to_edit.status == UserOrgStatus::Confirmed as i32 { // Removing owner permission, 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 { + if UserOrganization::count_confirmed_by_org_and_type(org_id, UserOrgType::Owner, &mut conn).await <= 1 { err!("Can't delete the last owner") } } - member_to_edit.access_all = access_all; - member_to_edit.atype = new_type as i32; + // This check is also done at accept_invite(), _confirm_invite, _activate_user(), edit_user(), admin::update_user_org_type + // It returns different error messages per function. + if new_type < UserOrgType::Admin { + match OrgPolicy::is_user_allowed(&user_to_edit.user_uuid, org_id, true, &mut conn).await { + Ok(_) => {} + Err(OrgPolicyErr::TwoFactorMissing) => { + if CONFIG.email_2fa_auto_fallback() { + two_factor::email::find_and_activate_email_2fa(&user_to_edit.user_uuid, &mut conn).await?; + } else { + err!("You cannot modify this user to this type because they have not setup 2FA"); + } + } + Err(OrgPolicyErr::SingleOrgEnforced) => { + err!("You cannot modify this user to this type because they are a member of an organization which forbids it"); + } + } + } - // 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. - OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; + user_to_edit.access_all = data.access_all; + user_to_edit.atype = new_type as i32; // Delete all the odd collections - for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await { - c.delete(&conn).await?; + for c in CollectionUser::find_by_organization_and_user_uuid(org_id, &user_to_edit.user_uuid, &mut conn).await { + c.delete(&mut conn).await?; } // If no accessAll, add the collections received - if !access_all { + if !data.access_all { for col in data.collections.iter().flatten() { - match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await { + match Collection::find_by_uuid_and_org(&col.id, org_id, &mut conn).await { None => err!("Collection not found in Organization"), Some(collection) => { CollectionUser::save( - &member_to_edit.user_uuid, + &user_to_edit.user_uuid, &collection.uuid, col.read_only, col.hide_passwords, - col.manage, - &conn, + &mut conn, ) .await?; } @@ -1600,43 +1407,40 @@ async fn edit_member( } } - GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?; + GroupUser::delete_all_by_user(&user_to_edit.uuid, &mut conn).await?; - for group_id in data.groups.iter().flatten() { - let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone()); - group_entry.save(&conn).await?; + for group in data.groups.iter().flatten() { + let mut group_entry = GroupUser::new(String::from(group), user_to_edit.uuid.clone()); + group_entry.save(&mut conn).await?; } log_event( EventType::OrganizationUserUpdated as i32, - &member_to_edit.uuid, - &org_id, + &user_to_edit.uuid, + org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await; - member_to_edit.save(&conn).await + user_to_edit.save(&mut conn).await } #[delete("/organizations//users", data = "")] -async fn bulk_delete_member( - org_id: OrganizationId, - data: Json, +async fn bulk_delete_user( + org_id: &str, + data: Json, headers: AdminHeaders, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, -) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - let data: BulkMembershipIds = data.into_inner(); +) -> Json { + let data: OrgBulkIds = data.into_inner(); let mut bulk_response = Vec::new(); - for member_id in data.ids { - let err_msg = match _delete_member(&org_id, &member_id, &headers, &conn, &nt).await { + for org_user_id in data.ids { + let err_msg = match _delete_user(org_id, &org_user_id, &headers, &mut conn, &nt).await { Ok(_) => String::new(), Err(e) => format!("{e:?}"), }; @@ -1644,59 +1448,67 @@ async fn bulk_delete_member( bulk_response.push(json!( { "object": "OrganizationBulkConfirmResponseModel", - "id": member_id, + "id": org_user_id, "error": err_msg } )) } - Ok(Json(json!({ + Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null - }))) + })) } -#[delete("/organizations//users/")] -async fn delete_member( - org_id: OrganizationId, - member_id: MembershipId, +#[delete("/organizations//users/")] +async fn delete_user( + org_id: &str, + org_user_id: &str, headers: AdminHeaders, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - _delete_member(&org_id, &member_id, &headers, &conn, &nt).await + _delete_user(org_id, org_user_id, &headers, &mut conn, &nt).await } -async fn _delete_member( - org_id: &OrganizationId, - member_id: &MembershipId, +#[post("/organizations//users//delete")] +async fn post_delete_user( + org_id: &str, + org_user_id: &str, + headers: AdminHeaders, + mut conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + _delete_user(org_id, org_user_id, &headers, &mut conn, &nt).await +} + +async fn _delete_user( + org_id: &str, + org_user_id: &str, headers: &AdminHeaders, - conn: &DbConn, + conn: &mut DbConn, nt: &Notify<'_>, ) -> EmptyResult { - if org_id != &headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - let Some(member_to_delete) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { - err!("User to delete isn't member of the organization") + let user_to_delete = match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, conn).await { + Some(user) => user, + None => err!("User to delete isn't member of the organization"), }; - if member_to_delete.atype != MembershipType::User && headers.membership_type != MembershipType::Owner { + if user_to_delete.atype != UserOrgType::User && headers.org_user_type != UserOrgType::Owner { err!("Only Owners can delete Admins or Owners") } - if member_to_delete.atype == MembershipType::Owner && member_to_delete.status == MembershipStatus::Confirmed as i32 - { + if user_to_delete.atype == UserOrgType::Owner && user_to_delete.status == UserOrgStatus::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 { + if UserOrganization::count_confirmed_by_org_and_type(org_id, UserOrgType::Owner, conn).await <= 1 { err!("Can't delete the last owner") } } log_event( EventType::OrganizationUserRemoved as i32, - &member_to_delete.uuid, + &user_to_delete.uuid, org_id, &headers.user.uuid, headers.device.atype, @@ -1705,51 +1517,48 @@ async fn _delete_member( ) .await; - if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await { - nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await; + if let Some(user) = User::find_by_uuid(&user_to_delete.user_uuid, conn).await { + nt.send_user_update(UpdateType::SyncOrgKeys, &user).await; } - member_to_delete.delete(conn).await + user_to_delete.delete(conn).await } #[post("/organizations//users/public-keys", data = "")] async fn bulk_public_keys( - org_id: OrganizationId, - data: Json, - headers: AdminHeaders, - conn: DbConn, -) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - let data: BulkMembershipIds = data.into_inner(); + org_id: &str, + data: Json, + _headers: AdminHeaders, + mut conn: DbConn, +) -> Json { + let data: OrgBulkIds = data.into_inner(); let mut bulk_response = Vec::new(); - // Check all received Membership UUID's and find the matching User to retrieve the public-key. - // If the user does not exists, just ignore it, and do not return any information regarding that Membership UUID. + // Check all received UserOrg UUID's and find the matching User to retrieve the public-key. + // If the user does not exists, just ignore it, and do not return any information regarding that UserOrg UUID. // The web-vault will then ignore that user for the following steps. - for member_id in data.ids { - match Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await { - Some(member) => match User::find_by_uuid(&member.user_uuid, &conn).await { + for user_org_id in data.ids { + match UserOrganization::find_by_uuid_and_org(&user_org_id, org_id, &mut conn).await { + Some(user_org) => match User::find_by_uuid(&user_org.user_uuid, &mut conn).await { Some(user) => bulk_response.push(json!( { "object": "organizationUserPublicKeyResponseModel", - "id": member_id, + "id": user_org_id, "userId": user.uuid, "key": user.public_key } )), None => debug!("User doesn't exist"), }, - None => debug!("Membership doesn't exist"), + None => debug!("UserOrg doesn't exist"), } } - Ok(Json(json!({ + Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null - }))) + })) } use super::ciphers::update_cipher_from_data; @@ -1759,7 +1568,7 @@ use super::ciphers::CipherData; #[serde(rename_all = "camelCase")] struct ImportData { ciphers: Vec, - collections: Vec, + collections: Vec, collection_relationships: Vec, } @@ -1772,20 +1581,16 @@ struct RelationsData { value: usize, } -// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/ImportCiphersController.cs#L62 #[post("/ciphers/import-organization?", data = "")] async fn post_org_import( query: OrgIdData, data: Json, - headers: OrgMemberHeaders, - conn: DbConn, + headers: AdminHeaders, + mut conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - let org_id = query.organization_id; - if org_id != headers.membership.org_uuid { - err!("Organization not found", "Organization id's do not match"); - } let data: ImportData = data.into_inner(); + let org_id = query.organization_id; // Validate the import before continuing // Bitwarden does not process the import if there is one item invalid. @@ -1793,95 +1598,73 @@ async fn post_org_import( // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. Cipher::validate_cipher_data(&data.ciphers)?; - let existing_collections: HashSet> = - Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| Some(c.uuid)).collect(); - let mut collections: Vec = Vec::with_capacity(data.collections.len()); - for col in data.collections { - let collection_uuid = if existing_collections.contains(&col.id) { - let col_id = col.id.unwrap(); - // When not an Owner or Admin, check if the member is allowed to access the collection. - if headers.membership.atype < MembershipType::Admin - && !Collection::can_access_collection(&headers.membership, &col_id, &conn).await - { - err!(Compact, "The current user isn't allowed to manage this collection") - } - col_id + let mut collections = Vec::new(); + for coll in data.collections { + let collection = Collection::new(org_id.clone(), coll.name, coll.external_id); + if collection.save(&mut conn).await.is_err() { + collections.push(Err(Error::new("Failed to create Collection", "Failed to create Collection"))); } else { - // We do not allow users or managers which can not manage all collections to create new collections - // If there is any collection other than an existing import collection, abort the import. - if headers.membership.atype <= MembershipType::Manager && !headers.membership.has_full_access() { - err!(Compact, "The current user isn't allowed to create new collections") - } - let new_collection = Collection::new(org_id.clone(), col.name, col.external_id); - new_collection.save(&conn).await?; - new_collection.uuid - }; - - collections.push(collection_uuid); + collections.push(Ok(collection)); + } } // Read the relations between collections and ciphers - // Ciphers can be in multiple collections at the same time - let mut relations = Vec::with_capacity(data.collection_relationships.len()); + let mut relations = Vec::new(); for relation in data.collection_relationships { relations.push((relation.key, relation.value)); } let headers: Headers = headers.into(); - let mut ciphers: Vec = Vec::with_capacity(data.ciphers.len()); - for mut cipher_data in data.ciphers { - // Always clear folder_id's via an organization import - cipher_data.folder_id = None; + let mut ciphers = Vec::new(); + for cipher_data in data.ciphers { let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); - update_cipher_from_data( - &mut cipher, - cipher_data, - &headers, - Some(collections.clone()), - &conn, - &nt, - UpdateType::None, - ) - .await - .ok(); - ciphers.push(cipher.uuid); + update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &mut conn, &nt, UpdateType::None).await.ok(); + ciphers.push(cipher); } // Assign the collections - for (cipher_index, col_index) in relations { - let cipher_id = &ciphers[cipher_index]; - let col_id = &collections[col_index]; - CollectionCipher::save(cipher_id, col_id, &conn).await?; + for (cipher_index, coll_index) in relations { + let cipher_id = &ciphers[cipher_index].uuid; + let coll = &collections[coll_index]; + let coll_id = match coll { + Ok(coll) => coll.uuid.as_str(), + Err(_) => err!("Failed to assign to collection"), + }; + + CollectionCipher::save(cipher_id, coll_id, &mut conn).await?; } let mut user = headers.user; - user.update_revision(&conn).await + user.update_revision(&mut conn).await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] +#[allow(dead_code)] struct BulkCollectionsData { - organization_id: OrganizationId, - cipher_ids: Vec, - collection_ids: HashSet, + organization_id: String, + cipher_ids: Vec, + collection_ids: HashSet, remove_collections: bool, } -// This endpoint is only reachable via the organization view, therefore this endpoint is located here +// This endpoint is only reachable via the organization view, therefor this endpoint is located here // Also Bitwarden does not send out Notifications for these changes, it only does this for individual cipher collection updates #[post("/ciphers/bulk-collections", data = "")] -async fn post_bulk_collections(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +async fn post_bulk_collections(data: Json, headers: Headers, mut conn: DbConn) -> EmptyResult { let data: BulkCollectionsData = data.into_inner(); - if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() { - err!("You need to be a Member of the Organization to call this endpoint") + // This feature does not seem to be active on all the clients + // To prevent future issues, add a check to block a call when this is set to true + if data.remove_collections { + err!("Bulk removing of collections is not yet implemented") } // Get all the collection available to the user in one query // Also filter based upon the provided collections - let user_collections: HashMap = - Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn) + let user_collections: HashMap = + Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &mut conn) .await .into_iter() .filter_map(|c| { @@ -1893,10 +1676,10 @@ async fn post_bulk_collections(data: Json, headers: Headers }) .collect(); - // Verify if all the collections requested exists and are writable for the user, else abort + // Verify if all the collections requested exists and are writeable for the user, else abort for collection_uuid in &data.collection_ids { match user_collections.get(collection_uuid) { - Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &conn).await => (), + Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &mut conn).await => (), _ => err_code!("Resource not found", "User does not have access to a collection", 404), } } @@ -1904,18 +1687,10 @@ async fn post_bulk_collections(data: Json, headers: Headers for cipher_id in data.cipher_ids.iter() { // Only act on existing cipher uuid's // Do not abort the operation just ignore it, it could be a cipher was just deleted for example - if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await { - if cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { - // When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection - // In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting. - if data.remove_collections { - for collection in &data.collection_ids { - CollectionCipher::delete(&cipher.uuid, collection, &conn).await?; - } - } else { - for collection in &data.collection_ids { - CollectionCipher::save(&cipher.uuid, collection, &conn).await?; - } + if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &mut conn).await { + if cipher.is_write_accessible_to_user(&headers.user.uuid, &mut conn).await { + for collection in &data.collection_ids { + CollectionCipher::save(&cipher.uuid, collection, &mut conn).await?; } } }; @@ -1925,35 +1700,39 @@ async fn post_bulk_collections(data: Json, headers: Headers } #[get("/organizations//policies")] -async fn list_policies(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - let policies = OrgPolicy::find_by_org(&org_id, &conn).await; +async fn list_policies(org_id: &str, _headers: AdminHeaders, mut conn: DbConn) -> Json { + let policies = OrgPolicy::find_by_org(org_id, &mut conn).await; let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); - Ok(Json(json!({ + Json(json!({ "data": policies_json, "object": "list", "continuationToken": null - }))) + })) } #[get("/organizations//policies/token?")] -async fn list_policies_token(org_id: OrganizationId, token: &str, conn: DbConn) -> JsonResult { - let invite = decode_invite(token)?; - - if invite.org_id != org_id { - err!("Token doesn't match request organization"); - } - - // exit early when we have been invited via /admin panel - if org_id.as_ref() == FAKE_ADMIN_UUID { +async fn list_policies_token(org_id: &str, token: &str, mut conn: DbConn) -> JsonResult { + // web-vault 2024.6.2 seems to send these values and cause logs to output errors + // Catch this and prevent errors in the logs + // TODO: CleanUp after 2024.6.x is not used anymore. + if org_id == "undefined" && token == "undefined" { return Ok(Json(json!({}))); } + let invite = decode_invite(token)?; + + let invite_org_id = match invite.org_id { + Some(invite_org_id) => invite_org_id, + None => err!("Invalid token"), + }; + + if invite_org_id != org_id { + err!("Token doesn't match request organization"); + } + // TODO: We receive the invite token as ?token=<>, validate it contains the org id - let policies = OrgPolicy::find_by_org(&org_id, &conn).await; + let policies = OrgPolicy::find_by_org(org_id, &mut conn).await; let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); Ok(Json(json!({ @@ -1963,46 +1742,16 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, conn: DbConn) }))) } -// Called during the SSO enrollment return the default policy -#[get("/organizations/00000000-01DC-01DC-01DC-000000000000/policies/master-password", rank = 1)] -fn get_dummy_master_password_policy() -> JsonResult { - let (enabled, data) = match CONFIG.sso_master_password_policy_value() { - Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()), - _ => (false, "null".to_string()), - }; - let policy = OrgPolicy::new(FAKE_SSO_IDENTIFIER.into(), OrgPolicyType::MasterPassword, enabled, data); - Ok(Json(policy.to_json())) -} - -// Called during the SSO enrollment return the org policy if it exists -#[get("/organizations//policies/master-password", rank = 2)] -async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { - let policy = - OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &conn).await.unwrap_or_else(|| { - let (enabled, data) = match CONFIG.sso_master_password_policy_value() { - Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()), - _ => (false, "null".to_string()), - }; - - OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, enabled, data) - }); - - Ok(Json(policy.to_json())) -} - -#[get("/organizations//policies/", rank = 3)] -async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, conn: DbConn) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - - let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { - err!("Invalid or unsupported policy type") +#[get("/organizations//policies/")] +async fn get_policy(org_id: &str, pol_type: i32, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult { + let pol_type_enum = match OrgPolicyType::from_i32(pol_type) { + Some(pt) => pt, + None => err!("Invalid or unsupported policy type"), }; - let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { + let policy = match OrgPolicy::find_by_org_and_type(org_id, pol_type_enum, &mut conn).await { Some(p) => p, - None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "null".to_string()), + None => OrgPolicy::new(String::from(org_id), pol_type_enum, "null".to_string()), }; Ok(Json(policy.to_json())) @@ -2011,24 +1760,24 @@ async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders #[derive(Deserialize)] struct PolicyData { enabled: bool, + #[serde(rename = "type")] + _type: i32, data: Option, } #[put("/organizations//policies/", data = "")] async fn put_policy( - org_id: OrganizationId, + org_id: &str, pol_type: i32, data: Json, headers: AdminHeaders, - conn: DbConn, + mut conn: DbConn, ) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } let data: PolicyData = data.into_inner(); - let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { - err!("Invalid or unsupported policy type") + let pol_type_enum = match OrgPolicyType::from_i32(pol_type) { + Some(pt) => pt, + None => err!("Invalid or unsupported policy type"), }; // Bitwarden only allows the Reset Password policy when Single Org policy is enabled @@ -2039,7 +1788,7 @@ async fn put_policy( if CONFIG.enforce_single_org_with_reset_pw_policy() { if pol_type_enum == OrgPolicyType::ResetPassword && data.enabled { let single_org_policy_enabled = - match OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await { + match OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::SingleOrg, &mut conn).await { Some(p) => p.enabled, None => false, }; @@ -2052,7 +1801,7 @@ async fn put_policy( // Also prevent the Single Org Policy to be disabled if the Reset Password policy is enabled if pol_type_enum == OrgPolicyType::SingleOrg && !data.enabled { let reset_pw_policy_enabled = - match OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::ResetPassword, &conn).await { + match OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::ResetPassword, &mut conn).await { Some(p) => p.enabled, None => false, }; @@ -2066,29 +1815,29 @@ async fn put_policy( // 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( - &org_id, + org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await?; } // When enabling the SingleOrg policy, remove this org's members that are members of other orgs if pol_type_enum == OrgPolicyType::SingleOrg && data.enabled { - for mut member in Membership::find_by_org(&org_id, &conn).await.into_iter() { + for member in UserOrganization::find_by_org(org_id, &mut conn).await.into_iter() { // Policy only applies to non-Owner/non-Admin members who have accepted joining the org // Exclude invited and revoked users when checking for this policy. // Those users will not be allowed to accept or be activated because of the policy checks done there. - if member.atype < MembershipType::Admin - && member.status != MembershipStatus::Invited as i32 - && Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &member.org_uuid, &conn).await - > 0 + // We check if the count is larger then 1, because it includes this organization also. + if member.atype < UserOrgType::Admin + && member.status != UserOrgStatus::Invited as i32 + && UserOrganization::count_accepted_and_confirmed_by_user(&member.user_uuid, &mut conn).await > 1 { if CONFIG.mail_enabled() { - let org = Organization::find_by_uuid(&member.org_uuid, &conn).await.unwrap(); - let user = User::find_by_uuid(&member.user_uuid, &conn).await.unwrap(); + let org = Organization::find_by_uuid(&member.org_uuid, &mut conn).await.unwrap(); + let user = User::find_by_uuid(&member.user_uuid, &mut conn).await.unwrap(); mail::send_single_org_removed_from_org(&user.email, &org.name).await?; } @@ -2096,63 +1845,50 @@ async fn put_policy( log_event( EventType::OrganizationUserRemoved as i32, &member.uuid, - &org_id, + org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await; - member.revoke(); - member.save(&conn).await?; + member.delete(&mut conn).await?; } } } - let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { + let mut policy = match OrgPolicy::find_by_org_and_type(org_id, pol_type_enum, &mut conn).await { Some(p) => p, - None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_string()), + None => OrgPolicy::new(String::from(org_id), pol_type_enum, "{}".to_string()), }; policy.enabled = data.enabled; policy.data = serde_json::to_string(&data.data)?; - policy.save(&conn).await?; + policy.save(&mut conn).await?; log_event( EventType::PolicyUpdated as i32, - policy.uuid.as_ref(), - &org_id, + &policy.uuid, + org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await; Ok(Json(policy.to_json())) } -#[derive(Deserialize)] -struct PolicyDataVnext { - policy: PolicyData, - // Ignore metadata for now as we do not yet support this - // "metadata": { - // "defaultUserCollectionName": "2.xx|xx==|xx=" - // } -} - -#[put("/organizations//policies//vnext", data = "")] -async fn put_policy_vnext( - org_id: OrganizationId, - pol_type: i32, - data: Json, - headers: AdminHeaders, - conn: DbConn, -) -> JsonResult { - let data: PolicyDataVnext = data.into_inner(); - let policy: PolicyData = data.policy; - put_policy(org_id, pol_type, Json(policy), headers, conn).await +#[allow(unused_variables)] +#[get("/organizations//tax")] +fn get_organization_tax(org_id: &str, _headers: Headers) -> Json { + // Prevent a 404 error, which also causes Javascript errors. + // Upstream sends "Only allowed when not self hosted." As an error message. + // If we do the same it will also output this to the log, which is overkill. + // An empty list/data also works fine. + Json(_empty_data_json()) } #[get("/plans")] @@ -2183,31 +1919,17 @@ fn get_plans() -> Json { })) } -#[get("/organizations/<_org_id>/billing/metadata")] -fn get_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json { +#[get("/plans/all")] +fn get_plans_all() -> Json { + get_plans() +} + +#[get("/plans/sales-tax-rates")] +fn get_plans_tax_rates(_headers: Headers) -> 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!({ - "freeTrial":null, - "inactiveSubscription":null, - "resellerRenewal":null, - "taxId":null, - })) -} - -#[get("/organizations/<_org_id>/billing/vnext/self-host/metadata")] -fn get_self_host_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json { - // Prevent a 404 error, which also causes Javascript errors. - Json(json!({ - "isOnSecretsManagerStandalone": false, // Secrets Manager is not supported by Vaultwarden - "organizationOccupiedSeats": 0 // Vaultwarden does not count seats - })) -} - fn _empty_data_json() -> Value { json!({ "object": "list", @@ -2218,37 +1940,190 @@ fn _empty_data_json() -> Value { #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] -struct BulkRevokeMembershipIds { - ids: Option>, +struct OrgImportGroupData { + #[allow(dead_code)] + name: String, // "GroupName" + #[allow(dead_code)] + external_id: String, // "cn=GroupName,ou=Groups,dc=example,dc=com" + #[allow(dead_code)] + users: Vec, // ["uid=user,ou=People,dc=example,dc=com"] } -#[put("/organizations//users//revoke")] -async fn revoke_member( - org_id: OrganizationId, - member_id: MembershipId, +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct OrgImportUserData { + email: String, // "user@maildomain.net" + #[allow(dead_code)] + external_id: String, // "uid=user,ou=People,dc=example,dc=com" + deleted: bool, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct OrgImportData { + #[allow(dead_code)] + groups: Vec, + overwrite_existing: bool, + users: Vec, +} + +#[post("/organizations//import", data = "")] +async fn import(org_id: &str, data: Json, headers: Headers, mut conn: DbConn) -> EmptyResult { + let data = data.into_inner(); + + // TODO: Currently we aren't storing the externalId's anywhere, so we also don't have a way + // to differentiate between auto-imported users and manually added ones. + // This means that this endpoint can end up removing users that were added manually by an admin, + // as opposed to upstream which only removes auto-imported users. + + // User needs to be admin or owner to use the Directory Connector + match UserOrganization::find_by_user_and_org(&headers.user.uuid, org_id, &mut conn).await { + Some(user_org) if user_org.atype >= UserOrgType::Admin => { /* Okay, nothing to do */ } + Some(_) => err!("User has insufficient permissions to use Directory Connector"), + None => err!("User not part of organization"), + }; + + for user_data in &data.users { + if user_data.deleted { + // If user is marked for deletion and it exists, delete it + if let Some(user_org) = UserOrganization::find_by_email_and_org(&user_data.email, org_id, &mut conn).await { + log_event( + EventType::OrganizationUserRemoved as i32, + &user_org.uuid, + org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &mut conn, + ) + .await; + + user_org.delete(&mut conn).await?; + } + + // If user is not part of the organization, but it exists + } else if UserOrganization::find_by_email_and_org(&user_data.email, org_id, &mut conn).await.is_none() { + if let Some(user) = User::find_by_mail(&user_data.email, &mut conn).await { + let user_org_status = if CONFIG.mail_enabled() { + UserOrgStatus::Invited as i32 + } else { + UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites + }; + + let mut new_org_user = UserOrganization::new(user.uuid.clone(), String::from(org_id)); + new_org_user.access_all = false; + new_org_user.atype = UserOrgType::User as i32; + new_org_user.status = user_org_status; + + new_org_user.save(&mut conn).await?; + + log_event( + EventType::OrganizationUserInvited as i32, + &new_org_user.uuid, + org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &mut conn, + ) + .await; + + if CONFIG.mail_enabled() { + let org_name = match Organization::find_by_uuid(org_id, &mut conn).await { + Some(org) => org.name, + None => err!("Error looking up organization"), + }; + + mail::send_invite( + &user, + Some(String::from(org_id)), + Some(new_org_user.uuid), + &org_name, + Some(headers.user.email.clone()), + ) + .await?; + } + } + } + } + + // If this flag is enabled, any user that isn't provided in the Users list will be removed (by default they will be kept unless they have Deleted == true) + if data.overwrite_existing { + for user_org in UserOrganization::find_by_org_and_type(org_id, UserOrgType::User, &mut conn).await { + if let Some(user_email) = User::find_by_uuid(&user_org.user_uuid, &mut conn).await.map(|u| u.email) { + if !data.users.iter().any(|u| u.email == user_email) { + log_event( + EventType::OrganizationUserRemoved as i32, + &user_org.uuid, + org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &mut conn, + ) + .await; + + user_org.delete(&mut conn).await?; + } + } + } + } + + Ok(()) +} + +// Pre web-vault v2022.9.x endpoint +#[put("/organizations//users//deactivate")] +async fn deactivate_organization_user( + org_id: &str, + org_user_id: &str, + headers: AdminHeaders, + mut conn: DbConn, +) -> EmptyResult { + _revoke_organization_user(org_id, org_user_id, &headers, &mut conn).await +} + +// Pre web-vault v2022.9.x endpoint +#[put("/organizations//users/deactivate", data = "")] +async fn bulk_deactivate_organization_user( + org_id: &str, + data: Json, headers: AdminHeaders, conn: DbConn, +) -> Json { + bulk_revoke_organization_user(org_id, data, headers, conn).await +} + +#[put("/organizations//users//revoke")] +async fn revoke_organization_user( + org_id: &str, + org_user_id: &str, + headers: AdminHeaders, + mut conn: DbConn, ) -> EmptyResult { - _revoke_member(&org_id, &member_id, &headers, &conn).await + _revoke_organization_user(org_id, org_user_id, &headers, &mut conn).await +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct OrgBulkRevokeData { + ids: Option>, } #[put("/organizations//users/revoke", data = "")] -async fn bulk_revoke_members( - org_id: OrganizationId, - data: Json, +async fn bulk_revoke_organization_user( + org_id: &str, + data: Json, headers: AdminHeaders, - conn: DbConn, -) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } + mut conn: DbConn, +) -> Json { let data = data.into_inner(); let mut bulk_response = Vec::new(); match data.ids { - Some(members) => { - for member_id in members { - let err_msg = match _revoke_member(&org_id, &member_id, &headers, &conn).await { + Some(org_users) => { + for org_user_id in org_users { + let err_msg = match _revoke_organization_user(org_id, &org_user_id, &headers, &mut conn).await { Ok(_) => String::new(), Err(e) => format!("{e:?}"), }; @@ -2256,7 +2131,7 @@ async fn bulk_revoke_members( bulk_response.push(json!( { "object": "OrganizationUserBulkResponseModel", - "id": member_id, + "id": org_user_id, "error": err_msg } )); @@ -2265,42 +2140,39 @@ async fn bulk_revoke_members( None => error!("No users to revoke"), } - Ok(Json(json!({ + Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null - }))) + })) } -async fn _revoke_member( - org_id: &OrganizationId, - member_id: &MembershipId, +async fn _revoke_organization_user( + org_id: &str, + org_user_id: &str, headers: &AdminHeaders, - conn: &DbConn, + conn: &mut DbConn, ) -> EmptyResult { - if org_id != &headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - match Membership::find_by_uuid_and_org(member_id, org_id, conn).await { - Some(mut member) if member.status > MembershipStatus::Revoked as i32 => { - if member.user_uuid == headers.user.uuid { + match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, conn).await { + Some(mut user_org) if user_org.status > UserOrgStatus::Revoked as i32 => { + if user_org.user_uuid == headers.user.uuid { err!("You cannot revoke yourself") } - if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { + if user_org.atype == UserOrgType::Owner && headers.org_user_type != UserOrgType::Owner { err!("Only owners can revoke other owners") } - if member.atype == MembershipType::Owner - && Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1 + if user_org.atype == UserOrgType::Owner + && UserOrganization::count_confirmed_by_org_and_type(org_id, UserOrgType::Owner, conn).await <= 1 { err!("Organization must have at least one confirmed owner") } - member.revoke(); - member.save(conn).await?; + user_org.revoke(); + user_org.save(conn).await?; log_event( EventType::OrganizationUserRevoked as i32, - &member.uuid, + &user_org.uuid, org_id, &headers.user.uuid, headers.device.atype, @@ -2315,31 +2187,50 @@ async fn _revoke_member( Ok(()) } -#[put("/organizations//users//restore")] -async fn restore_member( - org_id: OrganizationId, - member_id: MembershipId, +// Pre web-vault v2022.9.x endpoint +#[put("/organizations//users//activate")] +async fn activate_organization_user( + org_id: &str, + org_user_id: &str, + headers: AdminHeaders, + mut conn: DbConn, +) -> EmptyResult { + _restore_organization_user(org_id, org_user_id, &headers, &mut conn).await +} + +// Pre web-vault v2022.9.x endpoint +#[put("/organizations//users/activate", data = "")] +async fn bulk_activate_organization_user( + org_id: &str, + data: Json, headers: AdminHeaders, conn: DbConn, +) -> Json { + bulk_restore_organization_user(org_id, data, headers, conn).await +} + +#[put("/organizations//users//restore")] +async fn restore_organization_user( + org_id: &str, + org_user_id: &str, + headers: AdminHeaders, + mut conn: DbConn, ) -> EmptyResult { - _restore_member(&org_id, &member_id, &headers, &conn).await + _restore_organization_user(org_id, org_user_id, &headers, &mut conn).await } #[put("/organizations//users/restore", data = "")] -async fn bulk_restore_members( - org_id: OrganizationId, - data: Json, +async fn bulk_restore_organization_user( + org_id: &str, + data: Json, headers: AdminHeaders, - conn: DbConn, -) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } + mut conn: DbConn, +) -> Json { let data = data.into_inner(); let mut bulk_response = Vec::new(); - for member_id in data.ids { - let err_msg = match _restore_member(&org_id, &member_id, &headers, &conn).await { + for org_user_id in data.ids { + let err_msg = match _restore_organization_user(org_id, &org_user_id, &headers, &mut conn).await { Ok(_) => String::new(), Err(e) => format!("{e:?}"), }; @@ -2347,46 +2238,58 @@ async fn bulk_restore_members( bulk_response.push(json!( { "object": "OrganizationUserBulkResponseModel", - "id": member_id, + "id": org_user_id, "error": err_msg } )); } - Ok(Json(json!({ + Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null - }))) + })) } -async fn _restore_member( - org_id: &OrganizationId, - member_id: &MembershipId, +async fn _restore_organization_user( + org_id: &str, + org_user_id: &str, headers: &AdminHeaders, - conn: &DbConn, + conn: &mut DbConn, ) -> EmptyResult { - if org_id != &headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - match Membership::find_by_uuid_and_org(member_id, org_id, conn).await { - Some(mut member) if member.status < MembershipStatus::Accepted as i32 => { - if member.user_uuid == headers.user.uuid { + match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, conn).await { + Some(mut user_org) if user_org.status < UserOrgStatus::Accepted as i32 => { + if user_org.user_uuid == headers.user.uuid { err!("You cannot restore yourself") } - if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { + if user_org.atype == UserOrgType::Owner && headers.org_user_type != UserOrgType::Owner { err!("Only owners can restore other owners") } - member.restore(); - // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type - // This check need to be done after restoring to work with the correct status - OrgPolicy::check_user_allowed(&member, "restore", conn).await?; - member.save(conn).await?; + // This check is also done at accept_invite(), _confirm_invite, _activate_user(), edit_user(), admin::update_user_org_type + // It returns different error messages per function. + if user_org.atype < UserOrgType::Admin { + match OrgPolicy::is_user_allowed(&user_org.user_uuid, org_id, false, conn).await { + Ok(_) => {} + Err(OrgPolicyErr::TwoFactorMissing) => { + if CONFIG.email_2fa_auto_fallback() { + two_factor::email::find_and_activate_email_2fa(&user_org.user_uuid, conn).await?; + } else { + err!("You cannot restore this user because they have not setup 2FA"); + } + } + Err(OrgPolicyErr::SingleOrgEnforced) => { + err!("You cannot restore this user because they are a member of an organization which forbids it"); + } + } + } + + user_org.restore(); + user_org.save(conn).await?; log_event( EventType::OrganizationUserRestored as i32, - &member.uuid, + &user_org.uuid, org_id, &headers.user.uuid, headers.device.atype, @@ -2401,27 +2304,15 @@ async fn _restore_member( Ok(()) } -async fn get_groups_data( - details: bool, - org_id: OrganizationId, - headers: ManagerHeadersLoose, - conn: DbConn, -) -> JsonResult { - if org_id != headers.membership.org_uuid { - err!("Organization not found", "Organization id's do not match"); - } +#[get("/organizations//groups")] +async fn get_groups(org_id: &str, headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult { let groups: Vec = if CONFIG.org_groups_enabled() { - let groups = Group::find_by_organization(&org_id, &conn).await; + // Group::find_by_organization(&org_id, &mut conn).await.iter().map(Group::to_json).collect::() + let groups = Group::find_by_organization(org_id, &mut conn).await; let mut groups_json = Vec::with_capacity(groups.len()); - if details { - for g in groups { - groups_json.push(g.to_json_details(&conn).await) - } - } else { - for g in groups { - groups_json.push(g.to_json()) - } + for g in groups { + groups_json.push(g.to_json_details(&headers.org_user.atype, &mut conn).await) } groups_json } else { @@ -2437,16 +2328,6 @@ async fn get_groups_data( }))) } -#[get("/organizations//groups")] -async fn get_groups(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { - get_groups_data(false, org_id, headers, conn).await -} - -#[get("/organizations//groups/details", rank = 1)] -async fn get_groups_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { - get_groups_data(true, org_id, headers, conn).await -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GroupRequest { @@ -2454,13 +2335,13 @@ struct GroupRequest { #[serde(default)] access_all: bool, external_id: Option, - collections: Vec, - users: Vec, + collections: Vec, + users: Vec, } impl GroupRequest { - pub fn to_group(&self, org_uuid: &OrganizationId) -> Group { - Group::new(org_uuid.clone(), self.name.clone(), self.access_all, self.external_id.clone()) + pub fn to_group(&self, organizations_uuid: &str) -> Group { + Group::new(String::from(organizations_uuid), self.name.clone(), self.access_all, self.external_id.clone()) } pub fn update_group(&self, mut group: Group) -> Group { @@ -2471,44 +2352,46 @@ impl GroupRequest { group } - - /// Validate if all the collections and members belong to the provided organization - pub async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { - 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, 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, org_id)) - } - - Ok(()) - } } #[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -struct CollectionData { - id: CollectionId, +struct SelectionReadOnly { + id: String, read_only: bool, hide_passwords: bool, - manage: bool, } -impl CollectionData { - pub fn to_collection_group(&self, groups_uuid: GroupId) -> CollectionGroup { - CollectionGroup::new(self.id.clone(), groups_uuid, self.read_only, self.hide_passwords, self.manage) +impl SelectionReadOnly { + pub fn to_collection_group(&self, groups_uuid: String) -> CollectionGroup { + CollectionGroup::new(self.id.clone(), groups_uuid, self.read_only, self.hide_passwords) + } + + pub fn to_collection_group_details_read_only(collection_group: &CollectionGroup) -> SelectionReadOnly { + SelectionReadOnly { + id: collection_group.groups_uuid.clone(), + read_only: collection_group.read_only, + hide_passwords: collection_group.hide_passwords, + } + } + + pub fn to_collection_user_details_read_only(collection_user: &CollectionUser) -> SelectionReadOnly { + SelectionReadOnly { + id: collection_user.user_uuid.clone(), + read_only: collection_user.read_only, + hide_passwords: collection_user.hide_passwords, + } + } + + pub fn to_json(&self) -> Value { + json!(self) } } #[post("/organizations//groups/", data = "")] async fn post_group( - org_id: OrganizationId, - group_id: GroupId, + org_id: &str, + group_id: &str, data: Json, headers: AdminHeaders, conn: DbConn, @@ -2517,102 +2400,88 @@ async fn post_group( } #[post("/organizations//groups", data = "")] -async fn post_groups( - org_id: OrganizationId, - headers: AdminHeaders, - data: Json, - conn: DbConn, -) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } +async fn post_groups(org_id: &str, headers: AdminHeaders, data: Json, mut conn: DbConn) -> JsonResult { if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } let group_request = data.into_inner(); - group_request.validate(&org_id, &conn).await?; - - let group = group_request.to_group(&org_id); + let group = group_request.to_group(org_id); log_event( EventType::GroupCreated as i32, &group.uuid, - &org_id, + org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await; - add_update_group(group, group_request.collections, group_request.users, org_id, &headers, &conn).await + add_update_group(group, group_request.collections, group_request.users, org_id, &headers, &mut conn).await } #[put("/organizations//groups/", data = "")] async fn put_group( - org_id: OrganizationId, - group_id: GroupId, + org_id: &str, + group_id: &str, data: Json, headers: AdminHeaders, - conn: DbConn, + mut conn: DbConn, ) -> JsonResult { - 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"); } - let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { - err!("Group not found", "Group uuid is invalid or does not belong to the organization") + let group = match Group::find_by_uuid(group_id, &mut conn).await { + Some(group) => group, + None => err!("Group not found"), }; let group_request = data.into_inner(); - group_request.validate(&org_id, &conn).await?; - let updated_group = group_request.update_group(group); - CollectionGroup::delete_all_by_group(&group_id, &org_id, &conn).await?; - GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?; + CollectionGroup::delete_all_by_group(group_id, &mut conn).await?; + GroupUser::delete_all_by_group(group_id, &mut conn).await?; log_event( EventType::GroupUpdated as i32, &updated_group.uuid, - &org_id, + org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await; - add_update_group(updated_group, group_request.collections, group_request.users, org_id, &headers, &conn).await + add_update_group(updated_group, group_request.collections, group_request.users, org_id, &headers, &mut conn).await } async fn add_update_group( mut group: Group, - collections: Vec, - members: Vec, - org_id: OrganizationId, + collections: Vec, + users: Vec, + org_id: &str, headers: &AdminHeaders, - conn: &DbConn, + conn: &mut DbConn, ) -> JsonResult { group.save(conn).await?; - for col_selection in collections { - let mut collection_group = col_selection.to_collection_group(group.uuid.clone()); - collection_group.save(&org_id, conn).await?; + for selection_read_only_request in collections { + let mut collection_group = selection_read_only_request.to_collection_group(group.uuid.clone()); + collection_group.save(conn).await?; } - for assigned_member in members { - let mut user_entry = GroupUser::new(group.uuid.clone(), assigned_member.clone()); + for assigned_user_id in users { + let mut user_entry = GroupUser::new(group.uuid.clone(), assigned_user_id.clone()); user_entry.save(conn).await?; log_event( EventType::OrganizationUserUpdatedGroups as i32, - &assigned_member, - &org_id, + &assigned_user_id, + org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -2626,62 +2495,42 @@ async fn add_update_group( "organizationId": group.organizations_uuid, "name": group.name, "accessAll": group.access_all, - "externalId": group.external_id, - "object": "group" + "externalId": group.external_id }))) } -#[get("/organizations//groups//details")] -async fn get_group_details( - org_id: OrganizationId, - group_id: GroupId, - headers: AdminHeaders, - conn: DbConn, -) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } +#[get("/organizations/<_org_id>/groups//details")] +async fn get_group_details(_org_id: &str, group_id: &str, headers: AdminHeaders, mut conn: DbConn) -> JsonResult { if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } - let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { - err!("Group not found", "Group uuid is invalid or does not belong to the organization") + let group = match Group::find_by_uuid(group_id, &mut conn).await { + Some(group) => group, + _ => err!("Group could not be found!"), }; - Ok(Json(group.to_json_details(&conn).await)) + Ok(Json(group.to_json_details(&(headers.org_user_type as i32), &mut conn).await)) } #[post("/organizations//groups//delete")] -async fn post_delete_group( - org_id: OrganizationId, - group_id: GroupId, - headers: AdminHeaders, - conn: DbConn, -) -> EmptyResult { - _delete_group(&org_id, &group_id, &headers, &conn).await +async fn post_delete_group(org_id: &str, group_id: &str, headers: AdminHeaders, mut conn: DbConn) -> EmptyResult { + _delete_group(org_id, group_id, &headers, &mut conn).await } #[delete("/organizations//groups/")] -async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> EmptyResult { - _delete_group(&org_id, &group_id, &headers, &conn).await +async fn delete_group(org_id: &str, group_id: &str, headers: AdminHeaders, mut conn: DbConn) -> EmptyResult { + _delete_group(org_id, group_id, &headers, &mut conn).await } -async fn _delete_group( - org_id: &OrganizationId, - group_id: &GroupId, - headers: &AdminHeaders, - conn: &DbConn, -) -> EmptyResult { - if org_id != &headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } +async fn _delete_group(org_id: &str, group_id: &str, headers: &AdminHeaders, conn: &mut DbConn) -> EmptyResult { if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } - let Some(group) = Group::find_by_uuid_and_org(group_id, org_id, conn).await else { - err!("Group not found", "Group uuid is invalid or does not belong to the organization") + let group = match Group::find_by_uuid(group_id, conn).await { + Some(group) => group, + _ => err!("Group not found"), }; log_event( @@ -2695,114 +2544,94 @@ async fn _delete_group( ) .await; - group.delete(org_id, conn).await + group.delete(conn).await } #[delete("/organizations//groups", data = "")] async fn bulk_delete_groups( - org_id: OrganizationId, - data: Json, + org_id: &str, + data: Json, headers: AdminHeaders, - conn: DbConn, + mut 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"); } - let data: BulkGroupIds = data.into_inner(); + let data: OrgBulkIds = data.into_inner(); for group_id in data.ids { - _delete_group(&org_id, &group_id, &headers, &conn).await? + _delete_group(org_id, &group_id, &headers, &mut conn).await? } Ok(()) } -#[get("/organizations//groups/", rank = 2)] -async fn get_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } +#[get("/organizations/<_org_id>/groups/")] +async fn get_group(_org_id: &str, group_id: &str, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult { if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } - let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { - err!("Group not found", "Group uuid is invalid or does not belong to the organization") + let group = match Group::find_by_uuid(group_id, &mut conn).await { + Some(group) => group, + _ => err!("Group not found"), }; Ok(Json(group.to_json())) } -#[get("/organizations//groups//users")] -async fn get_group_members( - org_id: OrganizationId, - group_id: GroupId, - headers: AdminHeaders, - conn: DbConn, -) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } +#[get("/organizations/<_org_id>/groups//users")] +async fn get_group_users(_org_id: &str, group_id: &str, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult { 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") + match Group::find_by_uuid(group_id, &mut conn).await { + Some(_) => { /* Do nothing */ } + _ => err!("Group could not be found!"), }; - let group_members: Vec = GroupUser::find_by_group(&group_id, &org_id, &conn) + let group_users: Vec = GroupUser::find_by_group(group_id, &mut conn) .await .iter() .map(|entry| entry.users_organizations_uuid.clone()) .collect(); - Ok(Json(json!(group_members))) + Ok(Json(json!(group_users))) } #[put("/organizations//groups//users", data = "")] -async fn put_group_members( - org_id: OrganizationId, - group_id: GroupId, +async fn put_group_users( + org_id: &str, + group_id: &str, headers: AdminHeaders, - data: Json>, - conn: DbConn, + data: Json>, + mut 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") + match Group::find_by_uuid(group_id, &mut conn).await { + Some(_) => { /* Do nothing */ } + _ => err!("Group could not be found!"), }; - let assigned_members = data.into_inner(); + GroupUser::delete_all_by_group(group_id, &mut conn).await?; - 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?; + let assigned_user_ids = data.into_inner(); + for assigned_user_id in assigned_user_ids { + let mut user_entry = GroupUser::new(String::from(group_id), assigned_user_id.clone()); + user_entry.save(&mut conn).await?; log_event( EventType::OrganizationUserUpdatedGroups as i32, - &assigned_member, - &org_id, + &assigned_user_id, + org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await; } @@ -2810,41 +2639,136 @@ async fn put_group_members( 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"); - } +#[get("/organizations/<_org_id>/users//groups")] +async fn get_user_groups(_org_id: &str, user_id: &str, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult { 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."); + match UserOrganization::find_by_uuid(user_id, &mut conn).await { + Some(_) => { /* Do nothing */ } + _ => err!("User could not be found!"), + }; + + let user_groups: Vec = + GroupUser::find_by_user(user_id, &mut conn).await.iter().map(|entry| entry.groups_uuid.clone()).collect(); + + Ok(Json(json!(user_groups))) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct OrganizationUserUpdateGroupsRequest { + group_ids: Vec, +} + +#[post("/organizations//users//groups", data = "")] +async fn post_user_groups( + org_id: &str, + org_user_id: &str, + data: Json, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + put_user_groups(org_id, org_user_id, data, headers, conn).await +} + +#[put("/organizations//users//groups", data = "")] +async fn put_user_groups( + org_id: &str, + org_user_id: &str, + data: Json, + headers: AdminHeaders, + mut conn: DbConn, +) -> EmptyResult { + 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 or does not belong to the organization."); + let user_org = match UserOrganization::find_by_uuid(org_user_id, &mut conn).await { + Some(uo) => uo, + _ => err!("User could not be found!"), + }; + + if user_org.org_uuid != org_id { + err!("Group doesn't belong to organization"); + } + + GroupUser::delete_all_by_user(org_user_id, &mut conn).await?; + + let assigned_group_ids = data.into_inner(); + for assigned_group_id in assigned_group_ids.group_ids { + let mut group_user = GroupUser::new(assigned_group_id.clone(), String::from(org_user_id)); + group_user.save(&mut conn).await?; } log_event( EventType::OrganizationUserUpdatedGroups as i32, - &member_id, - &org_id, + org_user_id, + org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await; - GroupUser::delete_by_group_and_member(&group_id, &member_id, &conn).await + Ok(()) +} + +#[post("/organizations//groups//delete-user/")] +async fn post_delete_group_user( + org_id: &str, + group_id: &str, + org_user_id: &str, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + delete_group_user(org_id, group_id, org_user_id, headers, conn).await +} + +#[delete("/organizations//groups//users/")] +async fn delete_group_user( + org_id: &str, + group_id: &str, + org_user_id: &str, + headers: AdminHeaders, + mut conn: DbConn, +) -> EmptyResult { + if !CONFIG.org_groups_enabled() { + err!("Group support is disabled"); + } + + let user_org = match UserOrganization::find_by_uuid(org_user_id, &mut conn).await { + Some(uo) => uo, + _ => err!("User could not be found!"), + }; + + if user_org.org_uuid != org_id { + err!("User doesn't belong to organization"); + } + + let group = match Group::find_by_uuid(group_id, &mut conn).await { + Some(g) => g, + _ => err!("Group could not be found!"), + }; + + if group.organizations_uuid != org_id { + err!("Group doesn't belong to organization"); + } + + log_event( + EventType::OrganizationUserUpdatedGroups as i32, + org_user_id, + org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &mut conn, + ) + .await; + + GroupUser::delete_by_group_id_and_user_id(group_id, org_user_id, &mut conn).await } #[derive(Deserialize)] @@ -2862,16 +2786,14 @@ struct OrganizationUserResetPasswordRequest { key: String, } -// Upstream reports this is the renamed endpoint instead of `/keys` +// Upstrem reports this is the renamed endpoint instead of `/keys` // 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: 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 { - err!("Organization not found") +async fn get_organization_public_key(org_id: &str, _headers: Headers, mut conn: DbConn) -> JsonResult { + let org = match Organization::find_by_uuid(org_id, &mut conn).await { + Some(organization) => organization, + None => err!("Organization not found"), }; Ok(Json(json!({ @@ -2881,138 +2803,139 @@ async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberH } // 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 +// https://github.com/bitwarden/server/blob/25dc0c9178e3e3584074bbef0d4be827b7c89415/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L463-L468 #[get("/organizations//keys")] -async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { +async fn get_organization_keys(org_id: &str, headers: Headers, conn: DbConn) -> JsonResult { get_organization_public_key(org_id, headers, conn).await } -#[put("/organizations//users//reset-password", data = "")] +#[put("/organizations//users//reset-password", data = "")] async fn put_reset_password( - org_id: OrganizationId, - member_id: MembershipId, + org_id: &str, + org_user_id: &str, headers: AdminHeaders, data: Json, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { - err!("Required organization not found") + let org = match Organization::find_by_uuid(org_id, &mut conn).await { + Some(org) => org, + None => err!("Required organization not found"), }; - let Some(member) = Membership::find_by_uuid_and_org(&member_id, &org.uuid, &conn).await else { - err!("User to reset isn't member of required organization") + let org_user = match UserOrganization::find_by_uuid_and_org(org_user_id, &org.uuid, &mut conn).await { + Some(user) => user, + None => err!("User to reset isn't member of required organization"), }; - let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else { - err!("User not found") + let user = match User::find_by_uuid(&org_user.user_uuid, &mut conn).await { + Some(user) => user, + None => err!("User not found"), }; - check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers, &conn).await?; + check_reset_password_applicable_and_permissions(org_id, org_user_id, &headers, &mut conn).await?; - if member.reset_password_key.is_none() { + if org_user.reset_password_key.is_none() { err!("Password reset not or not correctly enrolled"); } - if member.status != (MembershipStatus::Confirmed as i32) { + if org_user.status != (UserOrgStatus::Confirmed as i32) { err!("Organization user must be confirmed for password reset functionality"); } // Sending email before resetting password to ensure working email configuration and the resulting // user notification. Also this might add some protection against security flaws and misuse - if let Err(e) = mail::send_admin_reset_password(&user.email, user.display_name(), &org.name).await { + if let Err(e) = mail::send_admin_reset_password(&user.email, &user.name, &org.name).await { err!(format!("Error sending user reset password email: {e:#?}")); } let reset_request = data.into_inner(); let mut user = user; - user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn) - .await?; - user.save(&conn).await?; + user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None); + user.save(&mut conn).await?; - nt.send_logout(&user, None, &conn).await; + nt.send_logout(&user, None).await; log_event( EventType::OrganizationUserAdminResetPassword as i32, - &member_id, - &org_id, + org_user_id, + org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + &mut conn, ) .await; Ok(()) } -#[get("/organizations//users//reset-password-details")] +#[get("/organizations//users//reset-password-details")] async fn get_reset_password_details( - org_id: OrganizationId, - member_id: MembershipId, + org_id: &str, + org_user_id: &str, headers: AdminHeaders, - conn: DbConn, + mut conn: DbConn, ) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } - let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { - err!("Required organization not found") + let org = match Organization::find_by_uuid(org_id, &mut conn).await { + Some(org) => org, + None => err!("Required organization not found"), }; - let Some(member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { - err!("User to reset isn't member of required organization") + let org_user = match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, &mut conn).await { + Some(user) => user, + None => err!("User to reset isn't member of required organization"), }; - let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else { - err!("User not found") + let user = match User::find_by_uuid(&org_user.user_uuid, &mut conn).await { + Some(user) => user, + None => err!("User not found"), }; - check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers, &conn).await?; + check_reset_password_applicable_and_permissions(org_id, org_user_id, &headers, &mut conn).await?; - // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs#L190 + // https://github.com/bitwarden/server/blob/3b50ccb9f804efaacdc46bed5b60e5b28eddefcf/src/Api/Models/Response/Organizations/OrganizationUserResponseModel.cs#L111 Ok(Json(json!({ "object": "organizationUserResetPasswordDetails", - "organizationUserId": member_id, - "kdf": user.client_kdf_type, - "kdfIterations": user.client_kdf_iter, - "kdfMemory": user.client_kdf_memory, - "kdfParallelism": user.client_kdf_parallelism, - "resetPasswordKey": member.reset_password_key, - "encryptedPrivateKey": org.private_key, + "kdf":user.client_kdf_type, + "kdfIterations":user.client_kdf_iter, + "kdfMemory":user.client_kdf_memory, + "kdfParallelism":user.client_kdf_parallelism, + "resetPasswordKey":org_user.reset_password_key, + "encryptedPrivateKey":org.private_key, + }))) } async fn check_reset_password_applicable_and_permissions( - org_id: &OrganizationId, - member_id: &MembershipId, + org_id: &str, + org_user_id: &str, headers: &AdminHeaders, - conn: &DbConn, + conn: &mut DbConn, ) -> EmptyResult { check_reset_password_applicable(org_id, conn).await?; - let Some(target_user) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { - err!("Reset target user not found") + let target_user = match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, conn).await { + Some(user) => user, + None => err!("Reset target user not found"), }; // Resetting user must be higher/equal to user to reset - match headers.membership_type { - MembershipType::Owner => Ok(()), - MembershipType::Admin if target_user.atype <= MembershipType::Admin => Ok(()), + match headers.org_user_type { + UserOrgType::Owner => Ok(()), + UserOrgType::Admin if target_user.atype <= UserOrgType::Admin => Ok(()), _ => err!("No permission to reset this user's password"), } } -async fn check_reset_password_applicable(org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { +async fn check_reset_password_applicable(org_id: &str, conn: &mut DbConn) -> EmptyResult { if !CONFIG.mail_enabled() { err!("Password reset is not supported on an email-disabled instance."); } - let Some(policy) = OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::ResetPassword, conn).await else { - err!("Policy not found") + let policy = match OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::ResetPassword, conn).await { + Some(p) => p, + None => err!("Policy not found"), }; if !policy.enabled { @@ -3022,91 +2945,112 @@ async fn check_reset_password_applicable(org_id: &OrganizationId, conn: &DbConn) Ok(()) } -#[put("/organizations//users//reset-password-enrollment", data = "")] +#[put("/organizations//users//reset-password-enrollment", data = "")] async fn put_reset_password_enrollment( - org_id: OrganizationId, - user_id: UserId, - headers: OrgMemberHeaders, + org_id: &str, + org_user_id: &str, + headers: Headers, data: Json, - conn: DbConn, + mut conn: DbConn, ) -> EmptyResult { - if user_id != headers.user.uuid { - err!("User to enroll isn't member of required organization", "The user_id and acting user do not match"); - } + let mut org_user = match UserOrganization::find_by_user_and_org(&headers.user.uuid, org_id, &mut conn).await { + Some(u) => u, + None => err!("User to enroll isn't member of required organization"), + }; - let mut membership = headers.membership; - - check_reset_password_applicable(&org_id, &conn).await?; + check_reset_password_applicable(org_id, &mut conn).await?; let reset_request = data.into_inner(); - let reset_password_key = match reset_request.reset_password_key { - None => None, - Some(ref key) if key.is_empty() => None, - Some(key) => Some(key), - }; - - if reset_password_key.is_none() && OrgPolicy::org_is_reset_password_auto_enroll(&org_id, &conn).await { - err!("Reset password can't be withdrawn due to an enterprise policy"); + if reset_request.reset_password_key.is_none() + && OrgPolicy::org_is_reset_password_auto_enroll(org_id, &mut conn).await + { + err!("Reset password can't be withdrawed due to an enterprise policy"); } - if reset_password_key.is_some() { + if reset_request.reset_password_key.is_some() { PasswordOrOtpData { master_password_hash: reset_request.master_password_hash, otp: reset_request.otp, } - .validate(&headers.user, true, &conn) + .validate(&headers.user, true, &mut conn) .await?; } - membership.reset_password_key = reset_password_key; - membership.save(&conn).await?; + org_user.reset_password_key = reset_request.reset_password_key; + org_user.save(&mut conn).await?; - let event_type = if membership.reset_password_key.is_some() { + let log_id = if org_user.reset_password_key.is_some() { EventType::OrganizationUserResetPasswordEnroll as i32 } else { EventType::OrganizationUserResetPasswordWithdraw as i32 }; - log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; + log_event(log_id, org_user_id, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &mut conn).await; Ok(()) } +// This is a new function active since the v2022.9.x clients. +// It combines the previous two calls done before. +// We call those two functions here and combine them ourselves. +// // NOTE: It seems clients can't handle uppercase-first keys!! // We need to convert all keys so they have the first character to be a lowercase. // Else the export will be just an empty JSON file. -// We currently only support exports by members of the Admin or Owner status. -// Vaultwarden does not yet support exporting only managed collections! -// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52 #[get("/organizations//export")] -async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { - if org_id != headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } +async fn get_org_export(org_id: &str, headers: AdminHeaders, mut conn: DbConn) -> Json { + use semver::{Version, VersionReq}; - Ok(Json(json!({ - "collections": convert_json_key_lcase_first(_get_org_collections(&org_id, &conn).await), - "ciphers": convert_json_key_lcase_first(_get_org_details(&org_id, &headers.host, &headers.user.uuid, &conn).await?), - }))) + // Since version v2023.1.0 the format of the export is different. + // Also, this endpoint was created since v2022.9.0. + // Therefore, we will check for any version smaller then v2023.1.0 and return a different response. + // If we can't determine the version, we will use the latest default v2023.1.0 and higher. + // https://github.com/bitwarden/server/blob/9ca93381ce416454734418c3a9f99ab49747f1b6/src/Api/Controllers/OrganizationExportController.cs#L44 + let use_list_response_model = if let Some(client_version) = headers.client_version { + let ver_match = VersionReq::parse("<2023.1.0").unwrap(); + let client_version = Version::parse(&client_version).unwrap(); + ver_match.matches(&client_version) + } else { + false + }; + + // Also both main keys here need to be lowercase, else the export will fail. + if use_list_response_model { + // Backwards compatible pre v2023.1.0 response + Json(json!({ + "collections": { + "data": convert_json_key_lcase_first(_get_org_collections(org_id, &mut conn).await), + "object": "list", + "continuationToken": null, + }, + "ciphers": { + "data": convert_json_key_lcase_first(_get_org_details(org_id, &headers.host, &headers.user.uuid, &mut conn).await), + "object": "list", + "continuationToken": null, + } + })) + } else { + // v2023.1.0 and newer response + Json(json!({ + "collections": convert_json_key_lcase_first(_get_org_collections(org_id, &mut conn).await), + "ciphers": convert_json_key_lcase_first(_get_org_details(org_id, &headers.host, &headers.user.uuid, &mut conn).await), + })) + } } async fn _api_key( - org_id: &OrganizationId, + org_id: &str, data: Json, rotate: bool, headers: AdminHeaders, - conn: DbConn, + mut conn: DbConn, ) -> JsonResult { - if org_id != &headers.org_id { - err!("Organization not found", "Organization id's do not match"); - } let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; // Validate the admin users password/otp - data.validate(&user, true, &conn).await?; + data.validate(&user, true, &mut conn).await?; let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_id, &conn).await { Some(mut org_api_key) => { @@ -3119,7 +3063,7 @@ async fn _api_key( } None => { let api_key = crate::crypto::generate_api_key(); - let new_org_api_key = OrganizationApiKey::new(org_id.clone(), api_key); + let new_org_api_key = OrganizationApiKey::new(String::from(org_id), api_key); new_org_api_key.save(&conn).await.expect("Error creating organization API Key"); new_org_api_key } @@ -3133,21 +3077,16 @@ async fn _api_key( } #[post("/organizations//api-key", data = "")] -async fn api_key( - org_id: OrganizationId, - data: Json, - headers: AdminHeaders, - conn: DbConn, -) -> JsonResult { - _api_key(&org_id, data, false, headers, conn).await +async fn api_key(org_id: &str, data: Json, headers: AdminHeaders, conn: DbConn) -> JsonResult { + _api_key(org_id, data, false, headers, conn).await } #[post("/organizations//rotate-api-key", data = "")] async fn rotate_api_key( - org_id: OrganizationId, + org_id: &str, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { - _api_key(&org_id, data, true, headers, conn).await + _api_key(org_id, data, true, headers, conn).await } diff --git a/src/api/core/public.rs b/src/api/core/public.rs index d757d953..737d30dd 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -10,13 +10,7 @@ use std::collections::HashSet; use crate::{ api::EmptyResult, auth, - db::{ - models::{ - Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization, - OrganizationApiKey, OrganizationId, User, - }, - DbConn, - }, + db::{models::*, DbConn}, mail, CONFIG, }; @@ -50,99 +44,95 @@ struct OrgImportData { } #[post("/public/organization/import", data = "")] -async fn ldap_import(data: Json, token: PublicToken, conn: DbConn) -> EmptyResult { +async fn ldap_import(data: Json, token: PublicToken, mut conn: DbConn) -> EmptyResult { // Most of the logic for this function can be found here - // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs#L1203 + // https://github.com/bitwarden/server/blob/fd892b2ff4547648a276734fb2b14a8abae2c6f5/src/Core/Services/Implementations/OrganizationService.cs#L1797 let org_id = token.0; let data = data.into_inner(); 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 { + if let Some(mut user_org) = + UserOrganization::find_by_email_and_org(&user_data.email, &org_id, &mut conn).await + { // Only revoke a user if it is not the last confirmed owner - let revoked = if member.atype == MembershipType::Owner - && member.status == MembershipStatus::Confirmed as i32 + let revoked = if user_org.atype == UserOrgType::Owner + && user_org.status == UserOrgStatus::Confirmed as i32 { - if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 { + if UserOrganization::count_confirmed_by_org_and_type(&org_id, UserOrgType::Owner, &mut conn).await + <= 1 + { warn!("Can't revoke the last owner"); false } else { - member.revoke() + user_org.revoke() } } else { - member.revoke() + user_org.revoke() }; - let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); + let ext_modified = user_org.set_external_id(Some(user_data.external_id.clone())); if revoked || ext_modified { - member.save(&conn).await?; + user_org.save(&mut conn).await?; } } // 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 restored = member.restore(); - let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); + } else if let Some(mut user_org) = + UserOrganization::find_by_email_and_org(&user_data.email, &org_id, &mut conn).await + { + let restored = user_org.restore(); + let ext_modified = user_org.set_external_id(Some(user_data.external_id.clone())); if restored || ext_modified { - member.save(&conn).await?; + user_org.save(&mut conn).await?; } } else { // If user is not part of the organization - let user = match User::find_by_mail(&user_data.email, &conn).await { + let user = match User::find_by_mail(&user_data.email, &mut conn).await { Some(user) => user, // exists in vaultwarden None => { // User does not exist yet - let mut new_user = User::new(&user_data.email, None); - new_user.save(&conn).await?; + let mut new_user = User::new(user_data.email.clone()); + new_user.save(&mut conn).await?; if !CONFIG.mail_enabled() { - Invitation::new(&new_user.email).save(&conn).await?; + let invitation = Invitation::new(&new_user.email); + invitation.save(&mut conn).await?; } - user_created = true; new_user } }; - let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() { - MembershipStatus::Invited as i32 + let user_org_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() { + UserOrgStatus::Invited as i32 } else { - MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites + UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites }; - let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &conn).await { - Some(org) => (org.name, org.billing_email), - None => err!("Error looking up organization"), - }; + let mut new_org_user = UserOrganization::new(user.uuid.clone(), org_id.clone()); + new_org_user.set_external_id(Some(user_data.external_id.clone())); + new_org_user.access_all = false; + new_org_user.atype = UserOrgType::User as i32; + new_org_user.status = user_org_status; - let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone())); - new_member.set_external_id(Some(user_data.external_id.clone())); - new_member.access_all = false; - new_member.atype = MembershipType::User as i32; - new_member.status = member_status; - - new_member.save(&conn).await?; + new_org_user.save(&mut conn).await?; if CONFIG.mail_enabled() { - if let Err(e) = - mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await - { - // Upon error delete the user, invite and org member records when needed - if user_created { - user.delete(&conn).await?; - } else { - new_member.delete(&conn).await?; - } + let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &mut conn).await { + Some(org) => (org.name, org.billing_email), + None => err!("Error looking up organization"), + }; - err!(format!("Error sending invite: {e:?} ")); - } + mail::send_invite(&user, Some(org_id.clone()), Some(new_org_user.uuid), &org_name, Some(org_email)) + .await?; } } } if CONFIG.org_groups_enabled() { for group_data in &data.groups { - let group_uuid = match Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await { + let group_uuid = match Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &mut conn).await + { Some(group) => group.uuid, None => { let mut group = Group::new( @@ -151,17 +141,18 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn false, Some(group_data.external_id.clone()), ); - group.save(&conn).await?; + group.save(&mut conn).await?; group.uuid } }; - GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?; + GroupUser::delete_all_by_group(&group_uuid, &mut conn).await?; for ext_id in &group_data.member_external_ids { - if let Some(member) = Membership::find_by_external_id_and_org(ext_id, &org_id, &conn).await { - let mut group_user = GroupUser::new(group_uuid.clone(), member.uuid.clone()); - group_user.save(&conn).await?; + if let Some(user_org) = UserOrganization::find_by_external_id_and_org(ext_id, &org_id, &mut conn).await + { + let mut group_user = GroupUser::new(group_uuid.clone(), user_org.uuid.clone()); + group_user.save(&mut conn).await?; } } } @@ -173,18 +164,20 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn if data.overwrite_existing { // Generate a HashSet to quickly verify if a member is listed or not. let sync_members: HashSet = data.members.into_iter().map(|m| m.external_id).collect(); - for member in Membership::find_by_org(&org_id, &conn).await { - if let Some(ref user_external_id) = member.external_id { + for user_org in UserOrganization::find_by_org(&org_id, &mut conn).await { + if let Some(ref user_external_id) = user_org.external_id { if !sync_members.contains(user_external_id) { - if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 { + if user_org.atype == UserOrgType::Owner && user_org.status == UserOrgStatus::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 + if UserOrganization::count_confirmed_by_org_and_type(&org_id, UserOrgType::Owner, &mut conn) + .await + <= 1 { warn!("Can't delete the last owner"); continue; } } - member.delete(&conn).await?; + user_org.delete(&mut conn).await?; } } } @@ -193,7 +186,7 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn Ok(()) } -pub struct PublicToken(OrganizationId); +pub struct PublicToken(String); #[rocket::async_trait] impl<'r> FromRequest<'r> for PublicToken { @@ -210,8 +203,9 @@ impl<'r> FromRequest<'r> for PublicToken { None => err_handler!("No access token provided"), }; // Check JWT token is valid and get device and user from it - let Ok(claims) = auth::decode_api_org(access_token) else { - err_handler!("Invalid claim") + let claims = match auth::decode_api_org(access_token) { + Ok(claims) => claims, + Err(_) => err_handler!("Invalid claim"), }; // Check if time is between claims.nbf and claims.exp let time_now = Utc::now().timestamp(); @@ -233,12 +227,13 @@ impl<'r> FromRequest<'r> for PublicToken { Outcome::Success(conn) => conn, _ => err_handler!("Error getting DB"), }; - let Some(org_id) = claims.client_id.strip_prefix("organization.") else { - err_handler!("Malformed client_id") + let org_uuid = match claims.client_id.strip_prefix("organization.") { + Some(uuid) => uuid, + None => err_handler!("Malformed client_id"), }; - let org_id: OrganizationId = org_id.to_string().into(); - let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await else { - err_handler!("Invalid client_id") + let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_uuid, &conn).await { + Some(org_api_key) => org_api_key, + None => err_handler!("Invalid client_id"), }; if org_api_key.org_uuid != claims.client_sub { err_handler!("Token not issued for this org"); diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 22abb396..a7e5bcf0 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -1,42 +1,22 @@ -use std::{path::Path, sync::LazyLock, time::Duration}; +use std::path::Path; use chrono::{DateTime, TimeDelta, Utc}; use num_traits::ToPrimitive; -use rocket::{ - form::Form, - fs::{NamedFile, TempFile}, - serde::json::Json, -}; +use rocket::form::Form; +use rocket::fs::NamedFile; +use rocket::fs::TempFile; +use rocket::serde::json::Json; use serde_json::Value; use crate::{ api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, auth::{ClientIp, Headers, Host}, - config::PathType, - db::{ - models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId}, - DbConn, DbPool, - }, - util::{save_temp_file, NumberOrString}, + db::{models::*, DbConn, DbPool}, + util::{NumberOrString, SafeString}, CONFIG, }; const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available"; -static ANON_PUSH_DEVICE: LazyLock = LazyLock::new(|| { - let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z"); - Device { - uuid: String::from("00000000-0000-0000-0000-000000000000").into(), - created_at: dt, - updated_at: dt, - user_uuid: String::from("00000000-0000-0000-0000-000000000000").into(), - name: String::new(), - atype: 14, // 14 == Unknown Browser - push_uuid: Some(String::from("00000000-0000-0000-0000-000000000000").into()), - push_token: None, - refresh_token: String::new(), - twofactor_remember: None, - } -}); // The max file size allowed by Bitwarden clients and add an extra 5% to avoid issues const SIZE_525_MB: i64 = 550_502_400; @@ -60,8 +40,8 @@ pub fn routes() -> Vec { pub async fn purge_sends(pool: DbPool) { debug!("Purging sends"); - if let Ok(conn) = pool.get().await { - Send::purge(&conn).await; + if let Ok(mut conn) = pool.get().await { + Send::purge(&mut conn).await; } else { error!("Failed to get DB connection while purging sends") } @@ -87,7 +67,7 @@ pub struct SendData { file_length: Option, // Used for key rotations - pub id: Option, + pub id: Option, } /// Enforces the `Disable Send` policy. A non-owner/admin user belonging to @@ -98,10 +78,10 @@ pub struct SendData { /// /// There is also a Vaultwarden-specific `sends_allowed` config setting that /// controls this policy globally. -async fn enforce_disable_send_policy(headers: &Headers, conn: &DbConn) -> EmptyResult { - let user_id = &headers.user.uuid; +async fn enforce_disable_send_policy(headers: &Headers, conn: &mut DbConn) -> EmptyResult { + let user_uuid = &headers.user.uuid; if !CONFIG.sends_allowed() - || OrgPolicy::is_applicable_to_user(user_id, OrgPolicyType::DisableSend, None, conn).await + || OrgPolicy::is_applicable_to_user(user_uuid, OrgPolicyType::DisableSend, None, conn).await { err!("Due to an Enterprise Policy, you are only able to delete an existing Send.") } @@ -114,10 +94,10 @@ async fn enforce_disable_send_policy(headers: &Headers, conn: &DbConn) -> EmptyR /// but is allowed to remove this option from an existing Send. /// /// Ref: https://bitwarden.com/help/article/policies/#send-options -async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, conn: &DbConn) -> EmptyResult { - let user_id = &headers.user.uuid; +async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, conn: &mut DbConn) -> EmptyResult { + let user_uuid = &headers.user.uuid; let hide_email = data.hide_email.unwrap_or(false); - if hide_email && OrgPolicy::is_hide_email_disabled(user_id, conn).await { + if hide_email && OrgPolicy::is_hide_email_disabled(user_uuid, conn).await { err!( "Due to an Enterprise Policy, you are not allowed to hide your email address \ from recipients when creating or editing a Send." @@ -126,7 +106,7 @@ async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, c Ok(()) } -fn create_send(data: SendData, user_id: UserId) -> ApiResult { +fn create_send(data: SendData, user_uuid: String) -> ApiResult { let data_val = if data.r#type == SendType::Text as i32 { data.text } else if data.r#type == SendType::File as i32 { @@ -149,7 +129,7 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { } let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc()); - send.user_uuid = Some(user_id); + send.user_uuid = Some(user_uuid); send.notes = data.notes; send.max_access_count = match data.max_access_count { Some(m) => Some(m.into_i32()?), @@ -166,8 +146,8 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { } #[get("/sends")] -async fn get_sends(headers: Headers, conn: DbConn) -> Json { - let sends = Send::find_by_user(&headers.user.uuid, &conn); +async fn get_sends(headers: Headers, mut conn: DbConn) -> Json { + let sends = Send::find_by_user(&headers.user.uuid, &mut conn); let sends_json: Vec = sends.await.iter().map(|s| s.to_json()).collect(); Json(json!({ @@ -177,33 +157,39 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json { })) } -#[get("/sends/")] -async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult { - match Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await { - Some(send) => Ok(Json(send.to_json())), - None => err!("Send not found", "Invalid send uuid or does not belong to user"), +#[get("/sends/")] +async fn get_send(uuid: &str, headers: Headers, mut conn: DbConn) -> JsonResult { + let send = match Send::find_by_uuid(uuid, &mut conn).await { + Some(send) => send, + None => err!("Send not found"), + }; + + if send.user_uuid.as_ref() != Some(&headers.user.uuid) { + err!("Send is not owned by user") } + + Ok(Json(send.to_json())) } #[post("/sends", data = "")] -async fn post_send(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - enforce_disable_send_policy(&headers, &conn).await?; +async fn post_send(data: Json, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult { + enforce_disable_send_policy(&headers, &mut conn).await?; let data: SendData = data.into_inner(); - enforce_disable_hide_email_policy(&data, &headers, &conn).await?; + enforce_disable_hide_email_policy(&data, &headers, &mut conn).await?; if data.r#type == SendType::File as i32 { err!("File sends should use /api/sends/file") } let mut send = create_send(data, headers.user.uuid)?; - send.save(&conn).await?; + send.save(&mut conn).await?; nt.send_send_update( UpdateType::SyncSendCreate, &send, - &send.update_users_revision(&conn).await, - &headers.device, - &conn, + &send.update_users_revision(&mut conn).await, + &headers.device.uuid, + &mut conn, ) .await; @@ -224,15 +210,13 @@ struct UploadDataV2<'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?; +async fn post_send_file(data: Form>, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult { + enforce_disable_send_policy(&headers, &mut conn).await?; let UploadData { model, - data, + mut data, } = data.into_inner(); let model = model.into_inner(); @@ -243,12 +227,12 @@ async fn post_send_file(data: Form>, headers: Headers, conn: DbCo err!("Send size can't be negative") } - enforce_disable_hide_email_policy(&model, &headers, &conn).await?; + enforce_disable_hide_email_policy(&model, &headers, &mut 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 { + let Some(already_used) = Send::size_by_user(&headers.user.uuid, &mut conn).await else { err!("Existing sends overflow") }; let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else { @@ -271,9 +255,14 @@ async fn post_send_file(data: Form>, headers: Headers, conn: DbCo err!("Send content is not a file"); } - let file_id = crate::crypto::generate_send_file_id(); + let file_id = crate::crypto::generate_send_id(); + let folder_path = tokio::fs::canonicalize(&CONFIG.sends_folder()).await?.join(&send.uuid); + let file_path = folder_path.join(&file_id); + tokio::fs::create_dir_all(&folder_path).await?; - save_temp_file(&PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?; + if let Err(_err) = data.persist_to(&file_path).await { + data.move_copy_to(file_path).await? + } let mut data_value: Value = serde_json::from_str(&send.data)?; if let Some(o) = data_value.as_object_mut() { @@ -284,23 +273,23 @@ async fn post_send_file(data: Form>, headers: Headers, conn: DbCo send.data = serde_json::to_string(&data_value)?; // Save the changes in the database - send.save(&conn).await?; + send.save(&mut conn).await?; nt.send_send_update( UpdateType::SyncSendCreate, &send, - &send.update_users_revision(&conn).await, - &headers.device, - &conn, + &send.update_users_revision(&mut conn).await, + &headers.device.uuid, + &mut conn, ) .await; Ok(Json(send.to_json())) } -// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165 +// Upstream: https://github.com/bitwarden/server/blob/d0c793c95181dfb1b447eb450f85ba0bfd7ef643/src/Api/Controllers/SendsController.cs#L190 #[post("/sends/file/v2", data = "")] -async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - enforce_disable_send_policy(&headers, &conn).await?; +async fn post_send_file_v2(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { + enforce_disable_send_policy(&headers, &mut conn).await?; let data = data.into_inner(); @@ -308,7 +297,7 @@ async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) err!("Send content is not a file"); } - enforce_disable_hide_email_policy(&data, &headers, &conn).await?; + enforce_disable_hide_email_policy(&data, &headers, &mut conn).await?; let file_length = match &data.file_length { Some(m) => m.into_i64()?, @@ -321,7 +310,7 @@ async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) 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 { + let Some(already_used) = Send::size_by_user(&headers.user.uuid, &mut conn).await else { err!("Existing sends overflow") }; let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else { @@ -341,7 +330,7 @@ async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) let mut send = create_send(data, headers.user.uuid)?; - let file_id = crate::crypto::generate_send_file_id(); + let file_id = crate::crypto::generate_send_id(); let mut data_value: Value = serde_json::from_str(&send.data)?; if let Some(o) = data_value.as_object_mut() { @@ -350,12 +339,12 @@ async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) 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?; + send.save(&mut conn).await?; Ok(Json(json!({ "fileUploadType": 0, // 0 == Direct | 1 == Azure "object": "send-fileUpload", - "url": format!("/sends/{}/file/{file_id}", send.uuid), + "url": format!("/sends/{}/file/{}", send.uuid, file_id), "sendResponse": send.to_json() }))) } @@ -363,43 +352,47 @@ async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) #[derive(Deserialize)] #[allow(non_snake_case)] pub struct SendFileData { - id: SendFileId, + id: String, size: u64, fileName: String, } -// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195 -#[post("/sends//file/", format = "multipart/form-data", data = "")] +// https://github.com/bitwarden/server/blob/66f95d1c443490b653e5a15d32977e2f5a3f9e32/src/Api/Tools/Controllers/SendsController.cs#L250 +#[post("/sends//file/", format = "multipart/form-data", data = "")] async fn post_send_file_v2_data( - send_id: SendId, - file_id: SendFileId, + send_uuid: &str, + file_id: &str, data: Form>, headers: Headers, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - enforce_disable_send_policy(&headers, &conn).await?; + enforce_disable_send_policy(&headers, &mut conn).await?; - let data = data.into_inner(); + let mut data = data.into_inner(); - let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else { - err!("Send not found. Unable to save the file.", "Invalid send uuid or does not belong to user.") + let Some(send) = Send::find_by_uuid(send_uuid, &mut conn).await else { + err!("Send not found. Unable to save the file.") }; if send.atype != SendType::File as i32 { err!("Send is not a file type send."); } + let Some(send_user_id) = &send.user_uuid else { + err!("Sends are only supported for users at the moment.") + }; + + if send_user_id != &headers.user.uuid { + err!("Send doesn't belong to user."); + } + let Ok(send_data) = serde_json::from_str::(&send.data) else { err!("Unable to decode send data as json.") }; match data.data.raw_name() { - Some(raw_file_name) - if raw_file_name.dangerous_unsafe_unsanitized_raw() == send_data.fileName - // be less strict only if using CLI, cf. https://github.com/dani-garcia/vaultwarden/issues/5614 - || (headers.device.is_cli() && send_data.fileName.ends_with(raw_file_name.dangerous_unsafe_unsanitized_raw().as_str()) - ) => {} + Some(raw_file_name) if raw_file_name.dangerous_unsafe_unsanitized_raw() == send_data.fileName => (), Some(raw_file_name) => err!( "Send file name does not match.", format!( @@ -423,16 +416,26 @@ async fn post_send_file_v2_data( err!("Send file size does not match.", format!("Expected a file size of {} got {size}", send_data.size)); } - let file_path = format!("{send_id}/{file_id}"); + let folder_path = tokio::fs::canonicalize(&CONFIG.sends_folder()).await?.join(send_uuid); + let file_path = folder_path.join(file_id); - save_temp_file(&PathType::Sends, &file_path, data.data, false).await?; + // Check if the file already exists, if that is the case do not overwrite it + if tokio::fs::metadata(&file_path).await.is_ok() { + err!("Send file has already been uploaded.", format!("File {file_path:?} already exists")) + } + + tokio::fs::create_dir_all(&folder_path).await?; + + if let Err(_err) = data.data.persist_to(&file_path).await { + data.data.move_copy_to(file_path).await? + } nt.send_send_update( UpdateType::SyncSendCreate, &send, - &send.update_users_revision(&conn).await, - &headers.device, - &conn, + &send.update_users_revision(&mut conn).await, + &headers.device.uuid, + &mut conn, ) .await; @@ -449,12 +452,13 @@ pub struct SendAccessData { async fn post_access( access_id: &str, data: Json, - conn: DbConn, + mut conn: DbConn, ip: ClientIp, nt: Notify<'_>, ) -> JsonResult { - let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else { - err_code!(SEND_INACCESSIBLE_MSG, 404) + let mut send = match Send::find_by_access_id(access_id, &mut conn).await { + Some(s) => s, + None => err_code!(SEND_INACCESSIBLE_MSG, 404), }; if let Some(max_access_count) = send.max_access_count { @@ -490,31 +494,32 @@ async fn post_access( send.access_count += 1; } - send.save(&conn).await?; + send.save(&mut conn).await?; nt.send_send_update( UpdateType::SyncSendUpdate, &send, - &send.update_users_revision(&conn).await, - &ANON_PUSH_DEVICE, - &conn, + &send.update_users_revision(&mut conn).await, + &String::from("00000000-0000-0000-0000-000000000000"), + &mut conn, ) .await; - Ok(Json(send.to_json_access(&conn).await)) + Ok(Json(send.to_json_access(&mut conn).await)) } #[post("/sends//access/file/", data = "")] async fn post_access_file( - send_id: SendId, - file_id: SendFileId, + send_id: &str, + file_id: &str, data: Json, host: Host, - conn: DbConn, + mut conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else { - err_code!(SEND_INACCESSIBLE_MSG, 404) + let mut send = match Send::find_by_uuid(send_id, &mut conn).await { + Some(s) => s, + None => err_code!(SEND_INACCESSIBLE_MSG, 404), }; if let Some(max_access_count) = send.max_access_count { @@ -547,39 +552,28 @@ async fn post_access_file( send.access_count += 1; - send.save(&conn).await?; + send.save(&mut conn).await?; nt.send_send_update( UpdateType::SyncSendUpdate, &send, - &send.update_users_revision(&conn).await, - &ANON_PUSH_DEVICE, - &conn, + &send.update_users_revision(&mut conn).await, + &String::from("00000000-0000-0000-0000-000000000000"), + &mut conn, ) .await; + let token_claims = crate::auth::generate_send_claims(send_id, file_id); + let token = crate::auth::encode_jwt(&token_claims); Ok(Json(json!({ "object": "send-fileDownload", "id": file_id, - "url": download_url(&host, &send_id, &file_id).await?, + "url": format!("{}/api/sends/{}/{}?t={}", &host.host, send_id, file_id, token) }))) } -async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result { - let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?; - - if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) { - let token_claims = crate::auth::generate_send_claims(send_id, file_id); - let token = crate::auth::encode_jwt(&token_claims); - - Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", &host.host)) - } else { - Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_mins(5)).await?.uri().to_string()) - } -} - #[get("/sends//?")] -async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option { +async fn download_send(send_id: SafeString, file_id: SafeString, t: &str) -> Option { if let Ok(claims) = crate::auth::decode_send(t) { if claims.sub == format!("{send_id}/{file_id}") { return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok(); @@ -588,18 +582,19 @@ async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option< None } -#[put("/sends/", data = "")] -async fn put_send(send_id: SendId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - enforce_disable_send_policy(&headers, &conn).await?; +#[put("/sends/", data = "")] +async fn put_send(id: &str, data: Json, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult { + enforce_disable_send_policy(&headers, &mut conn).await?; let data: SendData = data.into_inner(); - enforce_disable_hide_email_policy(&data, &headers, &conn).await?; + enforce_disable_hide_email_policy(&data, &headers, &mut conn).await?; - let Some(mut send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else { - err!("Send not found", "Send send_id is invalid or does not belong to user") + let mut send = match Send::find_by_uuid(id, &mut conn).await { + Some(s) => s, + None => err!("Send not found"), }; - update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?; + update_send_from_data(&mut send, data, &headers, &mut conn, &nt, UpdateType::SyncSendUpdate).await?; Ok(Json(send.to_json())) } @@ -608,7 +603,7 @@ pub async fn update_send_from_data( send: &mut Send, data: SendData, headers: &Headers, - conn: &DbConn, + conn: &mut DbConn, nt: &Notify<'_>, ut: UpdateType, ) -> EmptyResult { @@ -657,46 +652,56 @@ pub async fn update_send_from_data( send.save(conn).await?; if ut != UpdateType::None { - nt.send_send_update(ut, send, &send.update_users_revision(conn).await, &headers.device, conn).await; + nt.send_send_update(ut, send, &send.update_users_revision(conn).await, &headers.device.uuid, conn).await; } Ok(()) } -#[delete("/sends/")] -async fn delete_send(send_id: SendId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else { - err!("Send not found", "Invalid send uuid, or does not belong to user") +#[delete("/sends/")] +async fn delete_send(id: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { + let send = match Send::find_by_uuid(id, &mut conn).await { + Some(s) => s, + None => err!("Send not found"), }; - send.delete(&conn).await?; + if send.user_uuid.as_ref() != Some(&headers.user.uuid) { + err!("Send is not owned by user") + } + + send.delete(&mut conn).await?; nt.send_send_update( UpdateType::SyncSendDelete, &send, - &send.update_users_revision(&conn).await, - &headers.device, - &conn, + &send.update_users_revision(&mut conn).await, + &headers.device.uuid, + &mut conn, ) .await; Ok(()) } -#[put("/sends//remove-password")] -async fn put_remove_password(send_id: SendId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - enforce_disable_send_policy(&headers, &conn).await?; +#[put("/sends//remove-password")] +async fn put_remove_password(id: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult { + enforce_disable_send_policy(&headers, &mut conn).await?; - let Some(mut send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else { - err!("Send not found", "Invalid send uuid, or does not belong to user") + let mut send = match Send::find_by_uuid(id, &mut conn).await { + Some(s) => s, + None => err!("Send not found"), }; + if send.user_uuid.as_ref() != Some(&headers.user.uuid) { + err!("Send is not owned by user") + } + send.set_password(None); - send.save(&conn).await?; + send.save(&mut conn).await?; nt.send_send_update( UpdateType::SyncSendUpdate, &send, - &send.update_users_revision(&conn).await, - &headers.device, - &conn, + &send.update_users_revision(&mut conn).await, + &headers.device.uuid, + &mut conn, ) .await; diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index 4759aa3c..9d4bd480 100644 --- a/src/api/core/two_factor/authenticator.rs +++ b/src/api/core/two_factor/authenticator.rs @@ -7,7 +7,7 @@ use crate::{ auth::{ClientIp, Headers}, crypto, db::{ - models::{EventType, TwoFactor, TwoFactorType, UserId}, + models::{EventType, TwoFactor, TwoFactorType}, DbConn, }, util::NumberOrString, @@ -16,28 +16,24 @@ use crate::{ pub use crate::config::CONFIG; pub fn routes() -> Vec { - routes![generate_authenticator, activate_authenticator, activate_authenticator_put, disable_authenticator] + routes![generate_authenticator, activate_authenticator, activate_authenticator_put,] } #[post("/two-factor/get-authenticator", data = "")] -async fn generate_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn generate_authenticator(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - data.validate(&user, false, &conn).await?; + data.validate(&user, false, &mut conn).await?; let type_ = TwoFactorType::Authenticator as i32; - let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await; + let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await; let (enabled, key) = match twofactor { Some(tf) => (true, tf.data), - _ => (false, crypto::encode_random_bytes::<20>(&BASE32)), + _ => (false, crypto::encode_random_bytes::<20>(BASE32)), }; - // Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all. - // It should help prevent TOTP disclosure if someone keeps their vault unlocked. - // Since it doesn't seem to be used, and also does not cause any issues, lets leave it out of the response. - // See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Controllers/TwoFactorController.cs#L94 Ok(Json(json!({ "enabled": enabled, "key": key, @@ -55,7 +51,7 @@ struct EnableAuthenticatorData { } #[post("/two-factor/authenticator", data = "")] -async fn activate_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn activate_authenticator(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: EnableAuthenticatorData = data.into_inner(); let key = data.key; let token = data.token.into_string(); @@ -66,7 +62,7 @@ async fn activate_authenticator(data: Json, headers: He master_password_hash: data.master_password_hash, otp: data.otp, } - .validate(&user, true, &conn) + .validate(&user, true, &mut conn) .await?; // Validate key as base32 and 20 bytes length @@ -80,11 +76,11 @@ async fn activate_authenticator(data: Json, headers: He } // Validate the token provided with the key, and save new twofactor - validate_totp_code(&user.uuid, &token, &key.to_uppercase(), &headers.ip, &conn).await?; + validate_totp_code(&user.uuid, &token, &key.to_uppercase(), &headers.ip, &mut conn).await?; - _generate_recover_code(&mut user, &conn).await; + _generate_recover_code(&mut user, &mut conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &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, &mut conn).await; Ok(Json(json!({ "enabled": true, @@ -99,37 +95,38 @@ async fn activate_authenticator_put(data: Json, headers } pub async fn validate_totp_code_str( - user_id: &UserId, + user_uuid: &str, totp_code: &str, secret: &str, ip: &ClientIp, - conn: &DbConn, + conn: &mut DbConn, ) -> EmptyResult { if !totp_code.chars().all(char::is_numeric) { err!("TOTP code is not a number"); } - validate_totp_code(user_id, totp_code, secret, ip, conn).await + validate_totp_code(user_uuid, totp_code, secret, ip, conn).await } pub async fn validate_totp_code( - user_id: &UserId, + user_uuid: &str, totp_code: &str, secret: &str, ip: &ClientIp, - conn: &DbConn, + conn: &mut DbConn, ) -> EmptyResult { use totp_lite::{totp_custom, Sha1}; - let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else { - err!("Invalid TOTP secret") + let decoded_secret = match BASE32.decode(secret.as_bytes()) { + Ok(s) => s, + Err(_) => err!("Invalid TOTP secret"), }; - 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_string()), - }; + let mut twofactor = + match TwoFactor::find_by_user_and_type(user_uuid, TwoFactorType::Authenticator as i32, conn).await { + Some(tf) => tf, + _ => TwoFactor::new(user_uuid.to_string(), TwoFactorType::Authenticator, secret.to_string()), + }; // The amount of steps back and forward in time // Also check if we need to disable time drifted TOTP codes. @@ -152,7 +149,7 @@ pub async fn validate_totp_code( if generated == totp_code && time_step > twofactor.last_used { // If the step does not equals 0 the time is drifted either server or client side. if step != 0 { - warn!("TOTP Time drift detected. The step offset is {step}"); + warn!("TOTP Time drift detected. The step offset is {}", step); } // Save the last used time step so only totp time steps higher then this one are allowed. @@ -161,7 +158,7 @@ pub async fn validate_totp_code( twofactor.save(conn).await?; return Ok(()); } else if generated == totp_code && time_step <= twofactor.last_used { - warn!("This TOTP or a TOTP code within {steps} steps back or forward has already been used!"); + warn!("This TOTP or a TOTP code within {} steps back or forward has already been used!", steps); err!( format!("Invalid TOTP code! Server time: {} IP: {}", current_time.format("%F %T UTC"), ip.ip), ErrorEvent { @@ -179,41 +176,3 @@ pub async fn validate_totp_code( } ); } - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct DisableAuthenticatorData { - key: String, - master_password_hash: String, - r#type: NumberOrString, -} - -#[delete("/two-factor/authenticator", data = "")] -async fn disable_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let user = headers.user; - let type_ = data.r#type.into_i32()?; - - if !user.check_valid_password(&data.master_password_hash) { - err!("Invalid password"); - } - - 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 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)); - } - } - - 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(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 f2de50c3..6de2935d 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -11,7 +11,7 @@ use crate::{ auth::Headers, crypto, db::{ - models::{EventType, TwoFactor, TwoFactorType, User, UserId}, + models::{EventType, TwoFactor, TwoFactorType, User}, DbConn, }, error::MapResult, @@ -26,8 +26,8 @@ pub fn routes() -> Vec { #[derive(Serialize, Deserialize)] struct DuoData { host: String, // Duo API hostname - ik: String, // client id - sk: String, // client secret + ik: String, // integration key + sk: String, // secret key } impl DuoData { @@ -92,13 +92,13 @@ impl DuoStatus { const DISABLED_MESSAGE_DEFAULT: &str = ""; #[post("/two-factor/get-duo", data = "")] -async fn get_duo(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn get_duo(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - data.validate(&user, false, &conn).await?; + data.validate(&user, false, &mut conn).await?; - let data = get_user_duo_data(&user.uuid, &conn).await; + let data = get_user_duo_data(&user.uuid, &mut conn).await; let (enabled, data) = match data { DuoStatus::Global(_) => (true, Some(DuoData::secret())), @@ -111,16 +111,13 @@ async fn get_duo(data: Json, headers: Headers, conn: DbConn) json!({ "enabled": enabled, "host": data.host, - "clientSecret": data.sk, - "clientId": data.ik, + "secretKey": data.sk, + "integrationKey": data.ik, "object": "twoFactorDuo" }) } else { json!({ "enabled": enabled, - "host": null, - "clientSecret": null, - "clientId": null, "object": "twoFactorDuo" }) }; @@ -132,8 +129,8 @@ async fn get_duo(data: Json, headers: Headers, conn: DbConn) #[serde(rename_all = "camelCase")] struct EnableDuoData { host: String, - client_secret: String, - client_id: String, + secret_key: String, + integration_key: String, master_password_hash: Option, otp: Option, } @@ -142,8 +139,8 @@ impl From for DuoData { fn from(d: EnableDuoData) -> Self { Self { host: d.host, - ik: d.client_id, - sk: d.client_secret, + ik: d.integration_key, + sk: d.secret_key, } } } @@ -154,11 +151,11 @@ fn check_duo_fields_custom(data: &EnableDuoData) -> bool { st.is_empty() || s == DISABLED_MESSAGE_DEFAULT } - !empty_or_default(&data.host) && !empty_or_default(&data.client_secret) && !empty_or_default(&data.client_id) + !empty_or_default(&data.host) && !empty_or_default(&data.secret_key) && !empty_or_default(&data.integration_key) } #[post("/two-factor/duo", data = "")] -async fn activate_duo(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn activate_duo(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: EnableDuoData = data.into_inner(); let mut user = headers.user; @@ -166,7 +163,7 @@ async fn activate_duo(data: Json, headers: Headers, conn: DbConn) master_password_hash: data.master_password_hash.clone(), otp: data.otp.clone(), } - .validate(&user, true, &conn) + .validate(&user, true, &mut conn) .await?; let (data, data_str) = if check_duo_fields_custom(&data) { @@ -180,17 +177,17 @@ async fn activate_duo(data: Json, headers: Headers, conn: DbConn) let type_ = TwoFactorType::Duo; let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str); - twofactor.save(&conn).await?; + twofactor.save(&mut conn).await?; - _generate_recover_code(&mut user, &conn).await; + _generate_recover_code(&mut user, &mut conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &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, &mut conn).await; Ok(Json(json!({ "enabled": true, "host": data.host, - "clientSecret": data.sk, - "clientId": data.ik, + "secretKey": data.sk, + "integrationKey": data.ik, "object": "twoFactorDuo" }))) } @@ -205,7 +202,7 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) use std::str::FromStr; // https://duo.com/docs/authapi#api-details - let url = format!("https://{}{path}", &data.host); + let url = format!("https://{}{}", &data.host, path); let date = Utc::now().to_rfc2822(); let username = &data.ik; let fields = [&date, method, &data.host, path, params]; @@ -231,12 +228,13 @@ 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 { +async fn get_user_duo_data(uuid: &str, conn: &mut DbConn) -> DuoStatus { let type_ = TwoFactorType::Duo as i32; // If the user doesn't have an entry, disabled - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else { - return DuoStatus::Disabled(DuoData::global().is_some()); + let twofactor = match TwoFactor::find_by_user_and_type(uuid, type_, conn).await { + Some(t) => t, + None => return DuoStatus::Disabled(DuoData::global().is_some()), }; // If the user has the required values, we use those @@ -254,17 +252,17 @@ async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus { } // 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)> { +pub(crate) async fn get_duo_keys_email(email: &str, conn: &mut 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, CONFIG.get_duo_akey().await, data.host)) + Ok((data.ik, data.sk, CONFIG.get_duo_akey(), data.host)) } -pub async fn generate_duo_signature(email: &str, conn: &DbConn) -> ApiResult<(String, String)> { +pub async fn generate_duo_signature(email: &str, conn: &mut DbConn) -> ApiResult<(String, String)> { let now = Utc::now().timestamp(); let (ik, sk, ak, host) = get_duo_keys_email(email, conn).await?; @@ -277,12 +275,12 @@ pub async fn generate_duo_signature(email: &str, conn: &DbConn) -> ApiResult<(St 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())); + let cookie = format!("{}|{}", prefix, BASE64.encode(val.as_bytes())); - format!("{cookie}|{}", crypto::hmac_sign(key, &cookie)) + format!("{}|{}", cookie, crypto::hmac_sign(key, &cookie)) } -pub async fn validate_duo_login(email: &str, response: &str, conn: &DbConn) -> EmptyResult { +pub async fn validate_duo_login(email: &str, response: &str, conn: &mut DbConn) -> EmptyResult { let split: Vec<&str> = response.split(':').collect(); if split.len() != 2 { err!( @@ -335,12 +333,14 @@ fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) - err!("Prefixes don't match") } - let Ok(cookie_vec) = BASE64.decode(u_b64.as_bytes()) else { - err!("Invalid Duo cookie encoding") + let cookie_vec = match BASE64.decode(u_b64.as_bytes()) { + Ok(c) => c, + Err(_) => err!("Invalid Duo cookie encoding"), }; - let Ok(cookie) = String::from_utf8(cookie_vec) else { - err!("Invalid Duo cookie encoding") + let cookie = match String::from_utf8(cookie_vec) { + Ok(c) => c, + Err(_) => err!("Invalid Duo cookie encoding"), }; let cookie_split: Vec<&str> = cookie.split('|').collect(); diff --git a/src/api/core/two_factor/duo_oidc.rs b/src/api/core/two_factor/duo_oidc.rs index 144ffe84..d252df91 100644 --- a/src/api/core/two_factor/duo_oidc.rs +++ b/src/api/core/two_factor/duo_oidc.rs @@ -10,7 +10,7 @@ use crate::{ api::{core::two_factor::duo::get_duo_keys_email, EmptyResult}, crypto, db::{ - models::{DeviceId, EventType, TwoFactorDuoContext}, + models::{EventType, TwoFactorDuoContext}, DbConn, DbPool, }, error::Error, @@ -21,7 +21,7 @@ use url::Url; // The location on this service that Duo should redirect users to. For us, this is a bridge // built in to the Bitwarden clients. -// See: https://github.com/bitwarden/clients/blob/5fb46df3415aefced0b52f2db86c873962255448/apps/web/src/connectors/duo-redirect.ts +// See: https://github.com/bitwarden/clients/blob/main/apps/web/src/connectors/duo-redirect.ts const DUO_REDIRECT_LOCATION: &str = "duo-redirect-connector.html"; // Number of seconds that a JWT we generate for Duo should be valid for. @@ -182,7 +182,7 @@ impl DuoClient { HealthCheckResponse::HealthFail { message, message_detail, - } => err!(format!("Duo health check FAIL response, msg: {message}, detail: {message_detail}")), + } => err!(format!("Duo health check FAIL response, msg: {}, detail: {}", message, message_detail)), }; if health_stat != "OK" { @@ -211,7 +211,10 @@ impl DuoClient { nonce, }; - let token = self.encode_duo_jwt(jwt_payload)?; + let token = match self.encode_duo_jwt(jwt_payload) { + Ok(token) => token, + Err(e) => return Err(e), + }; let authz_endpoint = format!("https://{}/oauth/v1/authorize", self.api_host); let mut auth_url = match Url::parse(authz_endpoint.as_str()) { @@ -275,7 +278,7 @@ impl DuoClient { let status_code = res.status(); if status_code != StatusCode::OK { - err!(format!("Failure response from Duo: {status_code}")) + err!(format!("Failure response from Duo: {}", status_code)) } let response: IdTokenResponse = match res.json::().await { @@ -317,7 +320,7 @@ struct DuoAuthContext { // Given a state string, retrieve the associated Duo auth context and // delete the retrieved state from the database. -async fn extract_context(state: &str, conn: &DbConn) -> Option { +async fn extract_context(state: &str, conn: &mut DbConn) -> Option { let ctx: TwoFactorDuoContext = match TwoFactorDuoContext::find_by_state(state, conn).await { Some(c) => c, None => return None, @@ -344,8 +347,8 @@ async fn extract_context(state: &str, conn: &DbConn) -> Option { // Task to clean up expired Duo authentication contexts that may have accumulated in the database. pub async fn purge_duo_contexts(pool: DbPool) { debug!("Purging Duo authentication contexts"); - if let Ok(conn) = pool.get().await { - TwoFactorDuoContext::purge_expired_duo_contexts(&conn).await; + if let Ok(mut conn) = pool.get().await { + TwoFactorDuoContext::purge_expired_duo_contexts(&mut conn).await; } else { error!("Failed to get DB connection while purging expired Duo authentications") } @@ -379,8 +382,8 @@ fn make_callback_url(client_name: &str) -> Result { pub async fn get_duo_auth_url( email: &str, client_id: &str, - device_identifier: &DeviceId, - conn: &DbConn, + device_identifier: &String, + conn: &mut DbConn, ) -> Result { let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?; @@ -417,8 +420,8 @@ pub async fn validate_duo_login( email: &str, two_factor_token: &str, client_id: &str, - device_identifier: &DeviceId, - conn: &DbConn, + device_identifier: &str, + conn: &mut DbConn, ) -> EmptyResult { // Result supplied to us by clients in the form "|" let split: Vec<&str> = two_factor_token.split('|').collect(); @@ -478,7 +481,7 @@ pub async fn validate_duo_login( Err(e) => return Err(e), }; - let d: Digest = digest(&SHA512_256, format!("{}{device_identifier}", ctx.nonce).as_bytes()); + let d: Digest = digest(&SHA512_256, format!("{}{}", ctx.nonce, device_identifier).as_bytes()); let hash: String = HEXLOWER.encode(d.as_ref()); match client.exchange_authz_code_for_result(code, email, hash.as_str()).await { diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index e7d1aed2..293c0671 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -7,10 +7,10 @@ use crate::{ core::{log_user_event, two_factor::_generate_recover_code}, EmptyResult, JsonResult, PasswordOrOtpData, }, - auth::{ClientHeaders, Headers}, + auth::Headers, crypto, db::{ - models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, + models::{EventType, TwoFactor, TwoFactorType, User}, DbConn, }, error::{Error, MapResult}, @@ -24,88 +24,46 @@ pub fn routes() -> Vec { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SendEmailLoginData { - #[serde(alias = "DeviceIdentifier")] - device_identifier: DeviceId, + // DeviceIdentifier: String, // Currently not used #[serde(alias = "Email")] - email: Option, + email: String, #[serde(alias = "MasterPasswordHash")] - master_password_hash: Option, - auth_request_id: Option, - auth_request_access_code: Option, + master_password_hash: String, } /// User is trying to login and wants to use email 2FA. /// Does not require Bearer token #[post("/two-factor/send-email-login", data = "")] // JsonResult -async fn send_email_login(data: Json, client_headers: ClientHeaders, conn: DbConn) -> EmptyResult { +async fn send_email_login(data: Json, mut conn: DbConn) -> EmptyResult { let data: SendEmailLoginData = data.into_inner(); + use crate::db::models::User; + + // Get the user + let user = match User::find_by_mail(&data.email, &mut conn).await { + Some(user) => user, + None => err!("Username or password is incorrect. Try again."), + }; + + // Check password + if !user.check_valid_password(&data.master_password_hash) { + err!("Username or password is incorrect. Try again.") + } + if !CONFIG._enable_email_2fa() { err!("Email 2FA is disabled") } - // Ratelimit the login - crate::ratelimit::check_limit_login(&client_headers.ip.ip)?; + send_token(&user.uuid, &mut conn).await?; - // Get the user - let email = match &data.email { - Some(email) if !email.is_empty() => Some(email), - _ => None, - }; - let master_password_hash = match &data.master_password_hash { - Some(password_hash) if !password_hash.is_empty() => Some(password_hash), - _ => None, - }; - let auth_request_id = match &data.auth_request_id { - Some(auth_request_id) if !auth_request_id.is_empty() => Some(auth_request_id), - _ => None, - }; - - let user = if let Some(email) = email { - let Some(user) = User::find_by_mail(email, &conn).await else { - err!("Username or password is incorrect. Try again.") - }; - - if let Some(master_password_hash) = master_password_hash { - // Check password - if !user.check_valid_password(master_password_hash) { - err!("Username or password is incorrect. Try again.") - } - } 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") - }; - - 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", "Invalid device, IP or code") - } - } else { - err!("No password hash has been submitted.") - } - - user - } else { - // SSO login only sends device id, so we get the user by the most recently used device - let Some(user) = User::find_by_device_for_email2fa(&data.device_identifier, &conn).await else { - err!("Username or password is incorrect. Try again.") - }; - - user - }; - - send_token(&user.uuid, &conn).await + Ok(()) } /// 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 { +pub async fn send_token(user_uuid: &str, conn: &mut DbConn) -> EmptyResult { let type_ = TwoFactorType::Email as i32; - let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?; + let mut twofactor = + TwoFactor::find_by_user_and_type(user_uuid, type_, conn).await.map_res("Two factor not found")?; let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); @@ -121,14 +79,14 @@ pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult { /// When user clicks on Manage email 2FA show the user the related information #[post("/two-factor/get-email", data = "")] -async fn get_email(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn get_email(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - data.validate(&user, false, &conn).await?; + data.validate(&user, false, &mut conn).await?; let (enabled, mfa_email) = - match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await { + match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &mut conn).await { Some(x) => { let twofactor_data = EmailTokenData::from_json(&x.data)?; (true, json!(twofactor_data.email)) @@ -154,7 +112,7 @@ struct SendEmailData { /// Send a verification email to the specified email address to check whether it exists/belongs to user. #[post("/two-factor/send-email", data = "")] -async fn send_email(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +async fn send_email(data: Json, headers: Headers, mut conn: DbConn) -> EmptyResult { let data: SendEmailData = data.into_inner(); let user = headers.user; @@ -162,7 +120,7 @@ async fn send_email(data: Json, headers: Headers, conn: DbConn) - master_password_hash: data.master_password_hash, otp: data.otp, } - .validate(&user, false, &conn) + .validate(&user, false, &mut conn) .await?; if !CONFIG._enable_email_2fa() { @@ -171,8 +129,8 @@ async fn send_email(data: Json, headers: Headers, conn: DbConn) - 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?; + if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await { + tf.delete(&mut conn).await?; } let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); @@ -180,7 +138,7 @@ async fn send_email(data: Json, headers: Headers, conn: DbConn) - // Uses EmailVerificationChallenge as type to show that it's not verified yet. let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json()); - twofactor.save(&conn).await?; + twofactor.save(&mut conn).await?; mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?; @@ -198,7 +156,7 @@ struct EmailData { /// Verify email belongs to user and can be used for 2FA email codes. #[put("/two-factor/email", data = "")] -async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn email(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: EmailData = data.into_inner(); let mut user = headers.user; @@ -207,17 +165,18 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes master_password_hash: data.master_password_hash, otp: data.otp, } - .validate(&user, true, &conn) + .validate(&user, true, &mut conn) .await?; 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")?; + TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await.map_res("Two factor not found")?; let mut email_data = EmailTokenData::from_json(&twofactor.data)?; - let Some(issued_token) = &email_data.last_token else { - err!("No token available") + let issued_token = match &email_data.last_token { + Some(t) => t, + _ => err!("No token available"), }; if !crypto::ct_eq(issued_token, data.token) { @@ -227,11 +186,11 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes email_data.reset_token(); twofactor.atype = TwoFactorType::Email as i32; twofactor.data = email_data.to_json(); - twofactor.save(&conn).await?; + twofactor.save(&mut conn).await?; - _generate_recover_code(&mut user, &conn).await; + _generate_recover_code(&mut user, &mut conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &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, &mut conn).await; Ok(Json(json!({ "email": email_data.email, @@ -241,24 +200,19 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes } /// Validate the email code when used as TwoFactor token mechanism -pub async fn validate_email_code_str( - user_id: &UserId, - token: &str, - data: &str, - ip: &std::net::IpAddr, - conn: &DbConn, -) -> EmptyResult { +pub async fn validate_email_code_str(user_uuid: &str, token: &str, data: &str, conn: &mut DbConn) -> EmptyResult { let mut email_data = EmailTokenData::from_json(data)?; - let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn) + let mut twofactor = TwoFactor::find_by_user_and_type(user_uuid, 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}"), + let issued_token = match &email_data.last_token { + Some(t) => t, + _ => err!( + "No token available", ErrorEvent { event: EventType::UserFailedLogIn2fa } - ) + ), }; if !crypto::ct_eq(issued_token, token) { @@ -270,7 +224,7 @@ pub async fn validate_email_code_str( twofactor.save(conn).await?; err!( - format!("Token is invalid! IP: {ip}"), + "Token is invalid", ErrorEvent { event: EventType::UserFailedLogIn2fa } @@ -330,7 +284,7 @@ impl EmailTokenData { } pub fn add_attempt(&mut self) { - self.attempts = self.attempts.saturating_add(1); + self.attempts += 1; } pub fn to_json(&self) -> String { @@ -346,7 +300,7 @@ impl EmailTokenData { } } -pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult { +pub async fn activate_email_2fa(user: &User, conn: &mut DbConn) -> EmptyResult { if user.verified_at.is_none() { err!("Auto-enabling of email 2FA failed because the users email address has not been verified!"); } @@ -373,11 +327,11 @@ pub fn obscure_email(email: &str) -> String { } }; - format!("{new_name}@{domain}") + format!("{}@{}", new_name, &domain) } -pub async fn find_and_activate_email_2fa(user_id: &UserId, conn: &DbConn) -> EmptyResult { - if let Some(user) = User::find_by_uuid(user_id, conn).await { +pub async fn find_and_activate_email_2fa(user_uuid: &str, conn: &mut DbConn) -> EmptyResult { + if let Some(user) = User::find_by_uuid(user_uuid, conn).await { activate_email_2fa(&user, conn).await } else { err!("User not found!"); diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 3a503a23..e3795eb8 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -1,9 +1,7 @@ use chrono::{TimeDelta, Utc}; use data_encoding::BASE32; -use num_traits::FromPrimitive; use rocket::serde::json::Json; use rocket::Route; -use serde::Deserialize; use serde_json::Value; use crate::{ @@ -11,15 +9,9 @@ use crate::{ core::{log_event, log_user_event}, EmptyResult, JsonResult, PasswordOrOtpData, }, - auth::Headers, + auth::{ClientHeaders, Headers}, crypto, - db::{ - models::{ - DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor, - TwoFactorIncomplete, TwoFactorType, User, UserId, - }, - DbConn, DbPool, - }, + db::{models::*, DbConn, DbPool}, mail, util::NumberOrString, CONFIG, @@ -33,47 +25,11 @@ pub mod protected_actions; pub mod webauthn; pub mod yubikey; -fn has_global_duo_credentials() -> bool { - CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some() -} - -pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data: Option<&str>) -> bool { - #[derive(Deserialize)] - struct DuoProviderData { - host: String, - ik: String, - sk: String, - } - - match provider_type { - TwoFactorType::Authenticator => true, - TwoFactorType::Email => CONFIG._enable_email_2fa(), - TwoFactorType::Duo | TwoFactorType::OrganizationDuo => { - provider_data - .and_then(|raw| serde_json::from_str::(raw).ok()) - .is_some_and(|duo| !duo.host.is_empty() && !duo.ik.is_empty() && !duo.sk.is_empty()) - || has_global_duo_credentials() - } - TwoFactorType::YubiKey => { - CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some() - } - TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(), - TwoFactorType::Remember => !CONFIG.disable_2fa_remember(), - TwoFactorType::RecoveryCode => true, - TwoFactorType::U2f - | TwoFactorType::U2fRegisterChallenge - | TwoFactorType::U2fLoginChallenge - | TwoFactorType::EmailVerificationChallenge - | TwoFactorType::WebauthnRegisterChallenge - | TwoFactorType::WebauthnLoginChallenge - | TwoFactorType::ProtectedActions => false, - } -} - pub fn routes() -> Vec { let mut routes = routes![ get_twofactor, get_recover, + recover, disable_twofactor, disable_twofactor_put, get_device_verification_settings, @@ -90,15 +46,9 @@ pub fn routes() -> Vec { } #[get("/two-factor")] -async fn get_twofactor(headers: Headers, conn: DbConn) -> Json { - let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await; - let twofactors_json: Vec = twofactors - .iter() - .filter_map(|tf| { - let provider_type = TwoFactorType::from_i32(tf.atype)?; - is_twofactor_provider_usable(provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf)) - }) - .collect(); +async fn get_twofactor(headers: Headers, mut conn: DbConn) -> Json { + let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &mut conn).await; + let twofactors_json: Vec = twofactors.iter().map(TwoFactor::to_json_provider).collect(); Json(json!({ "data": twofactors_json, @@ -108,11 +58,11 @@ async fn get_twofactor(headers: Headers, conn: DbConn) -> Json { } #[post("/two-factor/get-recover", data = "")] -async fn get_recover(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn get_recover(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - data.validate(&user, true, &conn).await?; + data.validate(&user, true, &mut conn).await?; Ok(Json(json!({ "code": user.totp_recover, @@ -120,9 +70,58 @@ async fn get_recover(data: Json, headers: Headers, conn: DbCo }))) } -async fn _generate_recover_code(user: &mut User, conn: &DbConn) { +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RecoverTwoFactor { + master_password_hash: String, + email: String, + recovery_code: String, +} + +#[post("/two-factor/recover", data = "")] +async fn recover(data: Json, client_headers: ClientHeaders, mut conn: DbConn) -> JsonResult { + let data: RecoverTwoFactor = data.into_inner(); + + use crate::db::models::User; + + // Get the user + let mut user = match User::find_by_mail(&data.email, &mut conn).await { + Some(user) => user, + None => err!("Username or password is incorrect. Try again."), + }; + + // Check password + if !user.check_valid_password(&data.master_password_hash) { + err!("Username or password is incorrect. Try again.") + } + + // Check if recovery code is correct + if !user.check_valid_recovery_code(&data.recovery_code) { + err!("Recovery code is incorrect. Try again.") + } + + // Remove all twofactors from the user + TwoFactor::delete_all_by_user(&user.uuid, &mut conn).await?; + enforce_2fa_policy(&user, &user.uuid, client_headers.device_type, &client_headers.ip.ip, &mut conn).await?; + + log_user_event( + EventType::UserRecovered2fa as i32, + &user.uuid, + client_headers.device_type, + &client_headers.ip.ip, + &mut conn, + ) + .await; + + // Remove the recovery code, not needed without twofactors + user.totp_recover = None; + user.save(&mut conn).await?; + Ok(Json(Value::Object(serde_json::Map::new()))) +} + +async fn _generate_recover_code(user: &mut User, conn: &mut DbConn) { if user.totp_recover.is_none() { - let totp_recover = crypto::encode_random_bytes::<20>(&BASE32); + let totp_recover = crypto::encode_random_bytes::<20>(BASE32); user.totp_recover = Some(totp_recover); user.save(conn).await.ok(); } @@ -137,7 +136,7 @@ struct DisableTwoFactorData { } #[post("/two-factor/disable", data = "")] -async fn disable_twofactor(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn disable_twofactor(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: DisableTwoFactorData = data.into_inner(); let user = headers.user; @@ -146,19 +145,19 @@ async fn disable_twofactor(data: Json, headers: Headers, c master_password_hash: data.master_password_hash, otp: data.otp, } - .validate(&user, true, &conn) + .validate(&user, true, &mut 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?; - log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await { + twofactor.delete(&mut conn).await?; + log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &mut 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?; + if TwoFactor::find_by_user(&user.uuid, &mut conn).await.is_empty() { + enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn).await?; } Ok(Json(json!({ @@ -175,16 +174,17 @@ async fn disable_twofactor_put(data: Json, headers: Header pub async fn enforce_2fa_policy( user: &User, - act_user_id: &UserId, + act_uuid: &str, device_type: i32, ip: &std::net::IpAddr, - conn: &DbConn, + conn: &mut DbConn, ) -> EmptyResult { - for member in - Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await.into_iter() + for member in UserOrganization::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn) + .await + .into_iter() { // Policy only applies to non-Owner/non-Admin members who have accepted joining the org - if member.atype < MembershipType::Admin { + if member.atype < UserOrgType::Admin { if CONFIG.mail_enabled() { let org = Organization::find_by_uuid(&member.org_uuid, conn).await.unwrap(); mail::send_2fa_removed_from_org(&user.email, &org.name).await?; @@ -197,7 +197,7 @@ pub async fn enforce_2fa_policy( EventType::OrganizationUserRevoked as i32, &member.uuid, &member.org_uuid, - act_user_id, + act_uuid, device_type, ip, conn, @@ -210,16 +210,16 @@ pub async fn enforce_2fa_policy( } pub async fn enforce_2fa_policy_for_org( - org_id: &OrganizationId, - act_user_id: &UserId, + org_uuid: &str, + act_uuid: &str, device_type: i32, ip: &std::net::IpAddr, - conn: &DbConn, + conn: &mut DbConn, ) -> EmptyResult { - let org = Organization::find_by_uuid(org_id, conn).await.unwrap(); - for member in Membership::find_confirmed_by_org(org_id, conn).await.into_iter() { + let org = Organization::find_by_uuid(org_uuid, conn).await.unwrap(); + for member in UserOrganization::find_confirmed_by_org(org_uuid, conn).await.into_iter() { // Don't enforce the policy for Admins and Owners. - if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() { + if member.atype < UserOrgType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() { if CONFIG.mail_enabled() { let user = User::find_by_uuid(&member.user_uuid, conn).await.unwrap(); mail::send_2fa_removed_from_org(&user.email, &org.name).await?; @@ -231,8 +231,8 @@ pub async fn enforce_2fa_policy_for_org( log_event( EventType::OrganizationUserRevoked as i32, &member.uuid, - org_id, - act_user_id, + org_uuid, + act_uuid, device_type, ip, conn, @@ -251,7 +251,7 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) { return; } - let conn = match pool.get().await { + let mut conn = match pool.get().await { Ok(conn) => conn, _ => { error!("Failed to get DB connection in send_incomplete_2fa_notifications()"); @@ -262,9 +262,9 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) { let now = Utc::now().naive_utc(); 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; + let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &mut conn).await; for login in incomplete_logins { - let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found"); + let user = User::find_by_uuid(&login.user_uuid, &mut 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 @@ -279,7 +279,7 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) { .await { Ok(_) => { - if let Err(e) = login.delete(&conn).await { + if let Err(e) = login.delete(&mut conn).await { error!("Error deleting incomplete 2FA record: {e:#?}"); } } diff --git a/src/api/core/two_factor/protected_actions.rs b/src/api/core/two_factor/protected_actions.rs index 800a6cf4..1a1d59c8 100644 --- a/src/api/core/two_factor/protected_actions.rs +++ b/src/api/core/two_factor/protected_actions.rs @@ -1,4 +1,4 @@ -use chrono::{naive::serde::ts_seconds, NaiveDateTime, TimeDelta, Utc}; +use chrono::{DateTime, TimeDelta, Utc}; use rocket::{serde::json::Json, Route}; use crate::{ @@ -6,7 +6,7 @@ use crate::{ auth::Headers, crypto, db::{ - models::{TwoFactor, TwoFactorType, UserId}, + models::{TwoFactor, TwoFactorType}, DbConn, }, error::{Error, MapResult}, @@ -23,17 +23,16 @@ pub struct ProtectedActionData { /// Token issued to validate the protected action pub token: String, /// UNIX timestamp of token issue. - #[serde(with = "ts_seconds")] - pub token_sent: NaiveDateTime, + pub token_sent: i64, // The total amount of attempts - pub attempts: u64, + pub attempts: u8, } impl ProtectedActionData { pub fn new(token: String) -> Self { Self { token, - token_sent: Utc::now().naive_utc(), + token_sent: Utc::now().timestamp(), attempts: 0, } } @@ -51,16 +50,12 @@ impl ProtectedActionData { } pub fn add_attempt(&mut self) { - self.attempts = self.attempts.saturating_add(1); - } - - pub fn time_since_sent(&self) -> TimeDelta { - Utc::now().naive_utc() - self.token_sent + self.attempts += 1; } } #[post("/accounts/request-otp")] -async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult { +async fn request_otp(headers: Headers, mut conn: DbConn) -> EmptyResult { if !CONFIG.mail_enabled() { err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device."); } @@ -68,16 +63,10 @@ async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult { let user = headers.user; // Only one Protected Action per user is allowed to take place, delete the previous one - if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &conn).await + if let Some(pa) = + TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &mut conn).await { - let pa_data = ProtectedActionData::from_json(&pa.data)?; - let elapsed = pa_data.time_since_sent().num_seconds(); - let delay = 30; - if elapsed < delay { - err!(format!("Please wait {} seconds before requesting another code.", (delay - elapsed))); - } - - pa.delete(&conn).await?; + pa.delete(&mut conn).await?; } let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); @@ -85,7 +74,7 @@ async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult { // Uses EmailVerificationChallenge as type to show that it's not verified yet. let twofactor = TwoFactor::new(user.uuid, TwoFactorType::ProtectedActions, pa_data.to_json()); - twofactor.save(&conn).await?; + twofactor.save(&mut conn).await?; mail::send_protected_action_token(&user.email, &pa_data.token).await?; @@ -100,7 +89,7 @@ struct ProtectedActionVerify { } #[post("/accounts/verify-otp", data = "")] -async fn verify_otp(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +async fn verify_otp(data: Json, headers: Headers, mut conn: DbConn) -> EmptyResult { if !CONFIG.mail_enabled() { err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device."); } @@ -110,31 +99,33 @@ async fn verify_otp(data: Json, headers: Headers, conn: D // Delete the token after one validation attempt // This endpoint only gets called for the vault export, and doesn't need a second attempt - validate_protected_action_otp(&data.otp, &user.uuid, true, &conn).await + validate_protected_action_otp(&data.otp, &user.uuid, true, &mut conn).await } pub async fn validate_protected_action_otp( otp: &str, - user_id: &UserId, + user_uuid: &str, delete_if_valid: bool, - conn: &DbConn, + conn: &mut DbConn, ) -> EmptyResult { - let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn) + let pa = TwoFactor::find_by_user_and_type(user_uuid, 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 >= CONFIG.email_attempts_limit() { + pa_data.add_attempt(); + // Delete the token after x attempts if it has been used too many times + // We use the 6, which should be more then enough for invalid attempts and multiple valid checks + if pa_data.attempts > 6 { + pa.delete(conn).await?; err!("Token has expired") } // Check if the token has expired (Using the email 2fa expiration time) + let date = + DateTime::from_timestamp(pa_data.token_sent, 0).expect("Protected Action token timestamp invalid.").naive_utc(); let max_time = CONFIG.email_expiration_time() as i64; - if pa_data.time_since_sent().num_seconds() > max_time { + if date + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() { pa.delete(conn).await?; err!("Token has expired") } diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index ad17ce36..52ca70c4 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -1,47 +1,23 @@ +use rocket::serde::json::Json; +use rocket::Route; +use serde_json::Value; +use url::Url; +use webauthn_rs::{base64_data::Base64UrlSafeData, proto::*, AuthenticationState, RegistrationState, Webauthn}; + use crate::{ api::{ core::{log_user_event, two_factor::_generate_recover_code}, EmptyResult, JsonResult, PasswordOrOtpData, }, auth::Headers, - crypto::ct_eq, db::{ - models::{EventType, TwoFactor, TwoFactorType, UserId}, + models::{EventType, TwoFactor, TwoFactorType}, DbConn, }, error::Error, util::NumberOrString, CONFIG, }; -use rocket::serde::json::Json; -use rocket::Route; -use serde_json::Value; -use std::str::FromStr; -use std::sync::LazyLock; -use std::time::Duration; -use url::Url; -use uuid::Uuid; -use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration}; -use webauthn_rs::{Webauthn, WebauthnBuilder}; -use webauthn_rs_proto::{ - AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, - PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs, - RequestAuthenticationExtensions, UserVerificationPolicy, -}; - -static WEBAUTHN: LazyLock = LazyLock::new(|| { - let domain = CONFIG.domain(); - let domain_origin = CONFIG.domain_origin(); - let rp_id = Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(); - let rp_origin = Url::parse(&domain_origin).unwrap(); - - let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin) - .expect("Creating WebauthnBuilder failed") - .rp_name(&domain) - .timeout(Duration::from_mins(1)); - - webauthn.build().expect("Building Webauthn failed") -}); pub fn routes() -> Vec { routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,] @@ -69,13 +45,52 @@ pub struct U2FRegistration { pub migrated: Option, } +struct WebauthnConfig { + url: String, + origin: Url, + rpid: String, +} + +impl WebauthnConfig { + fn load() -> Webauthn { + let domain = CONFIG.domain(); + let domain_origin = CONFIG.domain_origin(); + Webauthn::new(Self { + rpid: Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(), + url: domain, + origin: Url::parse(&domain_origin).unwrap(), + }) + } +} + +impl webauthn_rs::WebauthnConfig for WebauthnConfig { + fn get_relying_party_name(&self) -> &str { + &self.url + } + + fn get_origin(&self) -> &Url { + &self.origin + } + + fn get_relying_party_id(&self) -> &str { + &self.rpid + } + + /// We have WebAuthn configured to discourage user verification + /// if we leave this enabled, it will cause verification issues when a keys send UV=1. + /// Upstream (the library they use) ignores this when set to discouraged, so we should too. + fn get_require_uv_consistency(&self) -> bool { + false + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct WebauthnRegistration { pub id: i32, pub name: String, pub migrated: bool, - pub credential: Passkey, + pub credential: Credential, } impl WebauthnRegistration { @@ -86,38 +101,20 @@ impl WebauthnRegistration { "migrated": self.migrated, }) } - - fn set_backup_eligible(&mut self, backup_eligible: bool, backup_state: bool) -> bool { - let mut changed = false; - let mut cred: Credential = self.credential.clone().into(); - - if cred.backup_state != backup_state { - cred.backup_state = backup_state; - changed = true; - } - - if backup_eligible && !cred.backup_eligible { - cred.backup_eligible = true; - changed = true; - } - - self.credential = cred.into(); - changed - } } #[post("/two-factor/get-webauthn", data = "")] -async fn get_webauthn(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - if !CONFIG.is_webauthn_2fa_supported() { - err!("Configured `DOMAIN` is not compatible with Webauthn") +async fn get_webauthn(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { + if !CONFIG.domain_set() { + err!("`DOMAIN` environment variable is not set. Webauthn disabled") } let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - data.validate(&user, false, &conn).await?; + data.validate(&user, false, &mut conn).await?; - let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?; + let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &mut conn).await?; let registrations_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ @@ -128,39 +125,30 @@ async fn get_webauthn(data: Json, headers: Headers, conn: DbC } #[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, mut conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - data.validate(&user, false, &conn).await?; + data.validate(&user, false, &mut conn).await?; - let registrations = get_webauthn_registrations(&user.uuid, &conn) + let registrations = get_webauthn_registrations(&user.uuid, &mut conn) .await? .1 .into_iter() - .map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering + .map(|r| r.credential.cred_id) // We return the credentialIds to the clients to avoid double registering .collect(); - 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(), + let (challenge, state) = WebauthnConfig::load().generate_challenge_register_options( + user.uuid.as_bytes().to_vec(), + user.email, + user.name, Some(registrations), + None, + None, )?; - let mut state = serde_json::to_value(&state)?; - state["rs"]["policy"] = Value::String("discouraged".to_string()); - state["rs"]["extensions"].as_object_mut().unwrap().clear(); - let type_ = TwoFactorType::WebauthnRegisterChallenge; - TwoFactor::new(user.uuid.clone(), type_, serde_json::to_string(&state)?).save(&conn).await?; - - // Because for this flow we abuse the passkeys as 2FA, and use it more like a securitykey - // we need to modify some of the default settings defined by `start_passkey_registration()`. - challenge.public_key.extensions = None; - if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() { - asc.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; - } + TwoFactor::new(user.uuid, type_, serde_json::to_string(&state)?).save(&mut conn).await?; let mut challenge_value = serde_json::to_value(challenge.public_key)?; challenge_value["status"] = "ok".into(); @@ -205,10 +193,8 @@ impl From for RegisterPublicKeyCredential { response: AuthenticatorAttestationResponseRaw { attestation_object: r.response.attestation_object, client_data_json: r.response.client_data_json, - transports: None, }, type_: r.r#type, - extensions: RegistrationExtensionsClientOutputs::default(), } } } @@ -219,7 +205,7 @@ pub struct PublicKeyCredentialCopy { pub id: String, pub raw_id: Base64UrlSafeData, pub response: AuthenticatorAssertionResponseRawCopy, - pub extensions: AuthenticationExtensionsClientOutputs, + pub extensions: Option, pub r#type: String, } @@ -252,7 +238,7 @@ impl From for PublicKeyCredential { } #[post("/two-factor/webauthn", data = "")] -async fn activate_webauthn(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn activate_webauthn(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: EnableWebauthnData = data.into_inner(); let mut user = headers.user; @@ -260,24 +246,25 @@ async fn activate_webauthn(data: Json, headers: Headers, con master_password_hash: data.master_password_hash, otp: data.otp, } - .validate(&user, true, &conn) + .validate(&user, true, &mut conn) .await?; // Retrieve and delete the saved challenge state let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; - let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await { Some(tf) => { - let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; - tf.delete(&conn).await?; + let state: RegistrationState = serde_json::from_str(&tf.data)?; + tf.delete(&mut conn).await?; state } None => err!("Can't recover challenge"), }; // Verify the credentials with the saved state - let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; + let (credential, _data) = + WebauthnConfig::load().register_credential(&data.device_response.into(), &state, |_| Ok(false))?; - let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1; + let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &mut conn).await?.1; // TODO: Check for repeated ID's registrations.push(WebauthnRegistration { id: data.id.into_i32()?, @@ -289,11 +276,11 @@ async fn activate_webauthn(data: Json, headers: Headers, con // Save the registrations and return them TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?) - .save(&conn) + .save(&mut conn) .await?; - _generate_recover_code(&mut user, &conn).await; + _generate_recover_code(&mut user, &mut conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &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, &mut conn).await; let keys_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ @@ -316,42 +303,44 @@ struct DeleteU2FData { } #[delete("/two-factor/webauthn", data = "")] -async fn delete_webauthn(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn delete_webauthn(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let id = data.id.into_i32()?; if !headers.user.check_valid_password(&data.master_password_hash) { err!("Invalid password"); } - 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 tf = + match TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &mut conn).await { + Some(tf) => tf, + None => err!("Webauthn data not found!"), + }; let mut data: Vec = serde_json::from_str(&tf.data)?; - let Some(item_pos) = data.iter().position(|r| r.id == id) else { - err!("Webauthn entry not found") + let item_pos = match data.iter().position(|r| r.id == id) { + Some(p) => p, + None => err!("Webauthn entry not found"), }; let removed_item = data.remove(item_pos); tf.data = serde_json::to_string(&data)?; - tf.save(&conn).await?; + tf.save(&mut conn).await?; drop(tf); // If entry is migrated from u2f, delete the u2f entry as well - if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await + if let Some(mut u2f) = + TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &mut conn).await { let mut data: Vec = match serde_json::from_str(&u2f.data) { Ok(d) => d, Err(_) => err!("Error parsing U2F data"), }; - data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); + data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id); let new_data_str = serde_json::to_string(&data)?; u2f.data = new_data_str; - u2f.save(&conn).await?; + u2f.save(&mut conn).await?; } let keys_json: Vec = data.iter().map(WebauthnRegistration::to_json).collect(); @@ -364,49 +353,31 @@ async fn delete_webauthn(data: Json, headers: Headers, conn: DbCo } pub async fn get_webauthn_registrations( - user_id: &UserId, - conn: &DbConn, + user_uuid: &str, + conn: &mut DbConn, ) -> Result<(bool, Vec), Error> { let type_ = TwoFactorType::Webauthn as i32; - match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { + match TwoFactor::find_by_user_and_type(user_uuid, 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 } } -pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonResult { +pub async fn generate_webauthn_login(user_uuid: &str, conn: &mut DbConn) -> JsonResult { // Load saved credentials - let creds: Vec = - get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect(); + let creds: Vec = + get_webauthn_registrations(user_uuid, conn).await?.1.into_iter().map(|r| r.credential).collect(); if creds.is_empty() { err!("No Webauthn devices registered") } // Generate a challenge based on the credentials - let (mut response, state) = WEBAUTHN.start_passkey_authentication(&creds)?; - - // Modify to discourage user verification - let mut state = serde_json::to_value(&state)?; - state["ast"]["policy"] = Value::String("discouraged".to_string()); - - // Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well - let app_id = format!("{}/app-id.json", &CONFIG.domain()); - state["ast"]["appid"] = Value::String(app_id.clone()); - - response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; - response - .public_key - .extensions - .get_or_insert(RequestAuthenticationExtensions { - appid: None, - uvm: None, - hmac_get_secret: None, - }) - .appid = Some(app_id); + let ext = RequestAuthenticationExtensions::builder().appid(format!("{}/app-id.json", &CONFIG.domain())).build(); + let (response, state) = WebauthnConfig::load().generate_challenge_authenticate_options(creds, Some(ext))?; // Save the challenge state for later validation - TwoFactor::new(user_id.clone(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?) + TwoFactor::new(user_uuid.into(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?) .save(conn) .await?; @@ -414,11 +385,11 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes Ok(Json(serde_json::to_value(response.public_key)?)) } -pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult { +pub async fn validate_webauthn_login(user_uuid: &str, response: &str, conn: &mut DbConn) -> EmptyResult { let type_ = TwoFactorType::WebauthnLoginChallenge as i32; - let mut state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { + let state = match TwoFactor::find_by_user_and_type(user_uuid, type_, conn).await { Some(tf) => { - let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?; + let state: AuthenticationState = serde_json::from_str(&tf.data)?; tf.delete(conn).await?; state } @@ -433,25 +404,19 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?; let rsp: PublicKeyCredential = rsp.into(); - let mut registrations = get_webauthn_registrations(user_id, conn).await?.1; + let mut registrations = get_webauthn_registrations(user_uuid, conn).await?.1; - // We need to check for and update the backup_eligible flag when needed. - // Vaultwarden did not have knowledge of this flag prior to migrating to webauthn-rs v0.5.x - // Because of this we check the flag at runtime and update the registrations and state when needed - let backup_flags_updated = check_and_update_backup_eligible(&rsp, &mut registrations, &mut state)?; - - let authentication_result = WEBAUTHN.finish_passkey_authentication(&rsp, &state)?; + // If the credential we received is migrated from U2F, enable the U2F compatibility + //let use_u2f = registrations.iter().any(|r| r.migrated && r.credential.cred_id == rsp.raw_id.0); + let (cred_id, auth_data) = WebauthnConfig::load().authenticate_credential(&rsp, &state)?; for reg in &mut registrations { - if ct_eq(reg.credential.cred_id(), authentication_result.cred_id()) { - // If the cred id matches and the credential is updated, Some(true) is returned - // In those cases, update the record, else leave it alone - let credential_updated = reg.credential.update_credential(&authentication_result) == Some(true); - if credential_updated || backup_flags_updated { - TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?) - .save(conn) - .await?; - } + if ®.credential.cred_id == cred_id { + reg.credential.counter = auth_data.counter; + + TwoFactor::new(user_uuid.to_string(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?) + .save(conn) + .await?; return Ok(()); } } @@ -463,56 +428,3 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db } ) } - -fn check_and_update_backup_eligible( - rsp: &PublicKeyCredential, - registrations: &mut Vec, - state: &mut PasskeyAuthentication, -) -> Result { - // The feature flags from the response - // For details see: https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data - const FLAG_BACKUP_ELIGIBLE: u8 = 0b0000_1000; - const FLAG_BACKUP_STATE: u8 = 0b0001_0000; - - if let Some(bits) = rsp.response.authenticator_data.get(32) { - let backup_eligible = 0 != (bits & FLAG_BACKUP_ELIGIBLE); - let backup_state = 0 != (bits & FLAG_BACKUP_STATE); - - // If the current key is backup eligible, then we probably need to update one of the keys already stored in the database - // This is needed because Vaultwarden didn't store this information when using the previous version of webauthn-rs since it was a new addition to the protocol - // Because we store multiple keys in one json string, we need to fetch the correct key first, and update its information before we let it verify - if backup_eligible { - let rsp_id = rsp.raw_id.as_slice(); - for reg in &mut *registrations { - if ct_eq(reg.credential.cred_id().as_slice(), rsp_id) { - if reg.set_backup_eligible(backup_eligible, backup_state) { - // We also need to adjust the current state which holds the challenge used to start the authentication verification - // Because Vaultwarden supports multiple keys, we need to loop through the deserialized state and check which key to update - let mut raw_state = serde_json::to_value(&state)?; - if let Some(credentials) = raw_state - .get_mut("ast") - .and_then(|v| v.get_mut("credentials")) - .and_then(|v| v.as_array_mut()) - { - for cred in credentials.iter_mut() { - if cred.get("cred_id").is_some_and(|v| { - // Deserialize to a [u8] so it can be compared using `ct_eq` with the `rsp_id` - let cred_id_slice: Base64UrlSafeData = serde_json::from_value(v.clone()).unwrap(); - ct_eq(cred_id_slice, rsp_id) - }) { - cred["backup_eligible"] = Value::Bool(backup_eligible); - cred["backup_state"] = Value::Bool(backup_state); - } - } - } - - *state = serde_json::from_value(raw_state)?; - return Ok(true); - } - break; - } - } - } - } - Ok(false) -} diff --git a/src/api/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs index 1cf11255..b2940353 100644 --- a/src/api/core/two_factor/yubikey.rs +++ b/src/api/core/two_factor/yubikey.rs @@ -83,19 +83,19 @@ async fn verify_yubikey_otp(otp: String) -> EmptyResult { } #[post("/two-factor/get-yubikey", data = "")] -async fn generate_yubikey(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn generate_yubikey(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { // Make sure the credentials are set get_yubico_credentials()?; let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - data.validate(&user, false, &conn).await?; + data.validate(&user, false, &mut conn).await?; - let user_id = &user.uuid; + let user_uuid = &user.uuid; let yubikey_type = TwoFactorType::YubiKey as i32; - let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await; + let r = TwoFactor::find_by_user_and_type(user_uuid, yubikey_type, &mut conn).await; if let Some(r) = r { let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; @@ -116,7 +116,7 @@ async fn generate_yubikey(data: Json, headers: Headers, conn: } #[post("/two-factor/yubikey", data = "")] -async fn activate_yubikey(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn activate_yubikey(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: EnableYubikeyData = data.into_inner(); let mut user = headers.user; @@ -124,12 +124,12 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: master_password_hash: data.master_password_hash.clone(), otp: data.otp.clone(), } - .validate(&user, true, &conn) + .validate(&user, true, &mut 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 { + match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &mut conn).await { Some(data) => data, None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()), }; @@ -145,14 +145,15 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: // Ensure they are valid OTPs for yubikey in &yubikeys { - if yubikey.is_empty() || yubikey.len() == 12 { + if yubikey.len() == 12 { + // YubiKey ID continue; } verify_yubikey_otp(yubikey.to_owned()).await.map_res("Invalid Yubikey OTP provided")?; } - let yubikey_ids: Vec = yubikeys.into_iter().filter_map(|x| x.get(..12).map(str::to_owned)).collect(); + let yubikey_ids: Vec = yubikeys.into_iter().map(|x| (x[..12]).to_owned()).collect(); let yubikey_metadata = YubikeyMetadata { keys: yubikey_ids, @@ -160,11 +161,11 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: }; yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap(); - yubikey_data.save(&conn).await?; + yubikey_data.save(&mut conn).await?; - _generate_recover_code(&mut user, &conn).await; + _generate_recover_code(&mut user, &mut conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &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, &mut conn).await; let mut result = jsonify_yubikeys(yubikey_metadata.keys); diff --git a/src/api/icons.rs b/src/api/icons.rs index 5c9ed113..6afbaa9f 100644 --- a/src/api/icons.rs +++ b/src/api/icons.rs @@ -1,25 +1,29 @@ use std::{ collections::HashMap, net::IpAddr, - sync::{Arc, LazyLock}, + sync::Arc, time::{Duration, SystemTime}, }; use bytes::{Bytes, BytesMut}; use futures::{stream::StreamExt, TryFutureExt}; -use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer}; +use once_cell::sync::Lazy; use regex::Regex; use reqwest::{ header::{self, HeaderMap, HeaderValue}, Client, Response, }; use rocket::{http::ContentType, response::Redirect, Route}; -use svg_hush::{data_url_filter, Filter}; +use tokio::{ + fs::{create_dir_all, remove_file, symlink_metadata, File}, + io::{AsyncReadExt, AsyncWriteExt}, +}; + +use html5gum::{Emitter, HtmlString, InfallibleTokenizer, Readable, StringReader, Tokenizer}; use crate::{ - config::PathType, error::Error, - http_client::{get_reqwest_client_builder, get_valid_host, should_block_host, CustomHttpClientError}, + http_client::{get_reqwest_client_builder, should_block_address, CustomHttpClientError}, util::Cached, CONFIG, }; @@ -31,32 +35,14 @@ pub fn routes() -> Vec { } } -static CLIENT: LazyLock = LazyLock::new(|| { +static CLIENT: Lazy = Lazy::new(|| { // Generate the default headers let mut default_headers = HeaderMap::new(); - default_headers.insert( - header::USER_AGENT, - HeaderValue::from_static( - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", - ), - ); - default_headers.insert(header::ACCEPT, HeaderValue::from_static("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")); - default_headers.insert(header::ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9")); + default_headers.insert(header::USER_AGENT, HeaderValue::from_static("Links (2.22; Linux X86_64; GNU C; text)")); + default_headers.insert(header::ACCEPT, HeaderValue::from_static("text/html, text/*;q=0.5, image/*, */*;q=0.1")); + default_headers.insert(header::ACCEPT_LANGUAGE, HeaderValue::from_static("en,*;q=0.1")); default_headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); default_headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache")); - default_headers.insert(header::UPGRADE_INSECURE_REQUESTS, HeaderValue::from_static("1")); - - default_headers.insert("Sec-Ch-Ua-Mobile", HeaderValue::from_static("?0")); - default_headers.insert("Sec-Ch-Ua-Platform", HeaderValue::from_static("Linux")); - default_headers.insert( - "Sec-Ch-Ua", - HeaderValue::from_static("\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\""), - ); - - default_headers.insert("Sec-Fetch-Site", HeaderValue::from_static("none")); - default_headers.insert("Sec-Fetch-Mode", HeaderValue::from_static("navigate")); - default_headers.insert("Sec-Fetch-User", HeaderValue::from_static("?1")); - default_headers.insert("Sec-Fetch-Dest", HeaderValue::from_static("document")); // Generate the cookie store let cookie_store = Arc::new(Jar::default()); @@ -70,31 +56,27 @@ static CLIENT: LazyLock = LazyLock::new(|| { .pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections .pool_idle_timeout(pool_idle_timeout) // Configure the Hyper Pool to timeout after 10 seconds .default_headers(default_headers.clone()) - .http1_title_case_headers() .build() .expect("Failed to build client") }); // Build Regex only once since this takes a lot of time. -static ICON_SIZE_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap()); +static ICON_SIZE_REGEX: Lazy = Lazy::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap()); -// The function name `icon_external` is checked in the `on_response` function in `AppHeaders` -// It is used to prevent sending a specific header which breaks icon downloads. -// If this function needs to be renamed, also adjust the code in `util.rs` -#[get("//icon.png")] -fn icon_external(host: &str) -> Cached> { - let Ok(host) = get_valid_host(host) else { - warn!("Invalid host: {host}"); - return Cached::ttl(None, CONFIG.icon_cache_negttl(), true); - }; - - if should_block_host(&host).is_err() { - warn!("Blocked address: {host}"); - return Cached::ttl(None, CONFIG.icon_cache_negttl(), true); +#[get("//icon.png")] +fn icon_external(domain: &str) -> Option { + if !is_valid_domain(domain) { + warn!("Invalid domain: {}", domain); + return None; } - let url = CONFIG._icon_service_url().replace("{}", &host.to_string()); - let redir = match CONFIG.icon_redirect_code() { + if should_block_address(domain) { + warn!("Blocked address: {}", domain); + return None; + } + + let url = CONFIG._icon_service_url().replace("{}", domain); + match CONFIG.icon_redirect_code() { 301 => Some(Redirect::moved(url)), // legacy permanent redirect 302 => Some(Redirect::found(url)), // legacy temporary redirect 307 => Some(Redirect::temporary(url)), @@ -103,25 +85,15 @@ fn icon_external(host: &str) -> Cached> { error!("Unexpected redirect code {}", CONFIG.icon_redirect_code()); None } - }; - Cached::ttl(redir, CONFIG.icon_cache_ttl(), true) + } } -#[get("//icon.png")] -async fn icon_internal(host: &str) -> Cached<(ContentType, Vec)> { +#[get("//icon.png")] +async fn icon_internal(domain: &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}"); - return Cached::ttl( - (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), - CONFIG.icon_cache_negttl(), - true, - ); - }; - - if should_block_host(&host).is_err() { - warn!("Blocked address: {host}"); + if !is_valid_domain(domain) { + warn!("Invalid domain: {}", domain); return Cached::ttl( (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), CONFIG.icon_cache_negttl(), @@ -129,7 +101,16 @@ async fn icon_internal(host: &str) -> Cached<(ContentType, Vec)> { ); } - match get_icon(&host.to_string()).await { + if should_block_address(domain) { + warn!("Blocked address: {}", domain); + return Cached::ttl( + (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), + CONFIG.icon_cache_negttl(), + true, + ); + } + + match get_icon(domain).await { Some((icon, icon_type)) => { Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true) } @@ -137,8 +118,45 @@ async fn icon_internal(host: &str) -> Cached<(ContentType, Vec)> { } } +/// Returns if the domain provided is valid or not. +/// +/// This does some manual checks and makes use of Url to do some basic checking. +/// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255. +fn is_valid_domain(domain: &str) -> bool { + const ALLOWED_CHARS: &str = "_-."; + + // If parsing the domain fails using Url, it will not work with reqwest. + if let Err(parse_error) = url::Url::parse(format!("https://{domain}").as_str()) { + debug!("Domain parse error: '{}' - {:?}", domain, parse_error); + return false; + } else if domain.is_empty() + || domain.contains("..") + || domain.starts_with('.') + || domain.starts_with('-') + || domain.ends_with('-') + { + debug!( + "Domain validation error: '{}' is either empty, contains '..', starts with an '.', starts or ends with a '-'", + domain + ); + return false; + } else if domain.len() > 255 { + debug!("Domain validation error: '{}' exceeds 255 characters", domain); + return false; + } + + for c in domain.chars() { + if !c.is_alphanumeric() && !ALLOWED_CHARS.contains(c) { + debug!("Domain validation error: '{}' contains an invalid character '{}'", domain, c); + return false; + } + } + + true +} + async fn get_icon(domain: &str) -> Option<(Vec, String)> { - let path = format!("{domain}.png"); + let path = format!("{}/{}.png", CONFIG.icon_cache_folder(), domain); // Check for expiration of negatively cached copy if icon_is_negcached(&path).await { @@ -146,7 +164,10 @@ async fn get_icon(domain: &str) -> Option<(Vec, String)> { } if let Some(icon) = get_cached_icon(&path).await { - let icon_type = get_icon_type(&icon).unwrap_or("x-icon"); + let icon_type = match get_icon_type(&icon) { + Some(x) => x, + _ => "x-icon", + }; return Some((icon, icon_type.to_string())); } @@ -157,7 +178,7 @@ async fn get_icon(domain: &str) -> Option<(Vec, String)> { // Get the icon, or None in case of error match download_icon(domain).await { Ok((icon, icon_type)) => { - save_icon(&path, icon.to_vec()).await; + save_icon(&path, &icon).await; Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_string())) } Err(e) => { @@ -168,9 +189,9 @@ async fn get_icon(domain: &str) -> Option<(Vec, String)> { return None; } - warn!("Unable to download icon: {e:?}"); + warn!("Unable to download icon: {:?}", e); let miss_indicator = path + ".miss"; - save_icon(&miss_indicator, vec![]).await; + save_icon(&miss_indicator, &[]).await; None } } @@ -183,9 +204,11 @@ async fn get_cached_icon(path: &str) -> Option> { } // Try to read the cached icon, and return it if it exists - if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache) { - if let Ok(buf) = operator.read(path).await { - return Some(buf.to_vec()); + if let Ok(mut f) = File::open(path).await { + let mut buffer = Vec::new(); + + if f.read_to_end(&mut buffer).await.is_ok() { + return Some(buffer); } } @@ -193,11 +216,9 @@ async fn get_cached_icon(path: &str) -> Option> { } 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())?; + let meta = symlink_metadata(path).await?; + let modified = meta.modified()?; + let age = SystemTime::now().duration_since(modified)?; Ok(ttl > 0 && ttl <= age.as_secs()) } @@ -209,13 +230,8 @@ async fn icon_is_negcached(path: &str) -> bool { match expired { // No longer negatively cached, drop the marker Ok(true) => { - match CONFIG.opendal_operator_for_path_type(&PathType::IconCache) { - Ok(operator) => { - if let Err(e) = operator.delete(&miss_indicator).await { - error!("Could not remove negative cache indicator for icon {path:?}: {e:?}"); - } - } - Err(e) => error!("Could not remove negative cache indicator for icon {path:?}: {e:?}"), + if let Err(e) = remove_file(&miss_indicator).await { + error!("Could not remove negative cache indicator for icon {:?}: {:?}", path, e); } false } @@ -245,7 +261,11 @@ impl Icon { } } -fn get_favicons_node(dom: Tokenizer, FaviconEmitter>, icons: &mut Vec, url: &url::Url) { +fn get_favicons_node( + dom: InfallibleTokenizer, FaviconEmitter>, + icons: &mut Vec, + url: &url::Url, +) { const TAG_LINK: &[u8] = b"link"; const TAG_BASE: &[u8] = b"base"; const TAG_HEAD: &[u8] = b"head"; @@ -254,7 +274,7 @@ fn get_favicons_node(dom: Tokenizer, FaviconEmitter>, icons: &m let mut base_url = url.clone(); let mut icon_tags: Vec = Vec::new(); - for Ok(token) in dom { + for token in dom { let tag_name: &[u8] = &token.tag.name; match tag_name { TAG_LINK => { @@ -275,7 +295,9 @@ fn get_favicons_node(dom: Tokenizer, FaviconEmitter>, icons: &m TAG_HEAD if token.closing => { break; } - _ => {} + _ => { + continue; + } } } @@ -301,7 +323,7 @@ struct IconUrlResult { /// Returns a IconUrlResult which holds a Vector IconList and a string which holds the referer. /// There will always two items within the iconlist which holds http(s)://domain.tld/favicon.ico. -/// This does not mean that location exists, but (it) is the default location the browser uses. +/// This does not mean that that location does exists, but it is the default location browser use. /// /// # Argument /// * `domain` - A string which holds the domain with extension. @@ -331,7 +353,7 @@ async fn get_icon_url(domain: &str) -> Result { tld = domain_parts.next_back().unwrap(), base = domain_parts.next_back().unwrap() ); - if get_valid_host(&base_domain).is_ok() { + if is_valid_domain(&base_domain) { let sslbase = format!("https://{base_domain}"); let httpbase = format!("http://{base_domain}"); debug!("[get_icon_url]: Trying without subdomains '{base_domain}'"); @@ -342,7 +364,7 @@ async fn get_icon_url(domain: &str) -> Result { // When the domain is not an IP, and has less then 2 dots, try to add www. infront of it. } else if is_ip.is_err() && domain.matches('.').count() < 2 { let www_domain = format!("www.{domain}"); - if get_valid_host(&www_domain).is_ok() { + if is_valid_domain(&www_domain) { let sslwww = format!("https://{www_domain}"); let httpwww = format!("http://{www_domain}"); debug!("[get_icon_url]: Trying with www. prefix '{www_domain}'"); @@ -379,7 +401,7 @@ async fn get_icon_url(domain: &str) -> Result { // 384KB should be more than enough for the HTML, though as we only really need the HTML header. let limited_reader = stream_to_bytes_limit(content, 384 * 1024).await?.to_vec(); - let dom = Tokenizer::new_with_emitter(limited_reader.to_reader(), FaviconEmitter::default()); + let dom = Tokenizer::new_with_emitter(limited_reader.to_reader(), FaviconEmitter::default()).infallible(); get_favicons_node(dom, &mut iconlist, &url); } else { // Add the default favicon.ico to the list with just the given domain @@ -425,8 +447,8 @@ async fn get_page_with_referer(url: &str, referer: &str) -> Result u8 { - static PRIORITY_MAP: LazyLock> = - LazyLock::new(|| [(".png", 10), (".jpg", 20), (".jpeg", 20)].into_iter().collect()); + static PRIORITY_MAP: Lazy> = + Lazy::new(|| [(".png", 10), (".jpg", 20), (".jpeg", 20)].into_iter().collect()); // Check if there is a dimension set let (width, height) = parse_sizes(sizes); @@ -477,11 +499,13 @@ fn parse_sizes(sizes: &str) -> (u16, u16) { if !sizes.is_empty() { match ICON_SIZE_REGEX.captures(sizes.trim()) { - Some(dimensions) if dimensions.len() >= 3 => { - width = dimensions[1].parse::().unwrap_or_default(); - height = dimensions[2].parse::().unwrap_or_default(); + None => {} + Some(dimensions) => { + if dimensions.len() >= 3 { + width = dimensions[1].parse::().unwrap_or_default(); + height = dimensions[2].parse::().unwrap_or_default(); + } } - _ => {} } } @@ -496,8 +520,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { use data_url::DataUrl; - let mut icons = icon_result.iconlist.iter().take(5).peekable(); - while let Some(icon) = icons.next() { + for icon in icon_result.iconlist.iter().take(5) { if icon.href.starts_with("data:image") { let Ok(datauri) = DataUrl::process(&icon.href) else { continue; @@ -514,10 +537,10 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { // Check if the icon type is allowed, else try an icon from the list. icon_type = get_icon_type(&body); if icon_type.is_none() { - debug!("Icon from {domain} data:image uri, is not a valid image type"); + debug!("Icon from {} data:image uri, is not a valid image type", domain); continue; } - info!("Extracted icon from data:image uri for {domain}"); + info!("Extracted icon from data:image uri for {}", domain); buffer = body.freeze(); break; } @@ -525,23 +548,11 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { _ => debug!("Extracted icon from data:image uri is invalid"), }; } else { - debug!("Trying {}", icon.href); - // Make sure all icons are checked before returning error - let res = match get_page_with_referer(&icon.href, &icon_result.referer).await { - Ok(r) => r, - Err(e) if icons.peek().is_none() => return Err(e), - Err(e) if CustomHttpClientError::downcast_ref(&e).is_some() => return Err(e), // If blacklisted stop immediately instead of checking the rest of the icons. see explanation and actual handling inside get_icon() - Err(e) => { - warn!("Unable to download icon: {e:?}"); - - // Continue to next icon - continue; - } - }; + let res = get_page_with_referer(&icon.href, &icon_result.referer).await?; buffer = stream_to_bytes_limit(res, 5120 * 1024).await?; // 5120KB/5MB for each icon max (Same as icons.bitwarden.net) - // Check if the icon type is allowed, else try another icon from the list. + // Check if the icon type is allowed, else try an icon from the list. icon_type = get_icon_type(&buffer); if icon_type.is_none() { buffer.clear(); @@ -555,58 +566,33 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { if buffer.is_empty() { err_silent!("Empty response or unable find a valid icon", domain); - } else if icon_type == Some("svg+xml") { - let mut svg_filter = Filter::new(); - svg_filter.set_data_url_filter(data_url_filter::allow_standard_images); - let mut sanitized_svg = Vec::new(); - if svg_filter.filter(&*buffer, &mut sanitized_svg).is_err() { - icon_type = None; - buffer.clear(); - } else { - buffer = sanitized_svg.into(); - } } Ok((buffer, icon_type)) } -async fn save_icon(path: &str, icon: Vec) { - let operator = match CONFIG.opendal_operator_for_path_type(&PathType::IconCache) { - Ok(operator) => operator, - Err(e) => { - warn!("Failed to get OpenDAL operator while saving icon: {e}"); - return; +async fn save_icon(path: &str, icon: &[u8]) { + match File::create(path).await { + Ok(mut f) => { + f.write_all(icon).await.expect("Error writing icon file"); + } + Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => { + create_dir_all(&CONFIG.icon_cache_folder()).await.expect("Error creating icon cache folder"); + } + Err(e) => { + warn!("Unable to save icon: {:?}", e); } - }; - - if let Err(e) = operator.write(path, icon).await { - warn!("Unable to save icon: {e:?}"); } } fn get_icon_type(bytes: &[u8]) -> Option<&'static str> { - fn check_svg_after_xml_declaration(bytes: &[u8]) -> Option<&'static str> { - // Look for SVG tag within the first 1KB - if let Ok(content) = std::str::from_utf8(&bytes[..bytes.len().min(1024)]) { - if content.contains(" Some("png"), - [0, 0, 1, 0, n1, n2, ..] if u16::from_le_bytes([*n1, *n2]) > 0 => Some("x-icon"), // https://en.wikipedia.org/wiki/ICO_(file_format) - [82, 73, 70, 70, _, _, _, _, 87, 69, 66, 80, ..] => Some("webp"), // Only match WebP Images - [255, 216, 255, b, ..] if *b >= 0xC0 => Some("jpeg"), - [71, 73, 70, 56, 55 | 57, 97, ..] => Some("gif"), - [66, 77, _, _, _, _, 0, 0, 0, 0, ..] => Some("bmp"), // https://en.wikipedia.org/wiki/BMP_file_format - [60, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg - [60, 63, 120, 109, 108, ..] => check_svg_after_xml_declaration(bytes), // An svg starting with Some("png"), + [0, 0, 1, 0, ..] => Some("x-icon"), + [82, 73, 70, 70, ..] => Some("webp"), + [255, 216, 255, ..] => Some("jpeg"), + [71, 73, 70, 56, ..] => Some("gif"), + [66, 77, ..] => Some("bmp"), _ => None, } } @@ -618,12 +604,6 @@ async fn stream_to_bytes_limit(res: Response, max_size: usize) -> Result Option { self.flush_current_attribute(true); self.last_start_tag.clear(); - match &self.current_token { - Some(token) if !token.closing => { - self.last_start_tag.extend(&*token.tag.name); - } - _ => {} + if self.current_token.is_some() && !self.current_token.as_ref().unwrap().closing { + self.last_start_tag.extend(&*self.current_token.as_ref().unwrap().tag.name); } html5gum::naive_next_state(&self.last_start_tag) } diff --git a/src/api/identity.rs b/src/api/identity.rs index 569deaf9..672f128c 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,10 +1,8 @@ use chrono::Utc; use num_traits::FromPrimitive; +use rocket::serde::json::Json; use rocket::{ form::{Form, FromForm}, - http::{Cookie, CookieJar, SameSite}, - response::Redirect, - serde::json::Json, Route, }; use serde_json::Value; @@ -12,119 +10,82 @@ use serde_json::Value; use crate::{ api::{ core::{ - accounts::{_prelogin, _register, kdf_upgrade, PreloginData, RegisterData}, + accounts::{PreloginData, RegisterData, _prelogin, _register}, log_user_event, - two_factor::{ - authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, - yubikey, - }, + two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey}, }, - master_password_policy, push::register_push_device, ApiResult, EmptyResult, JsonResult, }, - auth, - auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure}, - crypto, - db::{ - models::{ - AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey, - OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId, - }, - DbConn, - }, + auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp}, + db::{models::*, DbConn}, error::MapResult, - mail, sso, - sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, - util, CONFIG, + mail, util, CONFIG, }; pub fn routes() -> Vec { - routes![ - login, - prelogin, - prelogin_password, - identity_register, - register_verification_email, - register_finish, - prevalidate, - authorize, - oidcsignin, - oidcsignin_error - ] + routes![login, prelogin, identity_register] } #[post("/connect/token", data = "")] -async fn login( - data: Form, - client_header: ClientHeaders, - client_version: Option, - conn: DbConn, -) -> JsonResult { +async fn login(data: Form, client_header: ClientHeaders, mut conn: DbConn) -> JsonResult { let data: ConnectData = data.into_inner(); - let mut user_id: Option = None; + let mut user_uuid: Option = None; let login_result = match data.grant_type.as_ref() { "refresh_token" => { - _check_is_some(data.refresh_token.as_ref(), "refresh_token cannot be blank")?; - _refresh_login(data, &conn, &client_header.ip).await + _check_is_some(&data.refresh_token, "refresh_token cannot be blank")?; + _refresh_login(data, &mut conn).await } - "password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"), "password" => { - _check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; - _check_is_some(data.password.as_ref(), "password cannot be blank")?; - _check_is_some(data.scope.as_ref(), "scope cannot be blank")?; - _check_is_some(data.username.as_ref(), "username cannot be blank")?; + _check_is_some(&data.client_id, "client_id cannot be blank")?; + _check_is_some(&data.password, "password cannot be blank")?; + _check_is_some(&data.scope, "scope cannot be blank")?; + _check_is_some(&data.username, "username cannot be blank")?; - _check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?; - _check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; - _check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; + _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; + _check_is_some(&data.device_name, "device_name cannot be blank")?; + _check_is_some(&data.device_type, "device_type cannot be blank")?; - _password_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await + _password_login(data, &mut user_uuid, &mut conn, &client_header.ip).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")?; + _check_is_some(&data.client_id, "client_id cannot be blank")?; + _check_is_some(&data.client_secret, "client_secret cannot be blank")?; + _check_is_some(&data.scope, "scope cannot be blank")?; - _check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?; - _check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; - _check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; + _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; + _check_is_some(&data.device_name, "device_name cannot be blank")?; + _check_is_some(&data.device_type, "device_type cannot be blank")?; - _api_key_login(data, &mut user_id, &conn, &client_header.ip).await + _api_key_login(data, &mut user_uuid, &mut conn, &client_header.ip).await } - "authorization_code" if CONFIG.sso_enabled() => { - _check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; - _check_is_some(data.code.as_ref(), "code cannot be blank")?; - _check_is_some(data.code_verifier.as_ref(), "code verifier cannot be blank")?; - - _check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?; - _check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; - _check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; - - _sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await - } - "authorization_code" => err!("SSO sign-in is not available"), t => err!("Invalid type", t), }; - if let Some(user_id) = user_id { + if let Some(user_uuid) = user_uuid { match &login_result { Ok(_) => { log_user_event( EventType::UserLoggedIn as i32, - &user_id, + &user_uuid, client_header.device_type, &client_header.ip.ip, - &conn, + &mut conn, ) .await; } Err(e) => { if let Some(ev) = e.get_event() { - log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn) - .await + log_user_event( + ev.event as i32, + &user_uuid, + client_header.device_type, + &client_header.ip.ip, + &mut conn, + ) + .await } } } @@ -133,275 +94,94 @@ async fn login( login_result } -async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult { - // When a refresh token is invalid or missing we need to respond with an HTTP BadRequest (400) - // It also needs to return a json which holds at least a key `error` with the value `invalid_grant` - // See the link below for details - // https://github.com/bitwarden/clients/blob/2ee158e720a5e7dbe3641caf80b569e97a1dd91b/libs/common/src/services/api.service.ts#L1786-L1797 +async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult { + // Extract token + let token = data.refresh_token.unwrap(); - let Some(refresh_token) = data.refresh_token else { - err_json!(json!({"error": "invalid_grant"}), "Missing refresh_token") - }; + // Get device by refresh token + let mut device = Device::find_by_refresh_token(&token, conn).await.map_res("Invalid refresh token")?; + let scope = "api offline_access"; + let scope_vec = vec!["api".into(), "offline_access".into()]; + + // Common + let user = User::find_by_uuid(&device.user_uuid, conn).await.unwrap(); // --- // Disabled this variable, it was used to generate the JWT // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out // See: https://github.com/dani-garcia/vaultwarden/issues/4156 // --- - // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; - match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await { - Err(err) => { - err_json!( - json!({"error": "invalid_grant"}), - format!("Unable to refresh login credentials: {}", err.message()) - ) - } - Ok((mut device, auth_tokens)) => { - // Save to update `device.updated_at` to track usage and toggle new status - device.save(true, conn).await?; + // let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await; + let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); + device.save(conn).await?; - let result = json!({ - "refresh_token": auth_tokens.refresh_token(), - "access_token": auth_tokens.access_token(), - "expires_in": auth_tokens.expires_in(), - "token_type": "Bearer", - "scope": auth_tokens.scope(), - }); + let result = json!({ + "access_token": access_token, + "expires_in": expires_in, + "token_type": "Bearer", + "refresh_token": device.refresh_token, - Ok(Json(result)) - } - } + "scope": scope, + }); + + Ok(Json(result)) } -// After exchanging the code we need to check first if 2FA is needed before continuing -async fn _sso_login( - data: ConnectData, - user_id: &mut Option, - conn: &DbConn, - ip: &ClientIp, - client_version: Option<&ClientVersion>, -) -> JsonResult { - AuthMethod::Sso.check_scope(data.scope.as_ref())?; - - // Ratelimit the login - crate::ratelimit::check_limit_login(&ip.ip)?; - - let (state, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) { - (None, _) => err!( - "Got no code in OIDC data", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ), - (_, None) => err!( - "Got no code verifier in OIDC data", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ), - (Some(code), Some(code_verifier)) => (code, code_verifier.clone()), - }; - - let (sso_auth, user_infos) = sso::exchange_code(state, code_verifier, conn).await?; - let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await { - None => match SsoUser::find_by_mail(&user_infos.email, conn).await { - None => None, - Some((user, Some(_))) => { - error!( - "Login failure ({}), existing SSO user ({}) with same email ({})", - user_infos.identifier, user.uuid, user.email - ); - err_silent!( - "Existing SSO user with same email", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) - } - Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => { - error!( - "Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled", - user_infos.identifier, user.uuid, user.email - ); - err_silent!( - "Existing non SSO user with same email", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) - } - Some((user, None)) => match user_infos.email_verified { - None if !CONFIG.sso_allow_unknown_email_verification() => { - error!( - "Login failure ({}), existing non SSO user ({}) with same email ({}) and email verification status is unknown", - user_infos.identifier, user.uuid, user.email - ); - err_silent!( - "Email verification status is unknown", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) - } - Some(false) => { - error!( - "Login failure ({}), existing non SSO user ({}) with same email ({}) and email is not verified", - user_infos.identifier, user.uuid, user.email - ); - err_silent!( - "Email is not verified by the SSO provider", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) - } - _ => Some((user, None)), - }, - }, - Some((user, sso_user)) => Some((user, Some(sso_user))), - }; - - let now = Utc::now().naive_utc(); - // Will trigger 2FA flow if needed - let (user, mut device, twofactor_token, sso_user) = match user_with_sso { - None => { - if !CONFIG.is_email_domain_allowed(&user_infos.email) { - err!( - "Email domain not allowed", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ); - } - - match user_infos.email_verified { - None if !CONFIG.sso_allow_unknown_email_verification() => err!( - "Your provider does not send email verification status.\n\ - You will need to change the server configuration (check `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`) to log in.", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ), - Some(false) => err!( - "You need to verify your email with your provider before you can log in", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ), - _ => (), - } - - let mut user = User::new(&user_infos.email, user_infos.user_name.clone()); - user.verified_at = Some(now); - user.save(conn).await?; - - let device = get_device(&data, conn, &user).await?; - - (user, device, None, None) - } - Some((user, _)) if !user.enabled => { - err!( - "This user has been disabled", - format!("IP: {}. Username: {}.", ip.ip, user.display_name()), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) - } - Some((mut user, sso_user)) => { - let mut device = get_device(&data, conn, &user).await?; - - let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; - - if user.private_key.is_none() { - // User was invited a stub was created - user.verified_at = Some(now); - if let Some(ref user_name) = user_infos.user_name { - user.name = user_name.clone(); - } - - user.save(conn).await?; - } - - if user.email != user_infos.email { - if CONFIG.mail_enabled() { - mail::send_sso_change_email(&user_infos.email).await?; - } - info!("User {} email changed in SSO provider from {} to {}", user.uuid, user.email, user_infos.email); - } - - (user, device, twofactor_token, sso_user) - } - }; - - // Set the user_uuid here to be passed back used for event logging. - *user_id = Some(user.uuid.clone()); - - // We passed 2FA get auth tokens - let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?; - - authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await +#[derive(Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct MasterPasswordPolicy { + min_complexity: u8, + min_length: u32, + require_lower: bool, + require_upper: bool, + require_numbers: bool, + require_special: bool, + enforce_on_login: bool, } async fn _password_login( data: ConnectData, - user_id: &mut Option, - conn: &DbConn, + user_uuid: &mut Option, + conn: &mut DbConn, ip: &ClientIp, - client_version: Option<&ClientVersion>, ) -> JsonResult { // Validate scope - AuthMethod::Password.check_scope(data.scope.as_ref())?; + let scope = data.scope.as_ref().unwrap(); + if scope != "api offline_access" { + err!("Scope not supported") + } + let scope_vec = vec!["api".into(), "offline_access".into()]; // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; // Get the user let username = data.username.as_ref().unwrap().trim(); - let Some(mut user) = User::find_by_mail(username, conn).await else { - err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {username}.", ip.ip)) + let mut user = match User::find_by_mail(username, conn).await { + Some(user) => user, + None => err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {}.", ip.ip, username)), }; - // 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 - } - ) - } + // Set the user_uuid here to be passed back used for event logging. + *user_uuid = Some(user.uuid.clone()); + // Check password 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 - if let Some(ref auth_request_id) = data.auth_request { - let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, conn).await else { + if let Some(auth_request_uuid) = data.auth_request.clone() { + if let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_uuid.as_str(), conn).await { + if !auth_request.check_access_code(password) { + err!( + "Username or access code is incorrect. Try again", + format!("IP: {}. Username: {}.", ip.ip, username), + ErrorEvent { + event: EventType::UserFailedLogIn, + } + ) + } + } else { err!( "Auth request not found. Try again.", - format!("IP: {}. Username: {username}.", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn, - } - ) - }; - - let expiration_time = auth_request.creation_date + chrono::Duration::minutes(5); - let request_expired = Utc::now().naive_utc() >= expiration_time; - - if auth_request.user_uuid != user.uuid - || !auth_request.approved.unwrap_or(false) - || 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: {username}.", ip.ip), + format!("IP: {}. Username: {}.", ip.ip, username), ErrorEvent { event: EventType::UserFailedLogIn, } @@ -410,16 +190,32 @@ async fn _password_login( } else if !user.check_valid_password(password) { err!( "Username or password is incorrect. Try again", - format!("IP: {}. Username: {username}.", ip.ip), + format!("IP: {}. Username: {}.", ip.ip, username), ErrorEvent { event: EventType::UserFailedLogIn, } ) } - // Change the KDF Iterations (only when not logging in with an auth request) - if data.auth_request.is_none() { - kdf_upgrade(&mut user, password, conn).await?; + // Change the KDF Iterations + if user.password_iterations != CONFIG.password_iterations() { + user.password_iterations = CONFIG.password_iterations(); + user.set_password(password, None, false, None); + + if let Err(e) = user.save(conn).await { + error!("Error updating user: {:#?}", e); + } + } + + // Check if the user is disabled + if !user.enabled { + err!( + "This user has been disabled", + format!("IP: {}. Username: {}.", ip.ip, username), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) } let now = Utc::now().naive_utc(); @@ -437,11 +233,11 @@ async fn _password_login( user.login_verify_count += 1; if let Err(e) = user.save(conn).await { - error!("Error updating user: {e:#?}"); + error!("Error updating user: {:#?}", e); } if let Err(e) = mail::send_verify_email(&user.email, &user.uuid).await { - error!("Error auto-sending email verification email: {e:#?}"); + error!("Error auto-sending email verification email: {:#?}", e); } } } @@ -449,34 +245,20 @@ async fn _password_login( // We still want the login to fail until they actually verified the email address err!( "Please verify your email before trying again.", - format!("IP: {}. Username: {username}.", ip.ip), + format!("IP: {}. Username: {}.", ip.ip, username), ErrorEvent { event: EventType::UserFailedLogIn } ) } - let mut device = get_device(&data, conn, &user).await?; + let (mut device, new_device) = get_device(&data, conn, &user).await; - let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; + let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, 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 -} - -async fn authenticated_response( - user: &User, - device: &mut Device, - auth_tokens: auth::AuthTokens, - twofactor_token: Option, - conn: &DbConn, - ip: &ClientIp, -) -> JsonResult { - if CONFIG.mail_enabled() && device.is_new() { - let now = Utc::now().naive_utc(); - if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, device).await { - error!("Error sending new device email: {e:#?}"); + if CONFIG.mail_enabled() && new_device { + if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await { + error!("Error sending new device email: {:#?}", e); if CONFIG.require_device_email() { err!( @@ -490,53 +272,59 @@ async fn authenticated_response( } // register push device - if !device.is_new() { - register_push_device(device, conn).await?; + if !new_device { + register_push_device(&mut device, conn).await?; } - // Save to update `device.updated_at` to track usage and toggle new status - device.save(true, conn).await?; + // Common + // --- + // Disabled this variable, it was used to generate the JWT + // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out + // See: https://github.com/dani-garcia/vaultwarden/issues/4156 + // --- + // let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await; + let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); + device.save(conn).await?; - let master_password_policy = master_password_policy(user, conn).await; + // Fetch all valid Master Password Policies and merge them into one with all true's and larges numbers as one policy + let master_password_policies: Vec = + OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy( + &user.uuid, + OrgPolicyType::MasterPassword, + conn, + ) + .await + .into_iter() + .filter_map(|p| serde_json::from_str(&p.data).ok()) + .collect(); - let has_master_password = !user.password_hash.is_empty(); - let master_password_unlock = if has_master_password { - json!({ - "Kdf": { - "KdfType": user.client_kdf_type, - "Iterations": user.client_kdf_iter, - "Memory": user.client_kdf_memory, - "Parallelism": user.client_kdf_parallelism - }, - // This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps. - // 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.email - }) + let master_password_policy = if !master_password_policies.is_empty() { + let mut mpp_json = json!(master_password_policies.into_iter().reduce(|acc, policy| { + MasterPasswordPolicy { + min_complexity: acc.min_complexity.max(policy.min_complexity), + min_length: acc.min_length.max(policy.min_length), + require_lower: acc.require_lower || policy.require_lower, + require_upper: acc.require_upper || policy.require_upper, + require_numbers: acc.require_numbers || policy.require_numbers, + require_special: acc.require_special || policy.require_special, + enforce_on_login: acc.enforce_on_login || policy.enforce_on_login, + } + })); + mpp_json["object"] = json!("masterPasswordPolicy"); + mpp_json } else { - Value::Null - }; - - 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 + json!({"object": "masterPasswordPolicy"}) }; let mut result = json!({ - "access_token": auth_tokens.access_token(), - "expires_in": auth_tokens.expires_in(), + "access_token": access_token, + "expires_in": expires_in, "token_type": "Bearer", - "refresh_token": auth_tokens.refresh_token(), + "refresh_token": device.refresh_token, + "Key": user.akey, "PrivateKey": user.private_key, + //"TwoFactorToken": "11122233333444555666777888999" + "Kdf": user.client_kdf_type, "KdfIterations": user.client_kdf_iter, "KdfMemory": user.client_kdf_memory, @@ -544,57 +332,58 @@ async fn authenticated_response( "ResetMasterPassword": false, // TODO: Same as above "ForcePasswordReset": false, "MasterPasswordPolicy": master_password_policy, - "scope": auth_tokens.scope(), - "AccountKeys": account_keys, + + "scope": scope, "UserDecryptionOptions": { - "HasMasterPassword": has_master_password, - "MasterPasswordUnlock": master_password_unlock, + "HasMasterPassword": !user.password_hash.is_empty(), "Object": "userDecryptionOptions" }, }); - if !user.akey.is_empty() { - result["Key"] = Value::String(user.akey.clone()); - } - if let Some(token) = twofactor_token { result["TwoFactorToken"] = Value::String(token); } - info!("User {} logged in successfully. IP: {}", user.display_name(), ip.ip); + info!("User {} logged in successfully. IP: {}", username, ip.ip); Ok(Json(result)) } -async fn _api_key_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { +async fn _api_key_login( + data: ConnectData, + user_uuid: &mut Option, + conn: &mut 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() => _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, + match data.scope.as_ref().unwrap().as_ref() { + "api" => _user_api_key_login(data, user_uuid, conn, ip).await, + "api.organization" => _organization_api_key_login(data, conn, ip).await, _ => err!("Scope not supported"), } } async fn _user_api_key_login( data: ConnectData, - user_id: &mut Option, - conn: &DbConn, + user_uuid: &mut Option, + conn: &mut DbConn, ip: &ClientIp, ) -> JsonResult { // 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 { - err!("Malformed client_id", format!("IP: {}.", ip.ip)) + let client_user_uuid = match client_id.strip_prefix("user.") { + Some(uuid) => uuid, + None => err!("Malformed client_id", format!("IP: {}.", ip.ip)), }; - let client_user_id: UserId = client_user_id.into(); - let Some(user) = User::find_by_uuid(&client_user_id, conn).await else { - err!("Invalid client_id", format!("IP: {}.", ip.ip)) + let user = match User::find_by_uuid(client_user_uuid, conn).await { + Some(user) => user, + None => err!("Invalid client_id", format!("IP: {}.", ip.ip)), }; - // Set the user_id here to be passed back used for event logging. - *user_id = Some(user.uuid.clone()); + // Set the user_uuid here to be passed back used for event logging. + *user_uuid = Some(user.uuid.clone()); // Check if the user is disabled if !user.enabled { @@ -619,12 +408,12 @@ async fn _user_api_key_login( ) } - let mut device = get_device(&data, conn, &user).await?; + let (mut device, new_device) = get_device(&data, conn, &user).await; - if CONFIG.mail_enabled() && device.is_new() { + if CONFIG.mail_enabled() && new_device { let now = Utc::now().naive_utc(); if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await { - error!("Error sending new device email: {e:#?}"); + error!("Error sending new device email: {:#?}", e); if CONFIG.require_device_email() { err!( @@ -637,56 +426,24 @@ async fn _user_api_key_login( } } + // Common + let scope_vec = vec!["api".into()]; // --- // Disabled this variable, it was used to generate the JWT // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out // See: https://github.com/dani-garcia/vaultwarden/issues/4156 // --- - // let orgs = Membership::find_confirmed_by_user(&user.uuid, conn).await; - let access_claims = auth::LoginJwtClaims::default(&device, &user, &AuthMethod::UserApiKey, data.client_id); - - // Save to update `device.updated_at` to track usage and toggle new status - device.save(true, conn).await?; + // let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await; + let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); + device.save(conn).await?; info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); - let has_master_password = !user.password_hash.is_empty(); - let master_password_unlock = if has_master_password { - json!({ - "Kdf": { - "KdfType": user.client_kdf_type, - "Iterations": user.client_kdf_iter, - "Memory": user.client_kdf_memory, - "Parallelism": user.client_kdf_parallelism - }, - // This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps. - // 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.email - }) - } else { - Value::Null - }; - - 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. let result = json!({ - "access_token": access_claims.token(), - "expires_in": access_claims.expires_in(), + "access_token": access_token, + "expires_in": expires_in, "token_type": "Bearer", "Key": user.akey, "PrivateKey": user.private_key, @@ -696,28 +453,22 @@ async fn _user_api_key_login( "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, - "UserDecryptionOptions": { - "HasMasterPassword": has_master_password, - "MasterPasswordUnlock": master_password_unlock, - "Object": "userDecryptionOptions" - }, + "scope": "api", }); Ok(Json(result)) } -async fn _organization_api_key_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult { +async fn _organization_api_key_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) -> JsonResult { // Get the org via the client_id let client_id = data.client_id.as_ref().unwrap(); - let Some(org_id) = client_id.strip_prefix("organization.") else { - err!("Malformed client_id", format!("IP: {}.", ip.ip)) + let org_uuid = match client_id.strip_prefix("organization.") { + Some(uuid) => uuid, + None => err!("Malformed client_id", format!("IP: {}.", ip.ip)), }; - let org_id: OrganizationId = org_id.to_string().into(); - let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, conn).await else { - err!("Invalid client_id", format!("IP: {}.", ip.ip)) + let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_uuid, conn).await { + Some(org_api_key) => org_api_key, + None => err!("Invalid client_id", format!("IP: {}.", ip.ip)), }; // Check API key. @@ -727,43 +478,43 @@ async fn _organization_api_key_login(data: ConnectData, conn: &DbConn, ip: &Clie } let claim = generate_organization_api_key_login_claims(org_api_key.uuid, org_api_key.org_uuid); - let access_token = auth::encode_jwt(&claim); + let access_token = crate::auth::encode_jwt(&claim); Ok(Json(json!({ "access_token": access_token, "expires_in": 3600, "token_type": "Bearer", - "scope": AuthMethod::OrgApiKey.scope(), + "scope": "api.organization", }))) } /// Retrieves an existing device or creates a new device from ConnectData and the User -async fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> ApiResult { +async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> (Device, bool) { // On iOS, device_type sends "iOS", on others it sends a number // When unknown or unable to parse, return 14, which is 'Unknown Browser' let device_type = util::try_parse_string(data.device_type.as_ref()).unwrap_or(14); let device_id = data.device_identifier.clone().expect("No device id provided"); let device_name = data.device_name.clone().expect("No device name provided"); + let mut new_device = false; // Find device or create new - match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { - Some(device) => Ok(device), + let device = match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { + Some(device) => device, None => { - let mut device = Device::new(device_id, user.uuid.clone(), device_name, device_type); - // save device without updating `device.updated_at` - device.save(false, conn).await?; - Ok(device) + new_device = true; + Device::new(device_id, user.uuid.clone(), device_name, device_type) } - } + }; + + (device, new_device) } async fn twofactor_auth( - user: &mut User, + user: &User, data: &ConnectData, device: &mut Device, ip: &ClientIp, - client_version: Option<&ClientVersion>, - conn: &DbConn, + conn: &mut DbConn, ) -> ApiResult> { let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; @@ -775,35 +526,13 @@ async fn twofactor_auth( TwoFactorIncomplete::mark_incomplete(&user.uuid, &device.uuid, &device.name, device.atype, ip, conn).await?; - let twofactor_ids: Vec<_> = twofactors - .iter() - .filter_map(|tf| { - let provider_type = TwoFactorType::from_i32(tf.atype)?; - (tf.enabled && is_twofactor_provider_usable(provider_type, Some(&tf.data))).then_some(tf.atype) - }) - .collect(); - if twofactor_ids.is_empty() { - err!("No enabled and usable two factor providers are available for this account") - } - + let twofactor_ids: Vec<_> = twofactors.iter().map(|tf| tf.atype).collect(); let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one - // Ignore Remember and RecoveryCode Types during this check, these are special - if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id) - && !twofactor_ids.contains(&selected_id) - { - err_json!( - _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, - "Invalid two factor provider" - ) - } let twofactor_code = match data.two_factor_token { Some(ref code) => code, None => { - err_json!( - _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, - "2FA token not provided" - ) + err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA token not provided") } }; @@ -812,6 +541,7 @@ async fn twofactor_auth( use crate::crypto::ct_eq; let selected_data = _selected_data(selected_twofactor); + let mut remember = data.two_factor_remember.unwrap_or(0); match TwoFactorType::from_i32(selected_id) { Some(TwoFactorType::Authenticator) => { @@ -839,47 +569,22 @@ async fn twofactor_auth( } } Some(TwoFactorType::Email) => { - email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await? + email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, conn).await? } + Some(TwoFactorType::Remember) => { match device.twofactor_remember { - // When a 2FA Remember token is used, check and validate this JWT token, if it is valid, just continue - // If it is invalid we need to trigger the 2FA Login prompt - Some(ref token) - if !CONFIG.disable_2fa_remember() - && (ct_eq(token, twofactor_code) - && auth::decode_2fa_remember(twofactor_code) - .is_ok_and(|t| t.sub == device.uuid && t.user_uuid == user.uuid)) => {} + Some(ref code) if !CONFIG.disable_2fa_remember() && ct_eq(code, twofactor_code) => { + remember = 1; // Make sure we also return the token here, otherwise it will only remember the first time + } _ => { - // Always delete the current twofactor remember token here if it exists - if device.twofactor_remember.is_some() { - device.delete_twofactor_remember(); - // We need to save here, since we send a err_json!() which prevents saving `device` at a later stage - device.save(true, conn).await?; - } err_json!( - _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, - "2FA Remember token not provided or expired" + _json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, + "2FA Remember token not provided" ) } } } - Some(TwoFactorType::RecoveryCode) => { - // 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 - TwoFactor::delete_all_by_user(&user.uuid, conn).await?; - enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?; - - log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await; - - // Remove the recovery code, not needed without twofactors - user.totp_recover = None; - user.save(conn).await?; - } _ => err!( "Invalid two factor provider", ErrorEvent { @@ -890,13 +595,12 @@ async fn twofactor_auth( TwoFactorIncomplete::mark_complete(&user.uuid, &device.uuid, conn).await?; - let remember = data.two_factor_remember.unwrap_or(0); - let two_factor = if !CONFIG.disable_2fa_remember() && remember == 1 { - Some(device.refresh_twofactor_remember()) + if !CONFIG.disable_2fa_remember() && remember == 1 { + Ok(Some(device.refresh_twofactor_remember())) } else { - None - }; - Ok(two_factor) + device.delete_twofactor_remember(); + Ok(None) + } } fn _selected_data(tf: Option) -> ApiResult { @@ -905,10 +609,9 @@ fn _selected_data(tf: Option) -> ApiResult { async fn _json_err_twofactor( providers: &[i32], - user_id: &UserId, + user_uuid: &str, data: &ConnectData, - client_version: Option<&ClientVersion>, - conn: &DbConn, + conn: &mut DbConn, ) -> ApiResult { let mut result = json!({ "error" : "invalid_grant", @@ -926,13 +629,13 @@ async fn _json_err_twofactor( match TwoFactorType::from_i32(*provider) { Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ } - Some(TwoFactorType::Webauthn) if CONFIG.is_webauthn_2fa_supported() => { - let request = webauthn::generate_webauthn_login(user_id, conn).await?; + Some(TwoFactorType::Webauthn) if CONFIG.domain_set() => { + let request = webauthn::generate_webauthn_login(user_uuid, conn).await?; result["TwoFactorProviders2"][provider.to_string()] = request.0; } Some(TwoFactorType::Duo) => { - let email = match User::find_by_uuid(user_id, conn).await { + let email = match User::find_by_uuid(user_uuid, conn).await { Some(u) => u.email, None => err!("User does not exist"), }; @@ -964,8 +667,9 @@ async fn _json_err_twofactor( } Some(tf_type @ TwoFactorType::YubiKey) => { - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { - err!("No YubiKey devices registered") + let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, tf_type as i32, conn).await { + Some(tf) => tf, + None => err!("No YubiKey devices registered"), }; let yubikey_metadata: yubikey::YubikeyMetadata = serde_json::from_str(&twofactor.data)?; @@ -976,21 +680,14 @@ async fn _json_err_twofactor( } Some(tf_type @ TwoFactorType::Email) => { - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { - err!("No twofactor email registered") + let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, tf_type as i32, conn).await { + Some(tf) => tf, + None => 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? + // Send email immediately if email is the only 2FA option + if providers.len() == 1 { + email::send_token(user_uuid, conn).await? } let email_data = email::EmailTokenData::from_json(&twofactor.data)?; @@ -1011,75 +708,9 @@ async fn prelogin(data: Json, conn: DbConn) -> Json { _prelogin(data, conn).await } -#[post("/accounts/prelogin/password", data = "")] -async fn prelogin_password(data: Json, conn: DbConn) -> Json { - _prelogin(data, 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 { - email: String, - name: Option, - // receiveMarketingEmails: bool, -} - -#[derive(rocket::Responder)] -enum RegisterVerificationResponse { - #[response(status = 204)] - NoContent(()), - Token(Json), -} - -#[post("/accounts/register/send-verification-email", data = "")] -async fn register_verification_email( - data: Json, - conn: DbConn, -) -> ApiResult { - let data = data.into_inner(); - - // the registration can only continue if signup is allowed or there exists an invitation - if !(CONFIG.is_signup_allowed(&data.email) - || (!CONFIG.mail_enabled() && Invitation::find_by_mail(&data.email, &conn).await.is_some())) - { - err!("Registration not allowed or user already exists") - } - - let should_send_mail = CONFIG.mail_enabled() && CONFIG.signups_verify(); - - let token_claims = auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail); - let token = auth::encode_jwt(&token_claims); - - if should_send_mail { - let user = User::find_by_mail(&data.email, &conn).await; - if user.filter(|u| u.private_key.is_some()).is_some() { - // There is still a timing side channel here in that the code - // paths that send mail take noticeably longer than ones that don't. - // Add a randomized sleep to mitigate this somewhat. - use rand::{rngs::SmallRng, RngExt}; - let mut rng: SmallRng = rand::make_rng(); - let sleep_ms = rng.random_range(900..=1100) as u64; - tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await; - } else { - mail::send_register_verify_email(&data.email, &token).await?; - } - - Ok(RegisterVerificationResponse::NoContent(())) - } else { - // If email verification is not required, return the token directly - // the clients will use this token to finish the registration - Ok(RegisterVerificationResponse::Token(Json(token))) - } -} - -#[post("/accounts/register/finish", data = "")] -async fn register_finish(data: Json, conn: DbConn) -> JsonResult { - _register(data, true, conn).await + _register(data, conn).await } // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts @@ -1111,7 +742,7 @@ struct ConnectData { #[field(name = uncased("device_identifier"))] #[field(name = uncased("deviceidentifier"))] - device_identifier: Option, + device_identifier: Option, #[field(name = uncased("device_name"))] #[field(name = uncased("devicename"))] device_name: Option, @@ -1134,173 +765,12 @@ struct ConnectData { #[field(name = uncased("twofactorremember"))] two_factor_remember: Option, #[field(name = uncased("authrequest"))] - auth_request: Option, - - // Needed for authorization code - #[field(name = uncased("code"))] - code: Option, - #[field(name = uncased("code_verifier"))] - code_verifier: Option, + auth_request: Option, } -fn _check_is_some(value: Option<&T>, msg: &str) -> EmptyResult { + +fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { if value.is_none() { err!(msg) } Ok(()) } - -#[get("/sso/prevalidate")] -fn prevalidate() -> JsonResult { - if CONFIG.sso_enabled() { - let sso_token = sso::encode_ssotoken_claims(); - Ok(Json(json!({ - "token": sso_token, - }))) - } else { - err!("SSO sign-in is not available") - } -} - -const SSO_BINDING_COOKIE: &str = "VW_SSO_BINDING"; - -#[get("/connect/oidc-signin?&", rank = 1)] -async fn oidcsignin(code: OIDCCode, state: String, cookies: &CookieJar<'_>, mut conn: DbConn) -> ApiResult { - _oidcsignin_redirect( - state, - OIDCCodeWrapper::Ok { - code, - }, - cookies, - &mut conn, - ) - .await -} - -// Bitwarden client appear to only care for code and state so we pipe it through -// cf: https://github.com/bitwarden/clients/blob/80b74b3300e15b4ae414dc06044cc9b02b6c10a6/libs/auth/src/angular/sso/sso.component.ts#L141 -#[get("/connect/oidc-signin?&&", rank = 2)] -async fn oidcsignin_error( - state: String, - error: String, - error_description: Option, - cookies: &CookieJar<'_>, - mut conn: DbConn, -) -> ApiResult { - _oidcsignin_redirect( - state, - OIDCCodeWrapper::Error { - error, - error_description, - }, - cookies, - &mut conn, - ) - .await -} - -// The state was encoded using Base64 to ensure no issue with providers. -// iss and scope parameters are needed for redirection to work on IOS. -// We pass the state as the code to get it back later on. -async fn _oidcsignin_redirect( - base64_state: String, - code_response: OIDCCodeWrapper, - cookies: &CookieJar<'_>, - conn: &mut DbConn, -) -> ApiResult { - let state = sso::decode_state(&base64_state)?; - - let mut sso_auth = match SsoAuth::find(&state, conn).await { - None => err!(format!("Cannot retrieve sso_auth for {state}")), - Some(sso_auth) => sso_auth, - }; - - // Browser-binding check - // The cookie was set on /connect/authorize and must come from the same browser that initiated the flow. - let cookie_value = cookies.get(SSO_BINDING_COOKIE).map(|c| c.value().to_string()); - 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}")), - } - cookies.remove(Cookie::build(SSO_BINDING_COOKIE).path("/identity/connect/").build()); - - sso_auth.code_response = Some(code_response); - sso_auth.updated_at = Utc::now().naive_utc(); - sso_auth.save(conn).await?; - - let mut url = match url::Url::parse(&sso_auth.redirect_uri) { - Ok(url) => url, - Err(err) => err!(format!("Failed to parse redirect uri ({}): {err}", sso_auth.redirect_uri)), - }; - - url.query_pairs_mut() - .append_pair("code", &state) - .append_pair("state", &state) - .append_pair("scope", &AuthMethod::Sso.scope()) - .append_pair("iss", &CONFIG.domain()); - - debug!("Redirection to {url}"); - - Ok(Redirect::temporary(String::from(url))) -} - -#[derive(Debug, Clone, Default, FromForm)] -struct AuthorizeData { - #[field(name = uncased("client_id"))] - #[field(name = uncased("clientid"))] - client_id: String, - #[field(name = uncased("redirect_uri"))] - #[field(name = uncased("redirecturi"))] - redirect_uri: String, - #[allow(unused)] - response_type: Option, - #[allow(unused)] - scope: Option, - state: OIDCState, - code_challenge: OIDCCodeChallenge, - code_challenge_method: String, - #[allow(unused)] - response_mode: Option, - #[allow(unused)] - domain_hint: Option, - #[allow(unused)] - #[field(name = uncased("ssoToken"))] - sso_token: Option, -} - -// The `redirect_uri` will change depending of the client (web, android, ios ..) -#[get("/connect/authorize?")] -async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, conn: DbConn) -> ApiResult { - let AuthorizeData { - client_id, - redirect_uri, - state, - code_challenge, - code_challenge_method, - .. - } = data; - - if code_challenge_method != "S256" { - err!("Unsupported code challenge method"); - } - - // Generate browser-binding token. Stored hashed in DB; raw value handed to the browser as a cookie. - // Validated on /connect/oidc-signin - let binding_token = data_encoding::BASE64URL_NOPAD.encode(&crypto::get_random_bytes::<32>()); - let binding_hash = crypto::sha256_hex(binding_token.as_bytes()); - - let auth_url = - sso::authorize_url(state, code_challenge, &client_id, &redirect_uri, Some(binding_hash), conn).await?; - - cookies.add( - Cookie::build((SSO_BINDING_COOKIE, binding_token)) - .path("/identity/connect/") - .max_age(time::Duration::seconds(sso::SSO_AUTH_EXPIRATION.num_seconds())) - .same_site(SameSite::Lax) // Lax is needed because the IdP runs on a different FQDN - .http_only(true) - .secure(secure.https) - .build(), - ); - - Ok(Redirect::temporary(String::from(auth_url))) -} diff --git a/src/api/mod.rs b/src/api/mod.rs index ecdf9408..27a3775f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -32,14 +32,10 @@ pub use crate::api::{ web::routes as web_routes, web::static_files, }; -use crate::db::{ - models::{OrgPolicy, OrgPolicyType, User}, - DbConn, -}; -use crate::CONFIG; +use crate::db::{models::User, DbConn}; // Type aliases for API methods results -pub type ApiResult = Result; +type ApiResult = Result; pub type JsonResult = ApiResult>; pub type EmptyResult = ApiResult<()>; @@ -47,7 +43,6 @@ pub type EmptyResult = ApiResult<()>; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PasswordOrOtpData { - #[serde(alias = "MasterPasswordHash")] master_password_hash: Option, otp: Option, } @@ -56,7 +51,7 @@ impl PasswordOrOtpData { /// Tokens used via this struct can be used multiple times during the process /// First for the validation to continue, after that to enable or validate the following actions /// This is different per caller, so it can be adjusted to delete the token or not - pub async fn validate(&self, user: &User, delete_if_valid: bool, conn: &DbConn) -> EmptyResult { + pub async fn validate(&self, user: &User, delete_if_valid: bool, conn: &mut DbConn) -> EmptyResult { use crate::api::core::two_factor::protected_actions::validate_protected_action_otp; match (self.master_password_hash.as_deref(), self.otp.as_deref()) { @@ -73,51 +68,3 @@ impl PasswordOrOtpData { Ok(()) } } - -#[derive(Debug, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MasterPasswordPolicy { - min_complexity: Option, - min_length: Option, - require_lower: bool, - require_upper: bool, - require_numbers: bool, - require_special: bool, - enforce_on_login: bool, -} - -// Fetch all valid Master Password Policies and merge them into one with all trues and largest numbers as one policy -async fn master_password_policy(user: &User, conn: &DbConn) -> Value { - let master_password_policies: Vec = - OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy( - &user.uuid, - OrgPolicyType::MasterPassword, - conn, - ) - .await - .into_iter() - .filter_map(|p| serde_json::from_str(&p.data).ok()) - .collect(); - - let mut mpp_json = if !master_password_policies.is_empty() { - json!(master_password_policies.into_iter().reduce(|acc, policy| { - MasterPasswordPolicy { - min_complexity: acc.min_complexity.max(policy.min_complexity), - min_length: acc.min_length.max(policy.min_length), - require_lower: acc.require_lower || policy.require_lower, - require_upper: acc.require_upper || policy.require_upper, - require_numbers: acc.require_numbers || policy.require_numbers, - require_special: acc.require_special || policy.require_special, - enforce_on_login: acc.enforce_on_login || policy.enforce_on_login, - } - })) - } else if CONFIG.sso_enabled() { - CONFIG.sso_master_password_policy_value().unwrap_or(json!({})) - } else { - json!({}) - }; - - // NOTE: Upstream still uses PascalCase here for `Object`! - mpp_json["Object"] = json!("masterPasswordPolicy"); - mpp_json -} diff --git a/src/api/notifications.rs b/src/api/notifications.rs index b1d64472..8c925e37 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -1,31 +1,30 @@ -use std::{ - net::IpAddr, - sync::{Arc, LazyLock}, - time::Duration, -}; +use std::{net::IpAddr, sync::Arc, time::Duration}; use chrono::{NaiveDateTime, Utc}; use rmpv::Value; use rocket::{futures::StreamExt, Route}; -use rocket_ws::{Message, WebSocket}; use tokio::sync::mpsc::Sender; +use rocket_ws::{Message, WebSocket}; + use crate::{ auth::{ClientIp, WsAccessTokenHeader}, db::{ - models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId}, + models::{Cipher, Folder, Send as DbSend, User}, DbConn, }, Error, CONFIG, }; -pub static WS_USERS: LazyLock> = LazyLock::new(|| { +use once_cell::sync::Lazy; + +pub static WS_USERS: Lazy> = Lazy::new(|| { Arc::new(WebSocketUsers { map: Arc::new(dashmap::DashMap::new()), }) }); -pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock> = LazyLock::new(|| { +pub static WS_ANONYMOUS_SUBSCRIPTIONS: Lazy> = Lazy::new(|| { Arc::new(AnonymousWebSocketSubscriptions { map: Arc::new(dashmap::DashMap::new()), }) @@ -36,7 +35,7 @@ use super::{ push_send_update, push_user_update, }; -static NOTIFICATIONS_DISABLED: LazyLock = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled()); +static NOTIFICATIONS_DISABLED: Lazy = Lazy::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled()); pub fn routes() -> Vec { if CONFIG.enable_websocket() { @@ -54,13 +53,13 @@ struct WsAccessToken { struct WSEntryMapGuard { users: Arc, - user_uuid: UserId, + user_uuid: String, entry_uuid: uuid::Uuid, addr: IpAddr, } impl WSEntryMapGuard { - fn new(users: Arc, user_uuid: UserId, entry_uuid: uuid::Uuid, addr: IpAddr) -> Self { + fn new(users: Arc, user_uuid: String, entry_uuid: uuid::Uuid, addr: IpAddr) -> Self { Self { users, user_uuid, @@ -73,7 +72,7 @@ impl WSEntryMapGuard { impl Drop for WSEntryMapGuard { fn drop(&mut self) { info!("Closing WS connection from {}", self.addr); - if let Some(mut entry) = self.users.map.get_mut(self.user_uuid.as_ref()) { + if let Some(mut entry) = self.users.map.get_mut(&self.user_uuid) { entry.retain(|(uuid, _)| uuid != &self.entry_uuid); } } @@ -102,7 +101,6 @@ impl Drop for WSAnonymousEntryMapGuard { } } -#[allow(tail_expr_drop_order)] #[get("/hub?")] fn websockets_hub<'r>( ws: WebSocket, @@ -110,7 +108,8 @@ fn websockets_hub<'r>( ip: ClientIp, header_token: WsAccessTokenHeader, ) -> Result { - info!("Accepting Rocket WS connection from {}", ip.ip); + let addr = ip.ip; + info!("Accepting Rocket WS connection from {addr}"); let token = if let Some(token) = data.access_token { token @@ -130,10 +129,10 @@ fn websockets_hub<'r>( // Add a channel to send messages to this client to the map let entry_uuid = uuid::Uuid::new_v4(); let (tx, rx) = tokio::sync::mpsc::channel::(100); - users.map.entry(claims.sub.to_string()).or_default().push((entry_uuid, tx)); + users.map.entry(claims.sub.clone()).or_default().push((entry_uuid, tx)); // Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map - (rx, WSEntryMapGuard::new(users, claims.sub, entry_uuid, ip.ip)) + (rx, WSEntryMapGuard::new(users, claims.sub, entry_uuid, addr)) }; Ok({ @@ -157,6 +156,7 @@ fn websockets_hub<'r>( if serde_json::from_str(msg).ok() == Some(INITIAL_MESSAGE) { yield Message::binary(INITIAL_RESPONSE); + continue; } } @@ -186,10 +186,10 @@ fn websockets_hub<'r>( }) } -#[allow(tail_expr_drop_order)] #[get("/anonymous-hub?")] fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result { - info!("Accepting Anonymous Rocket WS connection from {}", ip.ip); + let addr = ip.ip; + info!("Accepting Anonymous Rocket WS connection from {addr}"); let (mut rx, guard) = { let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS); @@ -199,7 +199,7 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R subscriptions.map.insert(token.clone(), tx); // Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map - (rx, WSAnonymousEntryMapGuard::new(subscriptions, token, ip.ip)) + (rx, WSAnonymousEntryMapGuard::new(subscriptions, token, addr)) }; Ok({ @@ -223,6 +223,7 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R if serde_json::from_str(msg).ok() == Some(INITIAL_MESSAGE) { yield Message::binary(INITIAL_RESPONSE); + continue; } } @@ -256,11 +257,11 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R // Websockets server // -fn serialize(val: &Value) -> Vec { +fn serialize(val: Value) -> Vec { use rmpv::encode::write_value; let mut buf = Vec::new(); - write_value(&mut buf, val).expect("Error encoding MsgPack"); + write_value(&mut buf, &val).expect("Error encoding MsgPack"); // Add size bytes at the start // Extracted from BinaryMessageFormat.js @@ -289,7 +290,7 @@ fn serialize(val: &Value) -> Vec { fn serialize_date(date: NaiveDateTime) -> Value { let seconds: i64 = date.and_utc().timestamp(); let nanos: i64 = date.and_utc().timestamp_subsec_nanos().into(); - let timestamp = (nanos << 34) | seconds; + let timestamp = nanos << 34 | seconds; let bs = timestamp.to_be_bytes(); @@ -327,8 +328,8 @@ pub struct WebSocketUsers { } impl WebSocketUsers { - async fn send_update(&self, user_id: &UserId, data: &[u8]) { - if let Some(user) = self.map.get(user_id.as_ref()).map(|v| v.clone()) { + async fn send_update(&self, user_uuid: &str, data: &[u8]) { + if let Some(user) = self.map.get(user_uuid).map(|v| v.clone()) { for (_, sender) in user.iter() { if let Err(e) = sender.send(Message::binary(data)).await { error!("Error sending WS update {e}"); @@ -338,13 +339,13 @@ impl WebSocketUsers { } // NOTE: The last modified date needs to be updated before calling these methods - pub async fn send_user_update(&self, ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) { + pub async fn send_user_update(&self, ut: UpdateType, user: &User) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let data = create_update( - vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))], + vec![("UserId".into(), user.uuid.clone().into()), ("Date".into(), serialize_date(user.updated_at))], ut, None, ); @@ -354,20 +355,19 @@ impl WebSocketUsers { } if CONFIG.push_enabled() { - push_user_update(ut, user, push_uuid, conn).await; + push_user_update(ut, user); } } - pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) { + pub async fn send_logout(&self, user: &User, acting_device_uuid: Option) { // 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 data = create_update( - vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))], + vec![("UserId".into(), user.uuid.clone().into()), ("Date".into(), serialize_date(user.updated_at))], UpdateType::LogOut, - acting_device_id, + acting_device_uuid.clone(), ); if CONFIG.enable_websocket() { @@ -375,23 +375,29 @@ impl WebSocketUsers { } if CONFIG.push_enabled() { - push_logout(user, acting_device, conn).await; + push_logout(user, acting_device_uuid); } } - pub async fn send_folder_update(&self, ut: UpdateType, folder: &Folder, device: &Device, conn: &DbConn) { + pub async fn send_folder_update( + &self, + ut: UpdateType, + folder: &Folder, + acting_device_uuid: &String, + conn: &mut DbConn, + ) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let data = create_update( vec![ - ("Id".into(), folder.uuid.to_string().into()), - ("UserId".into(), folder.user_uuid.to_string().into()), + ("Id".into(), folder.uuid.clone().into()), + ("UserId".into(), folder.user_uuid.clone().into()), ("RevisionDate".into(), serialize_date(folder.updated_at)), ], ut, - Some(device.uuid.clone()), + Some(acting_device_uuid.into()), ); if CONFIG.enable_websocket() { @@ -399,7 +405,7 @@ impl WebSocketUsers { } if CONFIG.push_enabled() { - push_folder_update(ut, folder, device, conn).await; + push_folder_update(ut, folder, acting_device_uuid, conn).await; } } @@ -407,48 +413,48 @@ impl WebSocketUsers { &self, ut: UpdateType, cipher: &Cipher, - user_ids: &[UserId], - device: &Device, - collection_uuids: Option>, - conn: &DbConn, + user_uuids: &[String], + acting_device_uuid: &String, + collection_uuids: Option>, + conn: &mut DbConn, ) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } - let org_id = convert_option(cipher.organization_uuid.as_deref()); + let org_uuid = convert_option(cipher.organization_uuid.clone()); // Depending if there are collections provided or not, we need to have different values for the following variables. // The user_uuid should be `null`, and the revision date should be set to now, else the clients won't sync the collection change. - let (user_id, collection_uuids, revision_date) = if let Some(collection_uuids) = collection_uuids { + let (user_uuid, collection_uuids, revision_date) = if let Some(collection_uuids) = collection_uuids { ( Value::Nil, - Value::Array(collection_uuids.into_iter().map(|v| v.to_string().into()).collect::>()), + Value::Array(collection_uuids.into_iter().map(|v| v.into()).collect::>()), serialize_date(Utc::now().naive_utc()), ) } else { - (convert_option(cipher.user_uuid.as_deref()), Value::Nil, serialize_date(cipher.updated_at)) + (convert_option(cipher.user_uuid.clone()), Value::Nil, serialize_date(cipher.updated_at)) }; let data = create_update( vec![ - ("Id".into(), cipher.uuid.to_string().into()), - ("UserId".into(), user_id), - ("OrganizationId".into(), org_id), + ("Id".into(), cipher.uuid.clone().into()), + ("UserId".into(), user_uuid), + ("OrganizationId".into(), org_uuid), ("CollectionIds".into(), collection_uuids), ("RevisionDate".into(), revision_date), ], ut, - Some(device.uuid.clone()), // Acting device id (unique device/app uuid) + Some(acting_device_uuid.into()), ); if CONFIG.enable_websocket() { - for uuid in user_ids { + for uuid in user_uuids { self.send_update(uuid, &data).await; } } - if CONFIG.push_enabled() && user_ids.len() == 1 { - push_cipher_update(ut, cipher, device, conn).await; + if CONFIG.push_enabled() && user_uuids.len() == 1 { + push_cipher_update(ut, cipher, acting_device_uuid, conn).await; } } @@ -456,20 +462,20 @@ impl WebSocketUsers { &self, ut: UpdateType, send: &DbSend, - user_ids: &[UserId], - device: &Device, - conn: &DbConn, + user_uuids: &[String], + acting_device_uuid: &String, + conn: &mut DbConn, ) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } - let user_id = convert_option(send.user_uuid.as_deref()); + let user_uuid = convert_option(send.user_uuid.clone()); let data = create_update( vec![ - ("Id".into(), send.uuid.to_string().into()), - ("UserId".into(), user_id), + ("Id".into(), send.uuid.clone().into()), + ("UserId".into(), user_uuid), ("RevisionDate".into(), serialize_date(send.revision_date)), ], ut, @@ -477,56 +483,63 @@ impl WebSocketUsers { ); if CONFIG.enable_websocket() { - for uuid in user_ids { + for uuid in user_uuids { self.send_update(uuid, &data).await; } } - if CONFIG.push_enabled() && user_ids.len() == 1 { - push_send_update(ut, send, device, conn).await; + if CONFIG.push_enabled() && user_uuids.len() == 1 { + push_send_update(ut, send, acting_device_uuid, conn).await; } } - pub async fn send_auth_request(&self, user_id: &UserId, auth_request_uuid: &str, device: &Device, conn: &DbConn) { - // Skip any processing if both WebSockets and Push are not active - if *NOTIFICATIONS_DISABLED { - return; - } - let data = create_update( - vec![("Id".into(), auth_request_uuid.to_owned().into()), ("UserId".into(), user_id.to_string().into())], - UpdateType::AuthRequest, - Some(device.uuid.clone()), - ); - if CONFIG.enable_websocket() { - self.send_update(user_id, &data).await; - } - - if CONFIG.push_enabled() { - push_auth_request(user_id, auth_request_uuid, device, conn).await; - } - } - - pub async fn send_auth_response( + pub async fn send_auth_request( &self, - user_id: &UserId, - auth_request_id: &AuthRequestId, - device: &Device, - conn: &DbConn, + user_uuid: &String, + auth_request_uuid: &String, + acting_device_uuid: &String, + conn: &mut DbConn, ) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let data = create_update( - vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())], - UpdateType::AuthRequestResponse, - Some(device.uuid.clone()), + vec![("Id".into(), auth_request_uuid.clone().into()), ("UserId".into(), user_uuid.clone().into())], + UpdateType::AuthRequest, + Some(acting_device_uuid.to_string()), ); if CONFIG.enable_websocket() { - self.send_update(user_id, &data).await; + self.send_update(user_uuid, &data).await; } if CONFIG.push_enabled() { - push_auth_response(user_id, auth_request_id, device, conn).await; + push_auth_request(user_uuid.to_string(), auth_request_uuid.to_string(), conn).await; + } + } + + pub async fn send_auth_response( + &self, + user_uuid: &String, + auth_response_uuid: &str, + approving_device_uuid: String, + conn: &mut DbConn, + ) { + // Skip any processing if both WebSockets and Push are not active + if *NOTIFICATIONS_DISABLED { + return; + } + let data = create_update( + vec![("Id".into(), auth_response_uuid.to_owned().into()), ("UserId".into(), user_uuid.clone().into())], + UpdateType::AuthRequestResponse, + approving_device_uuid.clone().into(), + ); + if CONFIG.enable_websocket() { + self.send_update(auth_response_uuid, &data).await; + } + + if CONFIG.push_enabled() { + push_auth_response(user_uuid.to_string(), auth_response_uuid.to_string(), approving_device_uuid, conn) + .await; } } } @@ -545,16 +558,16 @@ impl AnonymousWebSocketSubscriptions { } } - pub async fn send_auth_response(&self, user_id: &UserId, auth_request_id: &AuthRequestId) { + pub async fn send_auth_response(&self, user_uuid: &String, auth_response_uuid: &str) { if !CONFIG.enable_websocket() { return; } let data = create_anonymous_update( - vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())], + vec![("Id".into(), auth_response_uuid.to_owned().into()), ("UserId".into(), user_uuid.clone().into())], UpdateType::AuthRequestResponse, - user_id, + user_uuid.to_string(), ); - self.send_update(auth_request_id, &data).await; + self.send_update(auth_response_uuid, &data).await; } } @@ -566,14 +579,14 @@ impl AnonymousWebSocketSubscriptions { "ReceiveMessage", // Target [ // Arguments { - "ContextId": acting_device_id || Nil, + "ContextId": acting_device_uuid || Nil, "Type": ut as i32, "Payload": {} } ] ] */ -fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_id: Option) -> Vec { +fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_uuid: Option) -> Vec { use rmpv::Value as V; let value = V::Array(vec![ @@ -582,41 +595,38 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_id: V::Nil, "ReceiveMessage".into(), V::Array(vec![V::Map(vec![ - ("ContextId".into(), acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| V::Nil)), + ("ContextId".into(), acting_device_uuid.map(|v| v.into()).unwrap_or_else(|| V::Nil)), ("Type".into(), (ut as i32).into()), ("Payload".into(), payload.into()), ])]), ]); - serialize(&value) + serialize(value) } -fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id: &UserId) -> Vec { +fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id: String) -> Vec { use rmpv::Value as V; let value = V::Array(vec![ 1.into(), V::Map(vec![]), V::Nil, - // This word is misspelled, but upstream has this too - // https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86 - // https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45 "AuthRequestResponseRecieved".into(), V::Array(vec![V::Map(vec![ ("Type".into(), (ut as i32).into()), ("Payload".into(), payload.into()), - ("UserId".into(), user_id.to_string().into()), + ("UserId".into(), user_id.into()), ])]), ]); - serialize(&value) + serialize(value) } fn create_ping() -> Vec { - serialize(&Value::Array(vec![6.into()])) + serialize(Value::Array(vec![6.into()])) } -// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs +#[allow(dead_code)] #[derive(Copy, Clone, Eq, PartialEq)] pub enum UpdateType { SyncCipherUpdate = 0, @@ -629,7 +639,7 @@ pub enum UpdateType { SyncOrgKeys = 6, SyncFolderCreate = 7, SyncFolderUpdate = 8, - // SyncCipherDelete = 9, // Redirects to `SyncLoginDelete` on upstream + SyncCipherDelete = 9, SyncSettings = 10, LogOut = 11, @@ -641,14 +651,6 @@ pub enum UpdateType { AuthRequest = 15, AuthRequestResponse = 16, - // SyncOrganizations = 17, // Not supported - // SyncOrganizationStatusChanged = 18, // Not supported - // SyncOrganizationCollectionSettingChanged = 19, // Not supported - - // Notification = 20, // Not supported - // NotificationStatus = 21, // Not supported - - // RefreshSecurityTasks = 22, // Not supported None = 100, } diff --git a/src/api/push.rs b/src/api/push.rs index e3ff1383..eaf304f9 100644 --- a/src/api/push.rs +++ b/src/api/push.rs @@ -1,8 +1,3 @@ -use std::{ - sync::LazyLock, - time::{Duration, Instant}, -}; - use reqwest::{ header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, Method, @@ -12,15 +7,14 @@ use tokio::sync::RwLock; use crate::{ api::{ApiResult, EmptyResult, UpdateType}, - db::{ - models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId}, - DbConn, - }, + db::models::{Cipher, Device, Folder, Send, User}, http_client::make_http_request, - util::{format_date, get_uuid}, CONFIG, }; +use once_cell::sync::Lazy; +use std::time::{Duration, Instant}; + #[derive(Deserialize)] struct AuthPushToken { access_token: String, @@ -33,20 +27,20 @@ struct LocalAuthPushToken { valid_until: Instant, } -async fn get_auth_api_token() -> ApiResult { - static API_TOKEN: LazyLock> = LazyLock::new(|| { +async fn get_auth_push_token() -> ApiResult { + static PUSH_TOKEN: Lazy> = Lazy::new(|| { RwLock::new(LocalAuthPushToken { access_token: String::new(), valid_until: Instant::now(), }) }); - let api_token = API_TOKEN.read().await; + let push_token = PUSH_TOKEN.read().await; - if api_token.valid_until.saturating_duration_since(Instant::now()).as_secs() > 0 { + if push_token.valid_until.saturating_duration_since(Instant::now()).as_secs() > 0 { debug!("Auth Push token still valid, no need for a new one"); - return Ok(api_token.access_token.clone()); + return Ok(push_token.access_token.clone()); } - drop(api_token); // Drop the read lock now + drop(push_token); // Drop the read lock now let installation_id = CONFIG.push_installation_id(); let client_id = format!("installation.{installation_id}"); @@ -73,48 +67,44 @@ async fn get_auth_api_token() -> ApiResult { Err(e) => err!(format!("Unexpected push token received from bitwarden server: {e}")), }; - let mut api_token = API_TOKEN.write().await; - api_token.valid_until = Instant::now() + let mut push_token = PUSH_TOKEN.write().await; + push_token.valid_until = Instant::now() .checked_add(Duration::new((json_pushtoken.expires_in / 2) as u64, 0)) // Token valid for half the specified time .unwrap(); - api_token.access_token = json_pushtoken.access_token; + push_token.access_token = json_pushtoken.access_token; - debug!("Token still valid for {}", api_token.valid_until.saturating_duration_since(Instant::now()).as_secs()); - Ok(api_token.access_token.clone()) + debug!("Token still valid for {}", push_token.valid_until.saturating_duration_since(Instant::now()).as_secs()); + Ok(push_token.access_token.clone()) } -pub async fn register_push_device(device: &mut Device, conn: &DbConn) -> EmptyResult { - if !CONFIG.push_enabled() || !device.is_push_device() { +pub async fn register_push_device(device: &mut Device, conn: &mut crate::db::DbConn) -> EmptyResult { + if !CONFIG.push_enabled() || !device.is_push_device() || device.is_registered() { return Ok(()); } if device.push_token.is_none() { - warn!("Skipping the registration of the device {:?} because the push_token field is empty.", device.uuid); - warn!("To get rid of this message you need to logout, clear the app data and login again on the device."); + warn!("Skipping the registration of the device {} because the push_token field is empty.", device.uuid); + warn!("To get rid of this message you need to clear the app data and reconnect the device."); return Ok(()); } - debug!("Registering Device {:?}", device.push_uuid); + debug!("Registering Device {}", device.uuid); - // Generate a random push_uuid so if it doesn't already have one - if device.push_uuid.is_none() { - device.push_uuid = Some(PushId(get_uuid())); - } + // generate a random push_uuid so we know the device is registered + device.push_uuid = Some(uuid::Uuid::new_v4().to_string()); //Needed to register a device for push to bitwarden : let data = json!({ - "deviceId": device.push_uuid, // Unique UUID per user/device - "pushToken": device.push_token, "userId": device.user_uuid, + "deviceId": device.push_uuid, + "identifier": device.uuid, "type": device.atype, - "identifier": device.uuid, // Unique UUID of the device/app, determined by the device/app it self currently registering - // "organizationIds:" [] // TODO: This is not yet implemented by Vaultwarden! - "installationId": CONFIG.push_installation_id(), + "pushToken": device.push_token }); - let auth_api_token = get_auth_api_token().await?; - let auth_header = format!("Bearer {auth_api_token}"); + let auth_push_token = get_auth_push_token().await?; + let auth_header = format!("Bearer {}", &auth_push_token); if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/register"))? .header(CONTENT_TYPE, "application/json") @@ -128,28 +118,25 @@ pub async fn register_push_device(device: &mut Device, conn: &DbConn) -> EmptyRe err!(format!("An error occurred while proceeding registration of a device: {e}")); } - if let Err(e) = device.save(true, conn).await { + if let Err(e) = device.save(conn).await { err!(format!("An error occurred while trying to save the (registered) device push uuid: {e}")); } Ok(()) } -pub async fn unregister_push_device(push_id: Option<&PushId>) -> EmptyResult { - if !CONFIG.push_enabled() || push_id.is_none() { +pub async fn unregister_push_device(push_uuid: Option) -> EmptyResult { + if !CONFIG.push_enabled() || push_uuid.is_none() { return Ok(()); } - let auth_api_token = get_auth_api_token().await?; + let auth_push_token = get_auth_push_token().await?; - let auth_header = format!("Bearer {auth_api_token}"); + let auth_header = format!("Bearer {}", &auth_push_token); - match make_http_request( - Method::POST, - &format!("{}/push/delete/{}", CONFIG.push_relay_uri(), push_id.as_ref().unwrap()), - )? - .header(AUTHORIZATION, auth_header) - .send() - .await + match make_http_request(Method::DELETE, &(CONFIG.push_relay_uri() + "/push/" + &push_uuid.unwrap()))? + .header(AUTHORIZATION, auth_header) + .send() + .await { Ok(r) => r, Err(e) => err!(format!("An error occurred during device unregistration: {e}")), @@ -157,108 +144,108 @@ pub async fn unregister_push_device(push_id: Option<&PushId>) -> EmptyResult { Ok(()) } -pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device, conn: &DbConn) { +pub async fn push_cipher_update( + ut: UpdateType, + cipher: &Cipher, + acting_device_uuid: &String, + conn: &mut crate::db::DbConn, +) { // We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too. if cipher.organization_uuid.is_some() { return; }; - let Some(user_id) = &cipher.user_uuid else { - debug!("Cipher has no uuid"); - return; + let user_uuid = match &cipher.user_uuid { + Some(c) => c, + None => { + debug!("Cipher has no uuid"); + return; + } }; - if Device::check_user_has_push_device(user_id, conn).await { + if Device::check_user_has_push_device(user_uuid, conn).await { send_to_push_relay(json!({ - "userId": user_id, - "organizationId": null, - "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device) - "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app) + "userId": user_uuid, + "organizationId": (), + "deviceId": acting_device_uuid, + "identifier": acting_device_uuid, "type": ut as i32, "payload": { "id": cipher.uuid, "userId": cipher.user_uuid, - "organizationId": null, - "collectionIds": null, - "revisionDate": format_date(&cipher.updated_at) - }, - "clientType": null, - "installationId": null + "organizationId": (), + "revisionDate": cipher.updated_at + } })) .await; } } -pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) { - if Device::check_user_has_push_device(&user.uuid, conn).await { - tokio::task::spawn(send_to_push_relay(json!({ +pub fn push_logout(user: &User, acting_device_uuid: Option) { + let acting_device_uuid: Value = acting_device_uuid.map(|v| v.into()).unwrap_or_else(|| Value::Null); + + tokio::task::spawn(send_to_push_relay(json!({ + "userId": user.uuid, + "organizationId": (), + "deviceId": acting_device_uuid, + "identifier": acting_device_uuid, + "type": UpdateType::LogOut as i32, + "payload": { "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": { - "userId": user.uuid, - "date": format_date(&user.updated_at) - }, - "clientType": null, - "installationId": null - }))); - } + "date": user.updated_at + } + }))); } -pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) { - if Device::check_user_has_push_device(&user.uuid, conn).await { - tokio::task::spawn(send_to_push_relay(json!({ +pub fn push_user_update(ut: UpdateType, user: &User) { + tokio::task::spawn(send_to_push_relay(json!({ + "userId": user.uuid, + "organizationId": (), + "deviceId": (), + "identifier": (), + "type": ut as i32, + "payload": { "userId": user.uuid, - "organizationId": null, - "deviceId": push_uuid, - "identifier": null, - "type": ut as i32, - "payload": { - "userId": user.uuid, - "date": format_date(&user.updated_at) - }, - "clientType": null, - "installationId": null - }))); - } + "date": user.updated_at + } + }))); } -pub async fn push_folder_update(ut: UpdateType, folder: &Folder, device: &Device, conn: &DbConn) { +pub async fn push_folder_update( + ut: UpdateType, + folder: &Folder, + acting_device_uuid: &String, + conn: &mut crate::db::DbConn, +) { if Device::check_user_has_push_device(&folder.user_uuid, conn).await { tokio::task::spawn(send_to_push_relay(json!({ "userId": folder.user_uuid, - "organizationId": null, - "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device) - "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app) + "organizationId": (), + "deviceId": acting_device_uuid, + "identifier": acting_device_uuid, "type": ut as i32, "payload": { "id": folder.uuid, "userId": folder.user_uuid, - "revisionDate": format_date(&folder.updated_at) - }, - "clientType": null, - "installationId": null + "revisionDate": folder.updated_at + } }))); } } -pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn: &DbConn) { +pub async fn push_send_update(ut: UpdateType, send: &Send, acting_device_uuid: &String, conn: &mut crate::db::DbConn) { if let Some(s) = &send.user_uuid { if Device::check_user_has_push_device(s, conn).await { tokio::task::spawn(send_to_push_relay(json!({ "userId": send.user_uuid, - "organizationId": null, - "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device) - "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app) + "organizationId": (), + "deviceId": acting_device_uuid, + "identifier": acting_device_uuid, "type": ut as i32, "payload": { "id": send.uuid, "userId": send.user_uuid, - "revisionDate": format_date(&send.revision_date) - }, - "clientType": null, - "installationId": null + "revisionDate": send.revision_date + } }))); } } @@ -269,20 +256,20 @@ async fn send_to_push_relay(notification_data: Value) { return; } - let auth_api_token = match get_auth_api_token().await { + let auth_push_token = match get_auth_push_token().await { Ok(s) => s, Err(e) => { - debug!("Could not get the auth push token: {e}"); + debug!("Could not get the auth push token: {}", e); return; } }; - let auth_header = format!("Bearer {auth_api_token}"); + let auth_header = format!("Bearer {}", &auth_push_token); let req = match make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/send")) { Ok(r) => r, Err(e) => { - error!("An error occurred while sending a send update to the push relay: {e}"); + error!("An error occurred while sending a send update to the push relay: {}", e); return; } }; @@ -295,42 +282,43 @@ async fn send_to_push_relay(notification_data: Value) { .send() .await { - error!("An error occurred while sending a send update to the push relay: {e}"); + error!("An error occurred while sending a send update to the push relay: {}", e); }; } -pub async fn push_auth_request(user_id: &UserId, auth_request_id: &str, device: &Device, conn: &DbConn) { - if Device::check_user_has_push_device(user_id, conn).await { +pub async fn push_auth_request(user_uuid: String, auth_request_uuid: String, conn: &mut crate::db::DbConn) { + if Device::check_user_has_push_device(user_uuid.as_str(), conn).await { tokio::task::spawn(send_to_push_relay(json!({ - "userId": user_id, - "organizationId": null, - "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device) - "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app) + "userId": user_uuid, + "organizationId": (), + "deviceId": null, + "identifier": null, "type": UpdateType::AuthRequest as i32, "payload": { - "userId": user_id, - "id": auth_request_id, - }, - "clientType": null, - "installationId": null + "id": auth_request_uuid, + "userId": user_uuid, + } }))); } } -pub async fn push_auth_response(user_id: &UserId, auth_request_id: &AuthRequestId, device: &Device, conn: &DbConn) { - if Device::check_user_has_push_device(user_id, conn).await { +pub async fn push_auth_response( + user_uuid: String, + auth_request_uuid: String, + approving_device_uuid: String, + conn: &mut crate::db::DbConn, +) { + if Device::check_user_has_push_device(user_uuid.as_str(), conn).await { tokio::task::spawn(send_to_push_relay(json!({ - "userId": user_id, - "organizationId": null, - "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device) - "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app) + "userId": user_uuid, + "organizationId": (), + "deviceId": approving_device_uuid, + "identifier": approving_device_uuid, "type": UpdateType::AuthRequestResponse as i32, "payload": { - "userId": user_id, - "id": auth_request_id, - }, - "clientType": null, - "installationId": null + "id": auth_request_uuid, + "userId": user_uuid, + } }))); } } diff --git a/src/api/web.rs b/src/api/web.rs index 0ae9c7db..6983719b 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -1,20 +1,13 @@ use std::path::{Path, PathBuf}; -use rocket::{ - fs::NamedFile, - http::ContentType, - response::{content::RawCss as Css, content::RawHtml as Html, Redirect}, - serde::json::Json, - Catcher, Route, -}; +use rocket::{fs::NamedFile, http::ContentType, response::content::RawHtml as Html, serde::json::Json, Catcher, Route}; use serde_json::Value; use crate::{ api::{core::now, ApiResult, EmptyResult}, auth::decode_file_download, - db::models::{AttachmentId, CipherId}, error::Error, - util::Cached, + util::{Cached, SafeString}, CONFIG, }; @@ -23,7 +16,7 @@ pub fn routes() -> Vec { // crate::utils::LOGGED_ROUTES to make sure they appear in the log let mut routes = routes![attachments, alive, alive_head, static_files]; if CONFIG.web_vault_enabled() { - routes.append(&mut routes![web_index, web_index_direct, web_index_head, app_id, web_files, vaultwarden_css]); + routes.append(&mut routes![web_index, web_index_head, app_id, web_files]); } #[cfg(debug_assertions)] @@ -52,71 +45,11 @@ fn not_found() -> ApiResult> { Ok(Html(text)) } -#[get("/css/vaultwarden.css")] -fn vaultwarden_css() -> Cached> { - let css_options = json!({ - "emergency_access_allowed": CONFIG.emergency_access_allowed(), - "load_user_scss": true, - "mail_2fa_enabled": CONFIG._enable_email_2fa(), - "mail_enabled": CONFIG.mail_enabled(), - "sends_allowed": CONFIG.sends_allowed(), - "remember_2fa_disabled": CONFIG.disable_2fa_remember(), - "password_hints_allowed": CONFIG.password_hints_allowed(), - "signup_disabled": CONFIG.is_signup_disabled(), - "sso_enabled": CONFIG.sso_enabled(), - "sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(), - "webauthn_2fa_supported": CONFIG.is_webauthn_2fa_supported(), - "yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(), - }); - - let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) { - Ok(t) => t, - Err(e) => { - // Something went wrong loading the template. Use the fallback - warn!("Loading scss/vaultwarden.scss.hbs or scss/user.vaultwarden.scss.hbs failed. {e}"); - CONFIG - .render_fallback_template("scss/vaultwarden.scss", &css_options) - .expect("Fallback scss/vaultwarden.scss.hbs to render") - } - }; - - let css = match grass_compiler::from_string( - scss, - &grass_compiler::Options::default().style(grass_compiler::OutputStyle::Compressed), - ) { - Ok(css) => css, - Err(e) => { - // Something went wrong compiling the scss. Use the fallback - warn!("Compiling the Vaultwarden SCSS styles failed. {e}"); - let mut css_options = css_options; - css_options["load_user_scss"] = json!(false); - let scss = CONFIG - .render_fallback_template("scss/vaultwarden.scss", &css_options) - .expect("Fallback scss/vaultwarden.scss.hbs to render"); - grass_compiler::from_string( - scss, - &grass_compiler::Options::default().style(grass_compiler::OutputStyle::Compressed), - ) - .expect("SCSS to compile") - } - }; - - // Cache for one day should be enough and not too much - Cached::ttl(Css(css), 86_400, false) -} - #[get("/")] async fn web_index() -> Cached> { Cached::short(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join("index.html")).await.ok(), false) } -// Make sure that `/index.html` redirect to actual domain path. -// If not, this might cause issues with the web-vault -#[get("/index.html")] -fn web_index_direct() -> Redirect { - Redirect::to(format!("{}/", CONFIG.domain_path())) -} - #[head("/")] fn web_index_head() -> EmptyResult { // Add an explicit HEAD route to prevent uptime monitoring services from @@ -165,16 +98,16 @@ async fn web_files(p: PathBuf) -> Cached> { Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true) } -#[get("/attachments//?")] -async fn attachments(cipher_id: CipherId, file_id: AttachmentId, token: String) -> Option { +#[get("/attachments//?")] +async fn attachments(uuid: SafeString, file_id: SafeString, token: String) -> Option { let Ok(claims) = decode_file_download(&token) else { return None; }; - if claims.sub != cipher_id || claims.file_id != file_id { + if claims.sub != *uuid || claims.file_id != *file_id { return None; } - NamedFile::open(Path::new(&CONFIG.attachments_folder()).join(cipher_id.as_ref()).join(file_id.as_ref())).await.ok() + NamedFile::open(Path::new(&CONFIG.attachments_folder()).join(uuid).join(file_id)).await.ok() } // We use DbConn here to let the alive healthcheck also verify the database connection. @@ -240,8 +173,8 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro "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"))), - "jquery-4.0.0.slim.js" => { - Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-4.0.0.slim.js"))) + "jquery-3.7.1.slim.js" => { + Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-3.7.1.slim.js"))) } _ => err!(format!("Static file not found: {filename}")), } diff --git a/src/auth.rs b/src/auth.rs index 43184369..b1a743da 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,83 +1,71 @@ -use std::{ - env, - net::IpAddr, - sync::{LazyLock, OnceLock}, -}; - -use chrono::{DateTime, TimeDelta, Utc}; +// JWT Handling +// +use chrono::{TimeDelta, Utc}; use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header}; use num_traits::FromPrimitive; +use once_cell::sync::{Lazy, OnceCell}; use openssl::rsa::Rsa; use serde::de::DeserializeOwned; use serde::ser::Serialize; - -use crate::{ - api::ApiResult, - config::PathType, - db::models::{ - AttachmentId, CipherId, CollectionId, DeviceId, DeviceType, EmergencyAccessId, MembershipId, OrgApiKeyId, - OrganizationId, SendFileId, SendId, UserId, - }, - error::Error, - sso, CONFIG, +use std::{ + env, + fs::File, + io::{Read, Write}, + net::IpAddr, }; +use crate::{error::Error, CONFIG}; + const JWT_ALGORITHM: Algorithm = Algorithm::RS256; -// Limit when BitWarden consider the token as expired -pub static BW_EXPIRATION: LazyLock = LazyLock::new(|| TimeDelta::try_minutes(5).unwrap()); +pub static DEFAULT_VALIDITY: Lazy = Lazy::new(|| TimeDelta::try_hours(2).unwrap()); +static JWT_HEADER: Lazy
= Lazy::new(|| Header::new(JWT_ALGORITHM)); -pub static DEFAULT_REFRESH_VALIDITY: LazyLock = LazyLock::new(|| TimeDelta::try_days(30).unwrap()); -pub static MOBILE_REFRESH_VALIDITY: LazyLock = LazyLock::new(|| TimeDelta::try_days(90).unwrap()); -pub static DEFAULT_ACCESS_VALIDITY: LazyLock = LazyLock::new(|| TimeDelta::try_hours(2).unwrap()); -static JWT_HEADER: LazyLock
= LazyLock::new(|| Header::new(JWT_ALGORITHM)); +pub static JWT_LOGIN_ISSUER: Lazy = Lazy::new(|| format!("{}|login", CONFIG.domain_origin())); +static JWT_INVITE_ISSUER: Lazy = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin())); +static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy = + Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin())); +static JWT_DELETE_ISSUER: Lazy = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin())); +static JWT_VERIFYEMAIL_ISSUER: Lazy = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin())); +static JWT_ADMIN_ISSUER: Lazy = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin())); +static JWT_SEND_ISSUER: Lazy = Lazy::new(|| format!("{}|send", CONFIG.domain_origin())); +static JWT_ORG_API_KEY_ISSUER: Lazy = Lazy::new(|| format!("{}|api.organization", CONFIG.domain_origin())); +static JWT_FILE_DOWNLOAD_ISSUER: Lazy = Lazy::new(|| format!("{}|file_download", CONFIG.domain_origin())); -pub static JWT_LOGIN_ISSUER: LazyLock = LazyLock::new(|| format!("{}|login", CONFIG.domain_origin())); -static JWT_INVITE_ISSUER: LazyLock = LazyLock::new(|| format!("{}|invite", CONFIG.domain_origin())); -static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: LazyLock = - LazyLock::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin())); -static JWT_DELETE_ISSUER: LazyLock = LazyLock::new(|| format!("{}|delete", CONFIG.domain_origin())); -static JWT_VERIFYEMAIL_ISSUER: LazyLock = LazyLock::new(|| format!("{}|verifyemail", CONFIG.domain_origin())); -static JWT_ADMIN_ISSUER: LazyLock = LazyLock::new(|| format!("{}|admin", CONFIG.domain_origin())); -static JWT_SEND_ISSUER: LazyLock = LazyLock::new(|| format!("{}|send", CONFIG.domain_origin())); -static JWT_ORG_API_KEY_ISSUER: LazyLock = - LazyLock::new(|| format!("{}|api.organization", CONFIG.domain_origin())); -static JWT_FILE_DOWNLOAD_ISSUER: LazyLock = - LazyLock::new(|| format!("{}|file_download", CONFIG.domain_origin())); -static JWT_REGISTER_VERIFY_ISSUER: LazyLock = - LazyLock::new(|| format!("{}|register_verify", CONFIG.domain_origin())); -static JWT_2FA_REMEMBER_ISSUER: LazyLock = LazyLock::new(|| format!("{}|2faremember", CONFIG.domain_origin())); +static PRIVATE_RSA_KEY: OnceCell = OnceCell::new(); +static PUBLIC_RSA_KEY: OnceCell = OnceCell::new(); -static PRIVATE_RSA_KEY: OnceLock = OnceLock::new(); -static PUBLIC_RSA_KEY: OnceLock = OnceLock::new(); +pub fn initialize_keys() -> Result<(), Error> { + fn read_key(create_if_missing: bool) -> Result<(Rsa, Vec), Error> { + let mut priv_key_buffer = Vec::with_capacity(2048); -pub async fn initialize_keys() -> Result<(), Error> { - use std::io::Error; + let mut priv_key_file = File::options() + .create(create_if_missing) + .truncate(false) + .read(true) + .write(create_if_missing) + .open(CONFIG.private_rsa_key())?; - let rsa_key_filename = std::path::PathBuf::from(CONFIG.private_rsa_key()) - .file_name() - .ok_or_else(|| Error::other("Private RSA key path missing filename"))? - .to_str() - .ok_or_else(|| Error::other("Private RSA key path filename is not valid UTF-8"))? - .to_string(); + #[allow(clippy::verbose_file_reads)] + let bytes_read = priv_key_file.read_to_end(&mut priv_key_buffer)?; - let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(Error::other)?; + let rsa_key = if bytes_read > 0 { + Rsa::private_key_from_pem(&priv_key_buffer[..bytes_read])? + } else if create_if_missing { + // Only create the key if the file doesn't exist or is empty + let rsa_key = Rsa::generate(2048)?; + priv_key_buffer = rsa_key.private_key_to_pem()?; + priv_key_file.write_all(&priv_key_buffer)?; + info!("Private key '{}' created correctly", CONFIG.private_rsa_key()); + rsa_key + } else { + err!("Private key does not exist or invalid format", CONFIG.private_rsa_key()); + }; - let priv_key_buffer = match operator.read(&rsa_key_filename).await { - Ok(buffer) => Some(buffer), - Err(e) if e.kind() == opendal::ErrorKind::NotFound => None, - Err(e) => return Err(e.into()), - }; + Ok((rsa_key, priv_key_buffer)) + } - let (priv_key, priv_key_buffer) = if let Some(priv_key_buffer) = priv_key_buffer { - (Rsa::private_key_from_pem(priv_key_buffer.to_vec().as_slice())?, priv_key_buffer.to_vec()) - } else { - let rsa_key = Rsa::generate(2048)?; - let priv_key_buffer = rsa_key.private_key_to_pem()?; - operator.write(&rsa_key_filename, priv_key_buffer.clone()).await?; - info!("Private key '{}' created correctly", CONFIG.private_rsa_key()); - (rsa_key, priv_key_buffer) - }; + let (priv_key, priv_key_buffer) = read_key(true).or_else(|_| read_key(false))?; let pub_key_buffer = priv_key.public_key_to_pem()?; let enc = EncodingKey::from_rsa_pem(&priv_key_buffer)?; @@ -98,7 +86,7 @@ pub fn encode_jwt(claims: &T) -> String { } } -pub fn decode_jwt(token: &str, issuer: String) -> Result { +fn decode_jwt(token: &str, issuer: String) -> Result { let mut validation = jsonwebtoken::Validation::new(JWT_ALGORITHM); validation.leeway = 30; // 30 seconds validation.validate_exp = true; @@ -112,15 +100,11 @@ pub fn decode_jwt(token: &str, issuer: String) -> Result err!("Token is invalid"), ErrorKind::InvalidIssuer => err!("Issuer is invalid"), ErrorKind::ExpiredSignature => err!("Token has expired"), - _ => err!(format!("Error decoding JWT: {:?}", err)), + _ => err!("Error decoding JWT"), }, } } -pub fn decode_refresh(token: &str) -> Result { - decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) -} - pub fn decode_login(token: &str) -> Result { decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) } @@ -157,14 +141,6 @@ pub fn decode_file_download(token: &str) -> Result { decode_jwt(token, JWT_FILE_DOWNLOAD_ISSUER.to_string()) } -pub fn decode_register_verify(token: &str) -> Result { - decode_jwt(token, JWT_REGISTER_VERIFY_ISSUER.to_string()) -} - -pub fn decode_2fa_remember(token: &str) -> Result { - decode_jwt(token, JWT_2FA_REMEMBER_ISSUER.to_string()) -} - #[derive(Debug, Serialize, Deserialize)] pub struct LoginJwtClaims { // Not before @@ -174,7 +150,7 @@ pub struct LoginJwtClaims { // Issuer pub iss: String, // Subject - pub sub: UserId, + pub sub: String, pub premium: bool, pub name: String, @@ -195,96 +171,13 @@ pub struct LoginJwtClaims { // user security_stamp pub sstamp: String, // device uuid - pub device: DeviceId, - // what kind of device, like FirefoxBrowser or Android derived from DeviceType - pub devicetype: String, - // the type of client_id, like web, cli, desktop, browser or mobile - pub client_id: String, - + pub device: String, // [ "api", "offline_access" ] pub scope: Vec, // [ "Application" ] pub amr: Vec, } -impl LoginJwtClaims { - pub fn new( - device: &Device, - user: &User, - nbf: i64, - exp: i64, - scope: Vec, - client_id: Option, - now: DateTime, - ) -> Self { - // --- - // Disabled these keys to be added to the JWT since they could cause the JWT to get too large - // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients - // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out - // --- - // fn arg: orgs: Vec, - // --- - // let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect(); - // let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect(); - // let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect(); - // let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect(); - - if exp <= (now + *BW_EXPIRATION).timestamp() { - warn!("Raise access_token lifetime to more than 5min.") - } - - // Create the JWT claims struct, to send to the client - Self { - nbf, - exp, - iss: JWT_LOGIN_ISSUER.to_string(), - sub: user.uuid.clone(), - premium: true, - name: user.name.clone(), - email: user.email.clone(), - 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 - // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients - // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out - // See: https://github.com/dani-garcia/vaultwarden/issues/4156 - // --- - // orgowner, - // orgadmin, - // orguser, - // orgmanager, - sstamp: user.security_stamp.clone(), - device: device.uuid.clone(), - devicetype: DeviceType::from_i32(device.atype).to_string(), - client_id: client_id.unwrap_or("undefined".to_string()), - scope, - amr: vec!["Application".into()], - } - } - - pub fn default(device: &Device, user: &User, auth_method: &AuthMethod, client_id: Option) -> Self { - let time_now = Utc::now(); - Self::new( - device, - user, - time_now.timestamp(), - (time_now + *DEFAULT_ACCESS_VALIDITY).timestamp(), - auth_method.scope_vec(), - client_id, - time_now, - ) - } - - pub fn token(&self) -> String { - encode_jwt(&self) - } - - pub fn expires_in(&self) -> i64 { - self.exp - Utc::now().timestamp() - } -} - #[derive(Debug, Serialize, Deserialize)] pub struct InviteJwtClaims { // Not before @@ -294,19 +187,19 @@ pub struct InviteJwtClaims { // Issuer pub iss: String, // Subject - pub sub: UserId, + pub sub: String, pub email: String, - pub org_id: OrganizationId, - pub member_id: MembershipId, + pub org_id: Option, + pub user_org_id: Option, pub invited_by_email: Option, } pub fn generate_invite_claims( - user_id: UserId, + uuid: String, email: String, - org_id: OrganizationId, - member_id: MembershipId, + org_id: Option, + user_org_id: Option, invited_by_email: Option, ) -> InviteJwtClaims { let time_now = Utc::now(); @@ -315,10 +208,10 @@ pub fn generate_invite_claims( nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_INVITE_ISSUER.to_string(), - sub: user_id, + sub: uuid, email, org_id, - member_id, + user_org_id, invited_by_email, } } @@ -332,18 +225,18 @@ pub struct EmergencyAccessInviteJwtClaims { // Issuer pub iss: String, // Subject - pub sub: UserId, + pub sub: String, pub email: String, - pub emer_id: EmergencyAccessId, + pub emer_id: String, pub grantor_name: String, pub grantor_email: String, } pub fn generate_emergency_access_invite_claims( - user_id: UserId, + uuid: String, email: String, - emer_id: EmergencyAccessId, + emer_id: String, grantor_name: String, grantor_email: String, ) -> EmergencyAccessInviteJwtClaims { @@ -353,7 +246,7 @@ pub fn generate_emergency_access_invite_claims( nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(), - sub: user_id, + sub: uuid, email, emer_id, grantor_name, @@ -370,23 +263,20 @@ pub struct OrgApiKeyLoginJwtClaims { // Issuer pub iss: String, // Subject - pub sub: OrgApiKeyId, + pub sub: String, pub client_id: String, - pub client_sub: OrganizationId, + pub client_sub: String, pub scope: Vec, } -pub fn generate_organization_api_key_login_claims( - org_api_key_uuid: OrgApiKeyId, - org_id: OrganizationId, -) -> OrgApiKeyLoginJwtClaims { +pub fn generate_organization_api_key_login_claims(uuid: String, org_id: String) -> OrgApiKeyLoginJwtClaims { let time_now = Utc::now(); OrgApiKeyLoginJwtClaims { nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_hours(1).unwrap()).timestamp(), iss: JWT_ORG_API_KEY_ISSUER.to_string(), - sub: org_api_key_uuid, + sub: uuid, client_id: format!("organization.{org_id}"), client_sub: org_id, scope: vec!["api.organization".into()], @@ -402,74 +292,22 @@ pub struct FileDownloadClaims { // Issuer pub iss: String, // Subject - pub sub: CipherId, + pub sub: String, - pub file_id: AttachmentId, + pub file_id: String, } -pub fn generate_file_download_claims(cipher_id: CipherId, file_id: AttachmentId) -> FileDownloadClaims { +pub fn generate_file_download_claims(uuid: String, file_id: String) -> FileDownloadClaims { let time_now = Utc::now(); FileDownloadClaims { nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(), iss: JWT_FILE_DOWNLOAD_ISSUER.to_string(), - sub: cipher_id, + sub: uuid, file_id, } } -#[derive(Debug, Serialize, Deserialize)] -pub struct RegisterVerifyClaims { - // Not before - pub nbf: i64, - // Expiration time - pub exp: i64, - // Issuer - pub iss: String, - // Subject - pub sub: String, - - pub name: Option, - pub verified: bool, -} - -pub fn generate_register_verify_claims(email: String, name: Option, verified: bool) -> RegisterVerifyClaims { - let time_now = Utc::now(); - RegisterVerifyClaims { - nbf: time_now.timestamp(), - exp: (time_now + TimeDelta::try_minutes(30).unwrap()).timestamp(), - iss: JWT_REGISTER_VERIFY_ISSUER.to_string(), - sub: email, - name, - verified, - } -} - -#[derive(Serialize, Deserialize)] -pub struct TwoFactorRememberClaims { - // Not before - pub nbf: i64, - // Expiration time - pub exp: i64, - // Issuer - pub iss: String, - // Subject - pub sub: DeviceId, - // UserId - pub user_uuid: UserId, -} - -pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) -> TwoFactorRememberClaims { - let time_now = Utc::now(); - TwoFactorRememberClaims { - nbf: time_now.timestamp(), - exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(), - iss: JWT_2FA_REMEMBER_ISSUER.to_string(), - sub: device_uuid, - user_uuid, - } -} - #[derive(Debug, Serialize, Deserialize)] pub struct BasicJwtClaims { // Not before @@ -493,14 +331,14 @@ pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { } } -pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims { +pub fn generate_verify_email_claims(uuid: String) -> BasicJwtClaims { let time_now = Utc::now(); let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); BasicJwtClaims { nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_VERIFYEMAIL_ISSUER.to_string(), - sub: user_id.to_string(), + sub: uuid, } } @@ -514,7 +352,7 @@ pub fn generate_admin_claims() -> BasicJwtClaims { } } -pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims { +pub fn generate_send_claims(send_id: &str, file_id: &str) -> BasicJwtClaims { let time_now = Utc::now(); BasicJwtClaims { nbf: time_now.timestamp(), @@ -533,7 +371,7 @@ use rocket::{ }; use crate::db::{ - models::{Collection, Device, Membership, MembershipStatus, MembershipType, User, UserStampException}, + models::{Collection, Device, User, UserOrgStatus, UserOrgType, UserOrganization, UserStampException}, DbConn, }; @@ -633,32 +471,36 @@ impl<'r> FromRequest<'r> for Headers { }; // Check JWT token is valid and get device and user from it - let Ok(claims) = decode_login(access_token) else { - err_handler!("Invalid claim") + let claims = match decode_login(access_token) { + Ok(claims) => claims, + Err(_) => err_handler!("Invalid claim"), }; - let device_id = claims.device; - let user_id = claims.sub; + let device_uuid = claims.device; + let user_uuid = claims.sub; - let conn = match DbConn::from_request(request).await { + let mut conn = match DbConn::from_request(request).await { Outcome::Success(conn) => conn, _ => err_handler!("Error getting DB"), }; - let Some(device) = Device::find_by_uuid_and_user(&device_id, &user_id, &conn).await else { - err_handler!("Invalid device id") + let device = match Device::find_by_uuid_and_user(&device_uuid, &user_uuid, &mut conn).await { + Some(device) => device, + None => err_handler!("Invalid device id"), }; - let Some(user) = User::find_by_uuid(&user_id, &conn).await else { - err_handler!("Device has no user associated") + let user = match User::find_by_uuid(&user_uuid, &mut conn).await { + Some(user) => user, + None => err_handler!("Device has no user associated"), }; if user.security_stamp != claims.sstamp { 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") + let current_route = match request.route().and_then(|r| r.name.as_deref()) { + Some(name) => name, + _ => err_handler!("Error getting current route for stamp exception"), }; // Check if the stamp exception has expired first. @@ -669,8 +511,8 @@ impl<'r> FromRequest<'r> for Headers { // 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:#?}"); + if let Err(e) = user.save(&mut conn).await { + error!("Error updating user: {:#?}", e); } err_handler!("Stamp exception is expired") } else if !stamp_exception.routes.contains(¤t_route.to_string()) { @@ -696,59 +538,11 @@ pub struct OrgHeaders { pub host: String, pub device: Device, pub user: User, - pub membership_type: MembershipType, - pub membership_status: MembershipStatus, - pub membership: Membership, + pub org_user_type: UserOrgType, + pub org_user: UserOrganization, pub ip: ClientIp, } -impl OrgHeaders { - fn is_member(&self) -> bool { - // Only allow not revoked members, we can not use the Confirmed status here - // as some endpoints can be triggered by invited users during joining - self.membership_status != MembershipStatus::Revoked && self.membership_type >= MembershipType::User - } - fn is_confirmed_and_admin(&self) -> bool { - self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin - } - fn is_confirmed_and_manager(&self) -> bool { - self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Manager - } - fn is_confirmed_and_owner(&self) -> bool { - self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner - } -} - -// org_id is usually the second path param ("/organizations/"), -// but there are cases where it is a query value. -// First check the path, if this is not a valid uuid, try the query values. -fn get_org_id(request: &Request<'_>) -> Option { - if let Some(Ok(org_id)) = request.param::(1) { - Some(org_id) - } else if let Some(Ok(org_id)) = request.query_value::("organizationId") { - Some(org_id) - } else { - None - } -} - -// Special Guard to ensure that there is an organization id present -// If there is no org id trigger the Outcome::Forward. -// This is useful for endpoints which work for both organization and personal vaults, like purge. -pub struct OrgIdGuard; - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for OrgIdGuard { - type Error = &'static str; - - async fn from_request(request: &'r Request<'_>) -> Outcome { - match get_org_id(request) { - Some(_) => Outcome::Success(OrgIdGuard), - None => Outcome::Forward(rocket::http::Status::NotFound), - } - } -} - #[rocket::async_trait] impl<'r> FromRequest<'r> for OrgHeaders { type Error = &'static str; @@ -756,43 +550,58 @@ impl<'r> FromRequest<'r> for OrgHeaders { async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = try_outcome!(Headers::from_request(request).await); - // Extract the org_id from the request - let url_org_id = get_org_id(request); + // org_id is usually the second path param ("/organizations/"), + // but there are cases where it is a query value. + // First check the path, if this is not a valid uuid, try the query values. + let url_org_id: Option<&str> = { + let mut url_org_id = None; + if let Some(Ok(org_id)) = request.param::<&str>(1) { + if uuid::Uuid::parse_str(org_id).is_ok() { + url_org_id = Some(org_id); + } + } + + if let Some(Ok(org_id)) = request.query_value::<&str>("organizationId") { + if uuid::Uuid::parse_str(org_id).is_ok() { + url_org_id = Some(org_id); + } + } + + url_org_id + }; match url_org_id { - Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => { - let conn = match DbConn::from_request(request).await { + Some(org_id) => { + let mut conn = match DbConn::from_request(request).await { Outcome::Success(conn) => conn, _ => err_handler!("Error getting DB"), }; let user = headers.user; - let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await else { - err_handler!("The current user isn't member of the organization"); + let org_user = match UserOrganization::find_by_user_and_org(&user.uuid, org_id, &mut conn).await { + Some(user) => { + if user.status == UserOrgStatus::Confirmed as i32 { + user + } else { + err_handler!("The current user isn't confirmed member of the organization") + } + } + None => err_handler!("The current user isn't member of the organization"), }; Outcome::Success(Self { host: headers.host, device: headers.device, user, - membership_type: { - if let Some(member_type) = MembershipType::from_i32(membership.atype) { - member_type + org_user_type: { + if let Some(org_usr_type) = UserOrgType::from_i32(org_user.atype) { + org_usr_type } else { // This should only happen if the DB is corrupted err_handler!("Unknown user type in the database") } }, - membership_status: { - if let Some(member_status) = MembershipStatus::from_i32(membership.status) { - // NOTE: add additional check for revoked if from_i32 is ever changed - // to return Revoked status. - member_status - } else { - err_handler!("User status is either revoked or invalid.") - } - }, - membership, + org_user, ip: headers.ip, }) } @@ -805,9 +614,9 @@ pub struct AdminHeaders { pub host: String, pub device: Device, pub user: User, - pub membership_type: MembershipType, + pub org_user_type: UserOrgType, + pub client_version: Option, pub ip: ClientIp, - pub org_id: OrganizationId, } #[rocket::async_trait] @@ -816,14 +625,15 @@ impl<'r> FromRequest<'r> for AdminHeaders { async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = try_outcome!(OrgHeaders::from_request(request).await); - if headers.is_confirmed_and_admin() { + let client_version = request.headers().get_one("Bitwarden-Client-Version").map(String::from); + if headers.org_user_type >= UserOrgType::Admin { Outcome::Success(Self { host: headers.host, device: headers.device, user: headers.user, - membership_type: headers.membership_type, + org_user_type: headers.org_user_type, + client_version, ip: headers.ip, - org_id: headers.membership.org_uuid, }) } else { err_handler!("You need to be Admin or Owner to call this endpoint") @@ -831,19 +641,30 @@ impl<'r> FromRequest<'r> for AdminHeaders { } } +impl From for Headers { + fn from(h: AdminHeaders) -> Headers { + Headers { + host: h.host, + device: h.device, + user: h.user, + ip: h.ip, + } + } +} + // col_id is usually the fourth path param ("/organizations//collections/"), // but there could be cases where it is a query value. // First check the path, if this is not a valid uuid, try the query values. -fn get_col_id(request: &Request<'_>) -> Option { +fn get_col_id(request: &Request<'_>) -> Option { if let Some(Ok(col_id)) = request.param::(3) { if uuid::Uuid::parse_str(&col_id).is_ok() { - return Some(col_id.into()); + return Some(col_id); } } if let Some(Ok(col_id)) = request.query_value::("collectionId") { if uuid::Uuid::parse_str(&col_id).is_ok() { - return Some(col_id.into()); + return Some(col_id); } } @@ -858,7 +679,6 @@ pub struct ManagerHeaders { pub device: Device, pub user: User, pub ip: ClientIp, - pub org_id: OrganizationId, } #[rocket::async_trait] @@ -867,15 +687,15 @@ impl<'r> FromRequest<'r> for ManagerHeaders { async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = try_outcome!(OrgHeaders::from_request(request).await); - if headers.is_confirmed_and_manager() { + if headers.org_user_type >= UserOrgType::Manager { match get_col_id(request) { Some(col_id) => { - let conn = match DbConn::from_request(request).await { + let mut conn = match DbConn::from_request(request).await { Outcome::Success(conn) => conn, _ => err_handler!("Error getting DB"), }; - if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await { + if !Collection::can_access_collection(&headers.org_user, &col_id, &mut conn).await { err_handler!("The current user isn't a manager for this collection") } } @@ -887,7 +707,6 @@ impl<'r> FromRequest<'r> for ManagerHeaders { device: headers.device, user: headers.user, ip: headers.ip, - org_id: headers.membership.org_uuid, }) } else { err_handler!("You need to be a Manager, Admin or Owner to call this endpoint") @@ -912,7 +731,7 @@ pub struct ManagerHeadersLoose { pub host: String, pub device: Device, pub user: User, - pub membership: Membership, + pub org_user: UserOrganization, pub ip: ClientIp, } @@ -922,12 +741,12 @@ impl<'r> FromRequest<'r> for ManagerHeadersLoose { async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = try_outcome!(OrgHeaders::from_request(request).await); - if headers.is_confirmed_and_manager() { + if headers.org_user_type >= UserOrgType::Manager { Outcome::Success(Self { host: headers.host, device: headers.device, user: headers.user, - membership: headers.membership, + org_user: headers.org_user, ip: headers.ip, }) } else { @@ -950,15 +769,15 @@ impl From for Headers { impl ManagerHeaders { pub async fn from_loose( h: ManagerHeadersLoose, - collections: &Vec, - conn: &DbConn, + collections: &Vec, + conn: &mut DbConn, ) -> Result { for col_id in collections { - if uuid::Uuid::parse_str(col_id.as_ref()).is_err() { + if uuid::Uuid::parse_str(col_id).is_err() { err!("Collection Id is malformed!"); } - if !Collection::is_coll_manageable_by_user(col_id, &h.membership.user_uuid, conn).await { - err!("Collection not found", "The current user isn't a manager for this collection") + if !Collection::can_access_collection(&h.org_user, col_id, conn).await { + err!("You don't have access to all collections!"); } } @@ -967,7 +786,6 @@ impl ManagerHeaders { device: h.device, user: h.user, ip: h.ip, - org_id: h.membership.org_uuid, }) } } @@ -976,7 +794,6 @@ pub struct OwnerHeaders { pub device: Device, pub user: User, pub ip: ClientIp, - pub org_id: OrganizationId, } #[rocket::async_trait] @@ -985,12 +802,11 @@ impl<'r> FromRequest<'r> for OwnerHeaders { async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = try_outcome!(OrgHeaders::from_request(request).await); - if headers.is_confirmed_and_owner() { + if headers.org_user_type == UserOrgType::Owner { Outcome::Success(Self { device: headers.device, user: headers.user, ip: headers.ip, - org_id: headers.membership.org_uuid, }) } else { err_handler!("You need to be Owner to call this endpoint") @@ -998,45 +814,6 @@ impl<'r> FromRequest<'r> for OwnerHeaders { } } -pub struct OrgMemberHeaders { - pub host: String, - pub device: Device, - pub user: User, - pub membership: Membership, - pub ip: ClientIp, -} - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for OrgMemberHeaders { - type Error = &'static str; - - async fn from_request(request: &'r Request<'_>) -> Outcome { - let headers = try_outcome!(OrgHeaders::from_request(request).await); - if headers.is_member() { - Outcome::Success(Self { - host: headers.host, - device: headers.device, - user: headers.user, - membership: headers.membership, - ip: headers.ip, - }) - } else { - err_handler!("You need to be a Member of the Organization to call this endpoint") - } - } -} - -impl From for Headers { - fn from(h: OrgMemberHeaders) -> Headers { - Headers { - host: h.host, - device: h.device, - user: h.user, - ip: h.ip, - } - } -} - // // Client IP address detection // @@ -1057,7 +834,7 @@ impl<'r> FromRequest<'r> for ClientIp { None => ip, } .parse() - .map_err(|_| warn!("'{}' header is malformed: {ip}", CONFIG.ip_header())) + .map_err(|_| warn!("'{}' header is malformed: {}", CONFIG.ip_header(), ip)) .ok() }) } else { @@ -1123,186 +900,3 @@ impl<'r> FromRequest<'r> for WsAccessTokenHeader { }) } } - -pub struct ClientVersion(pub semver::Version); - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for ClientVersion { - type Error = &'static str; - - async fn from_request(request: &'r Request<'_>) -> Outcome { - let headers = request.headers(); - - let Some(version) = headers.get_one("Bitwarden-Client-Version") else { - err_handler!("No Bitwarden-Client-Version header provided") - }; - - let Ok(version) = semver::Version::parse(version) else { - err_handler!("Invalid Bitwarden-Client-Version header provided") - }; - - Outcome::Success(ClientVersion(version)) - } -} - -#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AuthMethod { - OrgApiKey, - Password, - Sso, - UserApiKey, -} - -impl AuthMethod { - pub fn scope(&self) -> String { - match self { - AuthMethod::OrgApiKey => "api.organization".to_string(), - AuthMethod::Password => "api offline_access".to_string(), - AuthMethod::Sso => "api offline_access".to_string(), - AuthMethod::UserApiKey => "api".to_string(), - } - } - - pub fn scope_vec(&self) -> Vec { - self.scope().split_whitespace().map(str::to_string).collect() - } - - pub fn check_scope(&self, scope: Option<&String>) -> ApiResult { - let method_scope = self.scope(); - match scope { - None => err!("Missing scope"), - Some(scope) if scope == &method_scope => Ok(method_scope), - Some(scope) => err!(format!("Scope ({scope}) not supported")), - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub enum TokenWrapper { - Access(String), - Refresh(String), -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct RefreshJwtClaims { - // Not before - pub nbf: i64, - // Expiration time - pub exp: i64, - // Issuer - pub iss: String, - // Subject - pub sub: AuthMethod, - - pub device_token: String, - - pub token: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct AuthTokens { - pub refresh_claims: RefreshJwtClaims, - pub access_claims: LoginJwtClaims, -} - -impl AuthTokens { - pub fn refresh_token(&self) -> String { - encode_jwt(&self.refresh_claims) - } - - pub fn access_token(&self) -> String { - self.access_claims.token() - } - - pub fn expires_in(&self) -> i64 { - self.access_claims.expires_in() - } - - pub fn scope(&self) -> String { - self.refresh_claims.sub.scope() - } - - // Create refresh_token and access_token with default validity - pub fn new(device: &Device, user: &User, sub: AuthMethod, client_id: Option) -> Self { - let time_now = Utc::now(); - - let access_claims = LoginJwtClaims::default(device, user, &sub, client_id); - - let validity = if device.is_mobile() { - *MOBILE_REFRESH_VALIDITY - } else { - *DEFAULT_REFRESH_VALIDITY - }; - - let refresh_claims = RefreshJwtClaims { - nbf: time_now.timestamp(), - exp: (time_now + validity).timestamp(), - iss: JWT_LOGIN_ISSUER.to_string(), - sub, - device_token: device.refresh_token.clone(), - token: None, - }; - - Self { - refresh_claims, - access_claims, - } - } -} - -pub async fn refresh_tokens( - ip: &ClientIp, - refresh_token: &str, - client_id: Option, - conn: &DbConn, -) -> ApiResult<(Device, AuthTokens)> { - let refresh_claims = match decode_refresh(refresh_token) { - Err(err) => { - error!("Failed to decode {} refresh_token: {refresh_token}: {err:?}", ip.ip); - //err_silent!(format!("Impossible to read refresh_token: {}", err.message())) - - // If the token failed to decode, it was probably one of the old style tokens that was just a Base64 string. - // We can generate a claim for them for backwards compatibility. Note that the password refresh claims don't - // check expiration or issuer, so they're not included here. - RefreshJwtClaims { - nbf: 0, - exp: 0, - iss: String::new(), - sub: AuthMethod::Password, - device_token: refresh_token.into(), - token: None, - } - } - Ok(claims) => claims, - }; - - // Get device by refresh token - let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await { - None => err!("Invalid refresh token"), - Some(device) => device, - }; - - // Save to update `updated_at`. - device.save(true, conn).await?; - - let user = match User::find_by_uuid(&device.user_uuid, conn).await { - None => err!("Impossible to find user"), - Some(user) => user, - }; - - let auth_tokens = match refresh_claims.sub { - AuthMethod::Sso if CONFIG.sso_enabled() && CONFIG.sso_auth_only_not_session() => { - AuthTokens::new(&device, &user, refresh_claims.sub, client_id) - } - AuthMethod::Sso if CONFIG.sso_enabled() => { - sso::exchange_refresh_token(&device, &user, client_id, refresh_claims).await? - } - AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"), - AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"), - AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id), - _ => err!("Invalid auth method, cannot refresh token"), - }; - - Ok((device, auth_tokens)) -} diff --git a/src/config.rs b/src/config.rs index ae995f69..aa6b1145 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,56 +1,29 @@ -use std::{ - env::consts::EXE_SUFFIX, - fmt, - process::exit, - sync::{ - atomic::{AtomicBool, Ordering}, - LazyLock, RwLock, - }, +use std::env::consts::EXE_SUFFIX; +use std::process::exit; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + RwLock, }; use job_scheduler_ng::Schedule; +use once_cell::sync::Lazy; use reqwest::Url; -use serde::de::{self, Deserialize, Deserializer, MapAccess, Visitor}; use crate::{ + db::DbConnType, error::Error, - util::{ - get_active_web_release, get_env, get_env_bool, is_valid_email, parse_experimental_client_feature_flags, - FeatureFlagFilter, - }, + util::{get_env, get_env_bool, parse_experimental_client_feature_flags}, }; -static CONFIG_FILE: LazyLock = LazyLock::new(|| { +static CONFIG_FILE: Lazy = Lazy::new(|| { let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data")); get_env("CONFIG_FILE").unwrap_or_else(|| format!("{data_folder}/config.json")) }); -static CONFIG_FILE_PARENT_DIR: LazyLock = LazyLock::new(|| { - let path = std::path::PathBuf::from(&*CONFIG_FILE); - path.parent().unwrap_or(std::path::Path::new("data")).to_str().unwrap_or("data").to_string() -}); - -static CONFIG_FILENAME: LazyLock = LazyLock::new(|| { - let path = std::path::PathBuf::from(&*CONFIG_FILE); - path.file_name().unwrap_or(std::ffi::OsStr::new("config.json")).to_str().unwrap_or("config.json").to_string() -}); - pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false); -pub static CONFIG: LazyLock = LazyLock::new(|| { - std::thread::spawn(|| { - let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap_or_else(|e| { - println!("Error loading config:\n {e:?}\n"); - exit(12) - }); - - rt.block_on(Config::load()).unwrap_or_else(|e| { - println!("Error loading config:\n {e:?}\n"); - exit(12) - }) - }) - .join() - .unwrap_or_else(|e| { +pub static CONFIG: Lazy = Lazy::new(|| { + Config::load().unwrap_or_else(|e| { println!("Error loading config:\n {e:?}\n"); exit(12) }) @@ -59,41 +32,6 @@ pub static CONFIG: LazyLock = LazyLock::new(|| { pub type Pass = String; macro_rules! make_config { - // Support string print - ( @supportstr $name:ident, $value:expr, Pass, option ) => { serde_json::to_value(&$value.as_ref().map(|_| String::from("***"))).unwrap() }; // Optional pass, we map to an Option with "***" - ( @supportstr $name:ident, $value:expr, Pass, $none_action:ident ) => { "***".into() }; // Required pass, we return "***" - ( @supportstr $name:ident, $value:expr, $ty:ty, option ) => { serde_json::to_value(&$value).unwrap() }; // Optional other or string, we convert to json - ( @supportstr $name:ident, $value:expr, String, $none_action:ident ) => { $value.as_str().into() }; // Required string value, we convert to json - ( @supportstr $name:ident, $value:expr, $ty:ty, $none_action:ident ) => { ($value).into() }; // Required other value, we return as is or convert to json - - // Group or empty string - ( @show ) => { "" }; - ( @show $lit:literal ) => { $lit }; - - // Wrap the optionals in an Option type - ( @type $ty:ty, option) => { Option<$ty> }; - ( @type $ty:ty, $id:ident) => { $ty }; - - // Generate the values depending on none_action - ( @build $value:expr, $config:expr, option, ) => { $value }; - ( @build $value:expr, $config:expr, def, $default:expr ) => { $value.unwrap_or($default) }; - ( @build $value:expr, $config:expr, auto, $default_fn:expr ) => {{ - match $value { - Some(v) => v, - None => { - let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn; - f($config) - } - } - }}; - ( @build $value:expr, $config:expr, generated, $default_fn:expr ) => {{ - let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn; - f($config) - }}; - - ( @getenv $name:expr, bool ) => { get_env_bool($name) }; - ( @getenv $name:expr, $ty:ident ) => { get_env($name) }; - ($( $(#[doc = $groupdoc:literal])? $group:ident $(: $group_enabled:ident)? { @@ -113,103 +51,10 @@ macro_rules! make_config { _env: ConfigBuilder, _usr: ConfigBuilder, - _overrides: Vec<&'static str>, + _overrides: Vec, } - // Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html - // This deserialize doesn't care if there are keys missing, or if there are duplicate keys - // In case of duplicate keys (which should never be possible unless manually edited), the last value is used! - // Main reason for this is removing the `visit_seq` function, which causes a lot of code generation not needed or used for this struct. - impl<'de> Deserialize<'de> for ConfigBuilder { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - const FIELDS: &[&str] = &[ - $($( - stringify!($name), - )+)+ - ]; - - #[allow(non_camel_case_types)] - enum Field { - $($( - $name, - )+)+ - __ignore, - } - - impl<'de> Deserialize<'de> for Field { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct FieldVisitor; - - impl Visitor<'_> for FieldVisitor { - type Value = Field; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("ConfigBuilder field identifier") - } - - #[inline] - fn visit_str(self, value: &str) -> Result - where - E: de::Error, - { - match value { - $($( - stringify!($name) => Ok(Field::$name), - )+)+ - _ => Ok(Field::__ignore), - } - } - } - - deserializer.deserialize_identifier(FieldVisitor) - } - } - - struct ConfigBuilderVisitor; - - impl<'de> Visitor<'de> for ConfigBuilderVisitor { - type Value = ConfigBuilder; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("struct ConfigBuilder") - } - - #[inline] - fn visit_map(self, mut map: A) -> Result - where - A: MapAccess<'de>, - { - let mut builder = ConfigBuilder::default(); - while let Some(key) = map.next_key()? { - match key { - $($( - Field::$name => { - if builder.$name.is_some() { - return Err(de::Error::duplicate_field(stringify!($name))); - } - builder.$name = map.next_value()?; - } - )+)+ - Field::__ignore => { - let _ = map.next_value::()?; - } - } - } - Ok(builder) - } - } - - deserializer.deserialize_struct("ConfigBuilder", FIELDS, ConfigBuilderVisitor) - } - } - - #[derive(Clone, Default, Serialize)] + #[derive(Clone, Default, Deserialize, Serialize)] pub struct ConfigBuilder { $($( #[serde(skip_serializing_if = "Option::is_none")] @@ -218,6 +63,7 @@ macro_rules! make_config { } impl ConfigBuilder { + #[allow(clippy::field_reassign_with_default)] fn from_env() -> Self { let env_file = get_env("ENV_FILE").unwrap_or_else(|| String::from(".env")); match dotenvy::from_path(&env_file) { @@ -256,37 +102,28 @@ macro_rules! make_config { let mut builder = ConfigBuilder::default(); $($( - builder.$name = make_config! { @getenv pastey::paste!(stringify!([<$name:upper>])), $ty }; + builder.$name = make_config! { @getenv paste::paste!(stringify!([<$name:upper>])), $ty }; )+)+ builder } - async fn from_file() -> Result { - let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?; - let config_bytes = operator.read(&CONFIG_FILENAME).await?; - println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE); - serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into) - } - - fn clear_non_editable(&mut self) { - $($( - if !$editable { - self.$name = None; - } - )+)+ + fn from_file(path: &str) -> Result { + let config_str = std::fs::read_to_string(path)?; + println!("[INFO] Using saved config from `{path}` for configuration.\n"); + serde_json::from_str(&config_str).map_err(Into::into) } /// Merges the values of both builders into a new builder. /// If both have the same element, `other` wins. - fn merge(&self, other: &Self, show_overrides: bool, overrides: &mut Vec<&str>) -> Self { + fn merge(&self, other: &Self, show_overrides: bool, overrides: &mut Vec) -> Self { let mut builder = self.clone(); $($( if let v @Some(_) = &other.$name { builder.$name = v.clone(); if self.$name.is_some() { - overrides.push(pastey::paste!(stringify!([<$name:upper>]))); + overrides.push(paste::paste!(stringify!([<$name:upper>])).into()); } } )+)+ @@ -305,7 +142,7 @@ macro_rules! make_config { let mut config = ConfigItems::default(); let _domain_set = self.domain.is_some(); $($( - config.$name = make_config! { @build self.$name.clone(), &config, $none_action, $($default)? }; + config.$name = make_config!{ @build self.$name.clone(), &config, $none_action, $($default)? }; )+)+ config.domain_set = _domain_set; @@ -325,51 +162,24 @@ macro_rules! make_config { } #[derive(Clone, Default)] - struct ConfigItems { $($( $name: make_config! {@type $ty, $none_action}, )+)+ } - - #[derive(Serialize)] - struct ElementDoc { - name: &'static str, - description: &'static str, - } - - #[derive(Serialize)] - struct ElementData { - editable: bool, - name: &'static str, - value: serde_json::Value, - default: serde_json::Value, - #[serde(rename = "type")] - r#type: &'static str, - doc: ElementDoc, - overridden: bool, - } - - #[derive(Serialize)] - pub struct GroupData { - group: &'static str, - grouptoggle: &'static str, - groupdoc: &'static str, - elements: Vec, - } + struct ConfigItems { $($( $name: make_config!{@type $ty, $none_action}, )+)+ } #[allow(unused)] impl Config { $($( $(#[doc = $doc])+ - pub fn $name(&self) -> make_config! {@type $ty, $none_action} { + pub fn $name(&self) -> make_config!{@type $ty, $none_action} { self.inner.read().unwrap().config.$name.clone() } )+)+ pub fn prepare_json(&self) -> serde_json::Value { let (def, cfg, overridden) = { - // Lock the inner as short as possible and clone what is needed to prevent deadlocks let inner = &self.inner.read().unwrap(); (inner._env.build(), inner.config.clone(), inner._overrides.clone()) }; - fn _get_form_type(rust_type: &'static str) -> &'static str { + fn _get_form_type(rust_type: &str) -> &'static str { match rust_type { "Pass" => "password", "String" => "text", @@ -378,36 +188,48 @@ macro_rules! make_config { } } - fn _get_doc(doc_str: &'static str) -> ElementDoc { - let mut split = doc_str.split("|>").map(str::trim); - ElementDoc { - name: split.next().unwrap_or_default(), - description: split.next().unwrap_or_default(), - } + fn _get_doc(doc: &str) -> serde_json::Value { + let mut split = doc.split("|>").map(str::trim); + + // We do not use the json!() macro here since that causes a lot of macro recursion. + // This slows down compile time and it also causes issues with rust-analyzer + serde_json::Value::Object({ + let mut doc_json = serde_json::Map::new(); + doc_json.insert("name".into(), serde_json::to_value(split.next()).unwrap()); + doc_json.insert("description".into(), serde_json::to_value(split.next()).unwrap()); + doc_json + }) } - let data: Vec = vec![ - $( // This repetition is for each group - GroupData { - group: stringify!($group), - grouptoggle: stringify!($($group_enabled)?), - groupdoc: (make_config! { @show $($groupdoc)? }), + // We do not use the json!() macro here since that causes a lot of macro recursion. + // This slows down compile time and it also causes issues with rust-analyzer + serde_json::Value::Array(<[_]>::into_vec(Box::new([ + $( + serde_json::Value::Object({ + let mut group = serde_json::Map::new(); + group.insert("group".into(), (stringify!($group)).into()); + group.insert("grouptoggle".into(), (stringify!($($group_enabled)?)).into()); + group.insert("groupdoc".into(), (make_config!{ @show $($groupdoc)? }).into()); - elements: vec![ - $( // This repetition is for each element within a group - ElementData { - editable: $editable, - name: stringify!($name), - value: serde_json::to_value(&cfg.$name).unwrap_or_default(), - default: serde_json::to_value(&def.$name).unwrap_or_default(), - r#type: _get_form_type(stringify!($ty)), - doc: _get_doc(concat!($($doc),+)), - overridden: overridden.contains(&pastey::paste!(stringify!([<$name:upper>]))), - }, - )+], // End of elements repetition - }, - )+]; // End of groups repetition - serde_json::to_value(data).unwrap() + group.insert("elements".into(), serde_json::Value::Array(<[_]>::into_vec(Box::new([ + $( + serde_json::Value::Object({ + let mut element = serde_json::Map::new(); + element.insert("editable".into(), ($editable).into()); + element.insert("name".into(), (stringify!($name)).into()); + element.insert("value".into(), serde_json::to_value(cfg.$name).unwrap()); + element.insert("default".into(), serde_json::to_value(def.$name).unwrap()); + element.insert("type".into(), (_get_form_type(stringify!($ty))).into()); + element.insert("doc".into(), (_get_doc(concat!($($doc),+))).into()); + element.insert("overridden".into(), (overridden.contains(&paste::paste!(stringify!([<$name:upper>])).into())).into()); + element + }), + )+ + ])))); + group + }), + )+ + ]))) } pub fn get_support_json(&self) -> serde_json::Value { @@ -415,7 +237,6 @@ macro_rules! make_config { // Pass types will always be masked and no need to put them in the list. // Besides Pass, only String types will be masked via _privacy_mask. const PRIVACY_CONFIG: &[&str] = &[ - "allowed_connect_src", "allowed_iframe_ancestors", "database_url", "domain_origin", @@ -424,18 +245,12 @@ macro_rules! make_config { "helo_name", "org_creation_users", "signups_domains_whitelist", - "_smtp_img_src", - "smtp_from_name", "smtp_from", "smtp_host", "smtp_username", - "sso_authority", - "sso_callback_path", - "sso_client_id", ]; let cfg = { - // Lock the inner as short as possible and clone what is needed to prevent deadlocks let inner = &self.inner.read().unwrap(); inner.config.clone() }; @@ -465,21 +280,13 @@ macro_rules! make_config { serde_json::Value::Object({ let mut json = serde_json::Map::new(); $($( - json.insert(String::from(stringify!($name)), make_config! { @supportstr $name, cfg.$name, $ty, $none_action }); + json.insert(stringify!($name).into(), make_config!{ @supportstr $name, cfg.$name, $ty, $none_action }); )+)+; - // Loop through all privacy sensitive keys and mask them - for mask_key in PRIVACY_CONFIG { - if let Some(value) = json.get_mut(*mask_key) { - if let Some(s) = value.as_str() { - *value = _privacy_mask(s).into(); - } - } - } json }) } - pub fn get_overrides(&self) -> Vec<&'static str> { + pub fn get_overrides(&self) -> Vec { let overrides = { let inner = &self.inner.read().unwrap(); inner._overrides.clone() @@ -488,6 +295,55 @@ macro_rules! make_config { } } }; + + // Support string print + ( @supportstr $name:ident, $value:expr, Pass, option ) => { serde_json::to_value($value.as_ref().map(|_| String::from("***"))).unwrap() }; // Optional pass, we map to an Option with "***" + ( @supportstr $name:ident, $value:expr, Pass, $none_action:ident ) => { "***".into() }; // Required pass, we return "***" + ( @supportstr $name:ident, $value:expr, String, option ) => { // Optional other value, we return as is or convert to string to apply the privacy config + if PRIVACY_CONFIG.contains(&stringify!($name)) { + serde_json::to_value($value.as_ref().map(|x| _privacy_mask(x) )).unwrap() + } else { + serde_json::to_value($value).unwrap() + } + }; + ( @supportstr $name:ident, $value:expr, String, $none_action:ident ) => { // Required other value, we return as is or convert to string to apply the privacy config + if PRIVACY_CONFIG.contains(&stringify!($name)) { + _privacy_mask(&$value).into() + } else { + ($value).into() + } + }; + ( @supportstr $name:ident, $value:expr, $ty:ty, option ) => { serde_json::to_value($value).unwrap() }; // Optional other value, we return as is or convert to string to apply the privacy config + ( @supportstr $name:ident, $value:expr, $ty:ty, $none_action:ident ) => { ($value).into() }; // Required other value, we return as is or convert to string to apply the privacy config + + // Group or empty string + ( @show ) => { "" }; + ( @show $lit:literal ) => { $lit }; + + // Wrap the optionals in an Option type + ( @type $ty:ty, option) => { Option<$ty> }; + ( @type $ty:ty, $id:ident) => { $ty }; + + // Generate the values depending on none_action + ( @build $value:expr, $config:expr, option, ) => { $value }; + ( @build $value:expr, $config:expr, def, $default:expr ) => { $value.unwrap_or($default) }; + ( @build $value:expr, $config:expr, auto, $default_fn:expr ) => {{ + match $value { + Some(v) => v, + None => { + let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn; + f($config) + } + } + }}; + ( @build $value:expr, $config:expr, generated, $default_fn:expr ) => {{ + let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn; + f($config) + }}; + + ( @getenv $name:expr, bool ) => { get_env_bool($name) }; + ( @getenv $name:expr, $ty:ident ) => { get_env($name) }; + } //STRUCTURE: @@ -507,19 +363,19 @@ make_config! { /// Data folder |> Main data folder data_folder: String, false, def, "data".to_string(); /// Database URL - database_url: String, false, auto, |c| format!("{}/db.sqlite3", c.data_folder); + database_url: String, false, auto, |c| format!("{}/{}", c.data_folder, "db.sqlite3"); /// Icon cache folder - icon_cache_folder: String, false, auto, |c| format!("{}/icon_cache", c.data_folder); + icon_cache_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "icon_cache"); /// Attachments folder - attachments_folder: String, false, auto, |c| format!("{}/attachments", c.data_folder); + attachments_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "attachments"); /// Sends folder - sends_folder: String, false, auto, |c| format!("{}/sends", c.data_folder); + sends_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "sends"); /// Temp folder |> Used for storing temporary file uploads - tmp_folder: String, false, auto, |c| format!("{}/tmp", c.data_folder); + tmp_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "tmp"); /// Templates folder - templates_folder: String, false, auto, |c| format!("{}/templates", c.data_folder); + templates_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "templates"); /// Session JWT key - rsa_key_filename: String, false, auto, |c| format!("{}/rsa_key", c.data_folder); + rsa_key_filename: String, false, auto, |c| format!("{}/{}", c.data_folder, "rsa_key"); /// Web vault folder web_vault_folder: String, false, def, "web-vault/".to_string(); }, @@ -567,9 +423,6 @@ make_config! { /// 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_string(); - /// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login. - /// Defaults to daily. Set blank to disable this job. - purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_string(); }, /// General settings @@ -619,8 +472,7 @@ make_config! { disable_icon_download: bool, true, def, false; /// Allow new signups |> Controls whether new users can register. Users can be invited by the vaultwarden admin even if this is disabled signups_allowed: bool, true, def, true; - /// Require email verification on signups. On new client versions, this will require verification at signup time. On older clients, - /// this will prevent logins from succeeding until the address has been verified + /// Require email verification on signups. 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: u64, true, def, 3_600; @@ -645,11 +497,11 @@ make_config! { /// Password iterations |> Number of server-side passwords hashing iterations for the password hash. /// The default for new users. If changed, it will be updated during login for existing users. password_iterations: i32, true, def, 600_000; - /// Allow password hints |> Controls whether users can set or show password hints. This setting applies globally to all users. + /// Allow password hints |> Controls whether users can set password hints. This setting applies globally to all users. password_hints_allowed: bool, true, def, true; - /// Show password hint (Know the risks!) |> Controls whether a password hint should be shown directly in the web page - /// if SMTP service is not configured and password hints are allowed. Not recommended for publicly-accessible instances - /// because this provides unauthenticated access to potentially sensitive data. + /// Show password hint |> Controls whether a password hint should be shown directly in the web page + /// if SMTP service is not configured. Not recommended for publicly-accessible instances as this + /// provides unauthenticated access to potentially sensitive data. show_password_hint: bool, true, def, false; /// Admin token/Argon2 PHC |> The plain text token or Argon2 PHC string used to authenticate in this very same page. Changing it here will not deauthorize the current session! @@ -714,7 +566,7 @@ make_config! { authenticator_disable_time_drift: bool, true, def, false; /// Customize the enabled feature flags on the clients |> This is a comma separated list of feature flags to enable. - experimental_client_feature_flags: String, false, def, String::new(); + experimental_client_feature_flags: String, false, def, "fido2-vault-credentials".to_string(); /// Require new device emails |> When a user logs in an email is required to be sent. /// If sending the email fails the login attempt will fail. @@ -745,15 +597,9 @@ make_config! { /// 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: u64, false, def, 600; - - /// Database connection max pool size + /// Database connection pool size database_max_conns: u32, false, def, 10; - /// Database connection min pool size - database_min_conns: u32, false, def, 2; - /// Database connection init |> SQL statements to run when creating a new database connection, mainly useful for connection-scoped pragmas. If empty, a database-specific default is used. database_conn_init: String, false, def, String::new(); @@ -763,9 +609,6 @@ 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 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: 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 @@ -792,46 +635,6 @@ make_config! { /// Bitwarden enforces this by default. In Vaultwarden we encouraged to use multiple organizations because groups were not available. /// Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy. enforce_single_org_with_reset_pw_policy: bool, false, def, false; - - /// Prefer IPv6 (AAAA) resolving |> This settings configures the DNS resolver to resolve IPv6 first, and if not available try IPv4 - /// This could be useful in IPv6 only environments. - dns_prefer_ipv6: bool, true, def, false; - }, - - /// OpenID Connect SSO settings - sso { - /// Enabled - sso_enabled: bool, true, def, false; - /// Only SSO login |> Disable Email+Master Password login - sso_only: bool, true, def, false; - /// Allow email association |> Associate existing non-SSO user based on email - sso_signups_match_email: bool, true, def, true; - /// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover. - sso_allow_unknown_email_verification: bool, true, def, false; - /// Client ID - sso_client_id: String, true, def, String::new(); - /// Client Key - sso_client_secret: Pass, true, def, String::new(); - /// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`) - sso_authority: String, true, def, String::new(); - /// Authorization request scopes |> List the of the needed scope (`openid` is implicit) - sso_scopes: String, true, def, "email profile".to_string(); - /// Authorization request extra parameters - sso_authorize_extra_params: String, true, def, String::new(); - /// Use PKCE during Authorization flow - sso_pkce: bool, true, def, true; - /// Regex for additional trusted Id token audience |> By default only the client_id is trusted. - sso_audience_trusted: String, true, option; - /// CallBack Path |> Generated from Domain. - sso_callback_path: String, true, generated, |c| generate_sso_callback_path(&c.domain); - /// Optional SSO master password policy |> Ex format: '{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' - 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 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; }, /// Yubikey settings @@ -852,9 +655,9 @@ make_config! { _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 + /// Integration Key duo_ikey: String, true, option; - /// Client Secret + /// Secret Key duo_skey: Pass, true, option; /// Host duo_host: String, true, option; @@ -869,7 +672,7 @@ make_config! { /// Use Sendmail |> Whether to send mail via the `sendmail` command use_sendmail: bool, true, def, false; /// Sendmail Command |> Which sendmail command to use. The one found in the $PATH is used if not specified. - sendmail_command: String, false, option; + sendmail_command: String, true, option; /// Host smtp_host: String, true, option; /// DEPRECATED smtp_ssl |> DEPRECATED - Please use SMTP_SECURITY @@ -892,7 +695,7 @@ make_config! { smtp_auth_mechanism: String, true, option; /// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server 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 + /// Server name sent during HELO |> By default this value should be is on 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; @@ -916,28 +719,21 @@ make_config! { 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: u64, true, def, 3; - /// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy + /// Automatically enforce at login |> Setup email 2FA provider 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 email_2fa_auto_fallback: bool, true, def, false; }, } -fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { +fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { // Validate connection URL is valid and DB feature is enabled - #[cfg(sqlite)] - { - use crate::db::DbConnType; - let url = &cfg.database_url; - if DbConnType::from_url(url)? == DbConnType::Sqlite && url.contains('/') { - let path = std::path::Path::new(&url); - if let Some(parent) = path.parent() { - if !parent.is_dir() { - err!(format!( - "SQLite database directory `{}` does not exist or is not a directory", - parent.display() - )); - } + let url = &cfg.database_url; + if DbConnType::from_url(url)? == DbConnType::sqlite && url.contains('/') { + let path = std::path::Path::new(&url); + if let Some(parent) = path.parent() { + if !parent.is_dir() { + err!(format!("SQLite database directory `{}` does not exist or is not a directory", parent.display())); } } } @@ -951,14 +747,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { err!(format!("`DATABASE_MAX_CONNS` contains an invalid value. Ensure it is between 1 and {limit}.",)); } - if cfg.database_min_conns < 1 || cfg.database_min_conns > limit { - err!(format!("`DATABASE_MIN_CONNS` contains an invalid value. Ensure it is between 1 and {limit}.",)); - } - - if cfg.database_min_conns > cfg.database_max_conns { - err!(format!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.",)); - } - if let Some(log_file) = &cfg.log_file { if std::fs::OpenOptions::new().append(true).create(true).open(log_file).is_err() { err!("Unable to write to log file", log_file); @@ -972,13 +760,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { ); } - 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() { - err!("ALLOWED_CONNECT_SRC variable contains one or more invalid URLs. Only FQDN's starting with https are allowed"); - } - } - let whitelist = &cfg.signups_domains_whitelist; if !whitelist.is_empty() && whitelist.split(',').any(|d| d.trim().is_empty()) { err!("`SIGNUPS_DOMAINS_WHITELIST` contains empty tokens"); @@ -1029,17 +810,15 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } } - let invalid_flags = - parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags, FeatureFlagFilter::InvalidOnly); + // TODO: deal with deprecated flags so they can be removed from this list, cf. #4263 + const KNOWN_FLAGS: &[&str] = + &["autofill-overlay", "autofill-v2", "browser-fileless-import", "extension-refresh", "fido2-vault-credentials"]; + let configured_flags = parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags); + let invalid_flags: Vec<_> = configured_flags.keys().filter(|flag| !KNOWN_FLAGS.contains(&flag.as_str())).collect(); if !invalid_flags.is_empty() { - let feature_flags_error = format!("Unrecognized experimental client feature flags: {:?}.\n\ + err!(format!("Unrecognized experimental client feature flags: {invalid_flags:?}.\n\n\ Please ensure all feature flags are spelled correctly and that they are supported in this version.\n\ - Supported flags: {:?}\n", invalid_flags, SUPPORTED_FEATURE_FLAGS); - if on_update { - err!(feature_flags_error); - } else { - println!("[WARNING] {feature_flags_error}"); - } + Supported flags: {KNOWN_FLAGS:?}")); } const MAX_FILESIZE_KB: i64 = i64::MAX >> 10; @@ -1069,16 +848,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { err!("All Duo options need to be set for global Duo support") } - if cfg.sso_enabled { - if cfg.sso_client_id.is_empty() || cfg.sso_client_secret.is_empty() || cfg.sso_authority.is_empty() { - err!("`SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` and `SSO_AUTHORITY` must be set for SSO support") - } - - validate_internal_sso_issuer_url(&cfg.sso_authority)?; - validate_internal_sso_redirect_url(&cfg.sso_callback_path)?; - validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; - } - if cfg._enable_yubico { if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support") @@ -1104,12 +873,12 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { let command = cfg.sendmail_command.clone().unwrap_or_else(|| format!("sendmail{EXE_SUFFIX}")); let mut path = std::path::PathBuf::from(&command); - // Check if we can find the sendmail command to execute when no absolute path is given + if !path.is_absolute() { - let Ok(which_path) = which::which(&command) else { - err!(format!("sendmail command {command} not found in $PATH")) - }; - path = which_path; + match which::which(&command) { + Ok(result) => path = result, + Err(_) => err!(format!("sendmail command {command:?} not found in $PATH")), + } } match path.metadata() { @@ -1143,8 +912,8 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } } - if (cfg.smtp_host.is_some() || cfg.use_sendmail) && !is_valid_email(&cfg.smtp_from) { - err!(format!("SMTP_FROM '{}' is not a valid email address", cfg.smtp_from)) + if (cfg.smtp_host.is_some() || cfg.use_sendmail) && !cfg.smtp_from.contains('@') { + err!("SMTP_FROM does not contain a mandatory @ sign") } if cfg._enable_email_2fa && cfg.email_token_size < 6 { @@ -1256,35 +1025,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { Ok(()) } -fn validate_internal_sso_issuer_url(sso_authority: &String) -> Result { - match openidconnect::IssuerUrl::new(sso_authority.clone()) { - Err(err) => err!(format!("Invalid sso_authority URL ({sso_authority}): {err}")), - Ok(issuer_url) => Ok(issuer_url), - } -} - -fn validate_internal_sso_redirect_url(sso_callback_path: &String) -> Result { - match openidconnect::RedirectUrl::new(sso_callback_path.clone()) { - Err(err) => err!(format!("Invalid sso_callback_path ({sso_callback_path} built using `domain`) URL: {err}")), - Ok(redirect_url) => Ok(redirect_url), - } -} - -fn validate_sso_master_password_policy( - sso_master_password_policy: Option<&String>, -) -> Result, Error> { - let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::(mpp)); - - match policy { - None => Ok(None), - Some(Ok(jsobject @ serde_json::Value::Object(_))) => Ok(Some(jsobject)), - Some(Ok(_)) => err!("Invalid sso_master_password_policy: parsed value is not a JSON object"), - Some(Err(error)) => { - err!(format!("Invalid sso_master_password_policy ({error}), Ensure that it's correctly escaped with ''")) - } - } -} - /// Extracts an RFC 6454 web origin from a URL. fn extract_url_origin(url: &str) -> String { match Url::parse(url) { @@ -1312,18 +1052,10 @@ fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String { if embed_images { "cid:".to_string() } else { - // normalize base_url - let base_url = domain.trim_end_matches('/'); - format!("{base_url}/vw_static/") + format!("{domain}/vw_static/") } } -fn generate_sso_callback_path(domain: &str) -> String { - // normalize base_url - let base_url = domain.trim_end_matches('/'); - format!("{base_url}/identity/connect/oidc-signin") -} - /// Generate the correct URL for the icon service. /// This will be used within icons.rs to call the external icon service. fn generate_icon_service_url(icon_service: &str) -> String { @@ -1366,132 +1098,11 @@ fn smtp_convert_deprecated_ssl_options(smtp_ssl: Option, smtp_explicit_tls "starttls".to_string() } -fn opendal_operator_for_path(path: &str) -> Result { - // Cache of previously built operators by path - static OPERATORS_BY_PATH: LazyLock> = - LazyLock::new(dashmap::DashMap::new); - - if let Some(operator) = OPERATORS_BY_PATH.get(path) { - return Ok(operator.clone()); - } - - let operator = if path.starts_with("s3://") { - #[cfg(not(s3))] - return Err(opendal::Error::new(opendal::ErrorKind::ConfigInvalid, "S3 support is not enabled").into()); - - #[cfg(s3)] - opendal_s3_operator_for_path(path)? - } else { - let builder = opendal::services::Fs::default().root(path); - opendal::Operator::new(builder)?.finish() - }; - - OPERATORS_BY_PATH.insert(path.to_string(), operator.clone()); - - Ok(operator) -} - -#[cfg(s3)] -fn opendal_s3_operator_for_path(path: &str) -> Result { - use crate::http_client::aws::AwsReqwestConnector; - use aws_config::{default_provider::credentials::DefaultCredentialsChain, provider_config::ProviderConfig}; - - // This is a custom AWS credential loader that uses the official AWS Rust - // SDK config crate to load credentials. This ensures maximum compatibility - // with AWS credential configurations. For example, OpenDAL doesn't support - // AWS SSO temporary credentials yet. - struct OpenDALS3CredentialLoader {} - - #[async_trait] - impl reqsign::AwsCredentialLoad for OpenDALS3CredentialLoader { - async fn load_credential(&self, _client: reqwest::Client) -> anyhow::Result> { - use aws_credential_types::provider::ProvideCredentials as _; - use tokio::sync::OnceCell; - - static DEFAULT_CREDENTIAL_CHAIN: OnceCell = OnceCell::const_new(); - - let chain = DEFAULT_CREDENTIAL_CHAIN - .get_or_init(|| { - let reqwest_client = reqwest::Client::builder().build().unwrap(); - let connector = AwsReqwestConnector { - client: reqwest_client, - }; - - let conf = ProviderConfig::default().with_http_client(connector); - - DefaultCredentialsChain::builder().configure(conf).build() - }) - .await; - - let creds = chain.provide_credentials().await?; - - Ok(Some(reqsign::AwsCredential { - access_key_id: creds.access_key_id().to_string(), - secret_access_key: creds.secret_access_key().to_string(), - session_token: creds.session_token().map(|s| s.to_string()), - expires_in: creds.expiry().map(|expiration| expiration.into()), - })) - } - } - - const OPEN_DAL_S3_CREDENTIAL_LOADER: OpenDALS3CredentialLoader = OpenDALS3CredentialLoader {}; - - let url = Url::parse(path).map_err(|e| format!("Invalid path S3 URL path {path:?}: {e}"))?; - - let bucket = url.host_str().ok_or_else(|| format!("Missing Bucket name in data folder S3 URL {path:?}"))?; - - let builder = opendal::services::S3::default() - .customized_credential_load(Box::new(OPEN_DAL_S3_CREDENTIAL_LOADER)) - .enable_virtual_host_style() - .bucket(bucket) - .root(url.path()) - .default_storage_class("INTELLIGENT_TIERING"); - - Ok(opendal::Operator::new(builder)?.finish()) -} - -pub enum PathType { - Data, - IconCache, - Attachments, - Sends, - RsaKey, -} - -// Official available feature flags can be found here: -// Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135 -// Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12 -// 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", - // Autofill Team - "ssh-agent", - "ssh-agent-v2", - // Key Management Team - "ssh-key-vault-item", - "pm-25373-windows-biometrics-v2", - // Mobile Team - "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", -]; - impl Config { - pub async fn load() -> Result { + pub fn load() -> Result { // Loading from env and file let _env = ConfigBuilder::from_env(); - let _usr = ConfigBuilder::from_file().await.unwrap_or_default(); + let _usr = ConfigBuilder::from_file(&CONFIG_FILE).unwrap_or_default(); // Create merged config, config file overwrites env let mut _overrides = Vec::new(); @@ -1500,7 +1111,7 @@ impl Config { // Fill any missing with defaults let config = builder.build(); if !SKIP_CONFIG_VALIDATION.load(Ordering::Relaxed) { - validate_config(&config, false)?; + validate_config(&config)?; } Ok(Config { @@ -1515,17 +1126,12 @@ impl Config { }) } - pub async fn update_config(&self, other: ConfigBuilder, ignore_non_editable: bool) -> Result<(), Error> { + pub fn update_config(&self, other: ConfigBuilder) -> Result<(), Error> { // Remove default values //let builder = other.remove(&self.inner.read().unwrap()._env); // TODO: Remove values that are defaults, above only checks those set by env and not the defaults - let mut builder = other; - - // Remove values that are not editable - if ignore_non_editable { - builder.clear_non_editable(); - } + let builder = other; // Serialize now before we consume the builder let config_str = serde_json::to_string_pretty(&builder)?; @@ -1536,7 +1142,7 @@ impl Config { let env = &self.inner.read().unwrap()._env; env.merge(&builder, false, &mut overrides).build() }; - validate_config(&config, true)?; + validate_config(&config)?; // Save both the user and the combined config { @@ -1547,19 +1153,20 @@ impl Config { } //Save to file - let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?; - operator.write(&CONFIG_FILENAME, config_str).await?; + use std::{fs::File, io::Write}; + let mut file = File::create(&*CONFIG_FILE)?; + file.write_all(config_str.as_bytes())?; Ok(()) } - async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> { + 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 + self.update_config(builder) } /// Tests whether an email's domain is allowed. A domain is allowed if it @@ -1568,7 +1175,7 @@ impl Config { 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}'"); + warn!("Failed to parse email address '{}'", email); return false; } let email_domain = e[0].to_lowercase(); @@ -1588,16 +1195,6 @@ impl Config { } } - // The registration link should be hidden if - // - Signup is not allowed and email whitelist is empty unless mail is disabled and invitations are allowed - // - The SSO is activated and password login is disabled. - pub fn is_signup_disabled(&self) -> bool { - (!self.signups_allowed() - && self.signups_domains_whitelist().is_empty() - && (self.mail_enabled() || !self.invitations_allowed())) - || (self.sso_enabled() && self.sso_only()) - } - /// Tests whether the specified user is allowed to create an organization. pub fn is_org_creation_allowed(&self, email: &str) -> bool { let users = self.org_creation_users(); @@ -1611,9 +1208,8 @@ impl Config { } } - pub async fn delete_user_config(&self) -> Result<(), Error> { - let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?; - operator.delete(&CONFIG_FILENAME).await?; + pub fn delete_user_config(&self) -> Result<(), Error> { + std::fs::remove_file(&*CONFIG_FILE)?; // Empty user config let usr = ConfigBuilder::default(); @@ -1643,27 +1239,23 @@ impl Config { inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail) } - pub async fn get_duo_akey(&self) -> String { + pub 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); + 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(); + self.update_config_partial(builder).ok(); akey_s } } - pub fn is_webauthn_2fa_supported(&self) -> bool { - Url::parse(&self.domain()).expect("DOMAIN not a valid URL").domain().is_some() - } - /// Tests whether the admin token is set to a non-empty value. pub fn is_admin_token_set(&self) -> bool { let token = self.admin_token(); @@ -1671,39 +1263,17 @@ impl Config { token.is_some() && !token.unwrap().trim().is_empty() } - pub fn opendal_operator_for_path_type(&self, path_type: &PathType) -> Result { - let path = match path_type { - PathType::Data => self.data_folder(), - PathType::IconCache => self.icon_cache_folder(), - PathType::Attachments => self.attachments_folder(), - PathType::Sends => self.sends_folder(), - PathType::RsaKey => std::path::Path::new(&self.rsa_key_filename()) - .parent() - .ok_or_else(|| std::io::Error::other("Failed to get directory of RSA key file"))? - .to_str() - .ok_or_else(|| std::io::Error::other("Failed to convert RSA key file directory to UTF-8 string"))? - .to_string(), - }; - - opendal_operator_for_path(&path) - } - pub fn render_template(&self, name: &str, data: &T) -> Result { if self.reload_templates() { warn!("RELOADING TEMPLATES"); let hb = load_templates(CONFIG.templates_folder()); hb.render(name, data).map_err(Into::into) } else { - let hb = &self.inner.read().unwrap().templates; + let hb = &CONFIG.inner.read().unwrap().templates; hb.render(name, data).map_err(Into::into) } } - pub fn render_fallback_template(&self, name: &str, data: &T) -> Result { - let hb = &self.inner.read().unwrap().templates; - hb.render(&format!("fallback_{name}"), data).map_err(Into::into) - } - pub fn set_rocket_shutdown_handle(&self, handle: rocket::Shutdown) { self.inner.write().unwrap().rocket_shutdown_handle = Some(handle); } @@ -1715,26 +1285,6 @@ impl Config { } } } - - pub fn sso_issuer_url(&self) -> Result { - validate_internal_sso_issuer_url(&self.sso_authority()) - } - - pub fn sso_redirect_url(&self) -> Result { - validate_internal_sso_redirect_url(&self.sso_callback_path()) - } - - pub fn sso_master_password_policy_value(&self) -> Option { - validate_sso_master_password_policy(self.sso_master_password_policy().as_ref()).ok().flatten() - } - - pub fn sso_scopes_vec(&self) -> Vec { - self.sso_scopes().split_whitespace().map(str::to_string).collect() - } - - pub fn sso_authorize_extra_params_vec(&self) -> Vec<(String, String)> { - url::form_urlencoded::parse(self.sso_authorize_extra_params().as_bytes()).into_owned().collect() - } } use handlebars::{ @@ -1752,8 +1302,6 @@ where // Register helpers hb.register_helper("case", Box::new(case_helper)); hb.register_helper("to_json", Box::new(to_json)); - hb.register_helper("webver", Box::new(webver)); - hb.register_helper("vwver", Box::new(vwver)); macro_rules! reg { ($name:expr) => {{ @@ -1764,11 +1312,6 @@ where reg!($name); reg!(concat!($name, $ext)); }}; - (@withfallback $name:expr) => {{ - let template = include_str!(concat!("static/templates/", $name, ".hbs")); - hb.register_template_string($name, template).unwrap(); - hb.register_template_string(concat!("fallback_", $name), template).unwrap(); - }}; } // First register default templates here @@ -1777,8 +1320,6 @@ where reg!("email/email_footer_text"); reg!("email/admin_reset_password", ".html"); - reg!("email/change_email_existing", ".html"); - reg!("email/change_email_invited", ".html"); reg!("email/change_email", ".html"); reg!("email/delete_account", ".html"); reg!("email/emergency_access_invite_accepted", ".html"); @@ -1795,13 +1336,11 @@ where reg!("email/protected_action", ".html"); reg!("email/pw_hint_none", ".html"); reg!("email/pw_hint_some", ".html"); - reg!("email/register_verify_email", ".html"); reg!("email/send_2fa_removed_from_org", ".html"); reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_org_invite", ".html"); reg!("email/send_single_org_removed_from_org", ".html"); reg!("email/smtp_test", ".html"); - reg!("email/sso_change_email", ".html"); reg!("email/twofactor_email", ".html"); reg!("email/verify_email", ".html"); reg!("email/welcome_must_verify", ".html"); @@ -1816,9 +1355,6 @@ where reg!("404"); - reg!(@withfallback "scss/vaultwarden.scss"); - reg!("scss/user.vaultwarden.scss"); - // And then load user templates to overwrite the defaults // Use .hbs extension for the files // Templates get registered with their relative name @@ -1861,42 +1397,3 @@ fn to_json<'reg, 'rc>( out.write(&json)?; Ok(()) } - -// Configure the web-vault version as an integer so it can be used as a comparison smaller or greater then. -// The default is based upon the version since this feature is added. -static WEB_VAULT_VERSION: LazyLock = LazyLock::new(|| { - let vault_version = get_active_web_release(); - // Use a single regex capture to extract version components - let re = regex::Regex::new(r"(\d{4})\.(\d{1,2})\.(\d{1,2})").unwrap(); - re.captures(&vault_version) - .and_then(|c| { - (c.len() == 4).then(|| { - format!("{}.{}.{}", c.get(1).unwrap().as_str(), c.get(2).unwrap().as_str(), c.get(3).unwrap().as_str()) - }) - }) - .and_then(|v| semver::Version::parse(&v).ok()) - .unwrap_or_else(|| semver::Version::parse("2024.6.2").unwrap()) -}); - -// Configure the Vaultwarden version as an integer so it can be used as a comparison smaller or greater then. -// The default is based upon the version since this feature is added. -static VW_VERSION: LazyLock = LazyLock::new(|| { - let vw_version = crate::VERSION.unwrap_or("1.32.5"); - // Use a single regex capture to extract version components - let re = regex::Regex::new(r"(\d{1})\.(\d{1,2})\.(\d{1,2})").unwrap(); - re.captures(vw_version) - .and_then(|c| { - (c.len() == 4).then(|| { - format!("{}.{}.{}", c.get(1).unwrap().as_str(), c.get(2).unwrap().as_str(), c.get(3).unwrap().as_str()) - }) - }) - .and_then(|v| semver::Version::parse(&v).ok()) - .unwrap_or_else(|| semver::Version::parse("1.32.5").unwrap()) -}); - -handlebars::handlebars_helper!(webver: | web_vault_version: String | - semver::VersionReq::parse(&web_vault_version).expect("Invalid web-vault version compare string").matches(&WEB_VAULT_VERSION) -); -handlebars::handlebars_helper!(vwver: | vw_version: String | - semver::VersionReq::parse(&vw_version).expect("Invalid Vaultwarden version compare string").matches(&VW_VERSION) -); diff --git a/src/crypto.rs b/src/crypto.rs index 46d305a5..99f0fb91 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -6,7 +6,7 @@ use std::num::NonZeroU32; use data_encoding::{Encoding, HEXLOWER}; use ring::{digest, hmac, pbkdf2}; -const DIGEST_ALG: pbkdf2::Algorithm = pbkdf2::PBKDF2_HMAC_SHA256; +static DIGEST_ALG: pbkdf2::Algorithm = pbkdf2::PBKDF2_HMAC_SHA256; const OUTPUT_LEN: usize = digest::SHA256_OUTPUT_LEN; pub fn hash_password(secret: &[u8], salt: &[u8], iterations: u32) -> Vec { @@ -48,20 +48,20 @@ pub fn get_random_bytes() -> [u8; N] { } /// Encode random bytes using the provided function. -pub fn encode_random_bytes(e: &Encoding) -> String { +pub fn encode_random_bytes(e: Encoding) -> String { e.encode(&get_random_bytes::()) } /// Generates a random string over a specified alphabet. pub fn get_random_string(alphabet: &[u8], num_chars: usize) -> String { // Ref: https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html - use rand::RngExt; - let mut rng = rand::rng(); + use rand::Rng; + let mut rng = rand::thread_rng(); (0..num_chars) .map(|_| { - let i = rng.random_range(0..alphabet.len()); - char::from(alphabet[i]) + let i = rng.gen_range(0..alphabet.len()); + alphabet[i] as char }) .collect() } @@ -81,18 +81,17 @@ pub fn get_random_string_alphanum(num_chars: usize) -> String { } pub fn generate_id() -> String { - encode_random_bytes::(&HEXLOWER) + encode_random_bytes::(HEXLOWER) } -pub fn generate_send_file_id() -> String { - // Send File IDs are globally scoped, so make them longer to avoid collisions. +pub fn generate_send_id() -> String { + // Send IDs are globally scoped, so make them longer to avoid collisions. generate_id::<32>() // 256 bits } -use crate::db::models::AttachmentId; -pub fn generate_attachment_id() -> AttachmentId { +pub fn generate_attachment_id() -> String { // Attachment IDs are scoped to a cipher, so they can be smaller. - AttachmentId(generate_id::<10>()) // 80 bits + generate_id::<10>() // 80 bits } /// Generates a numeric token for email-based verifications. @@ -110,13 +109,7 @@ pub fn generate_api_key() -> String { // Constant time compare // pub fn ct_eq, U: AsRef<[u8]>>(a: T, b: U) -> bool { - use subtle::ConstantTimeEq; - a.as_ref().ct_eq(b.as_ref()).into() -} + use ring::constant_time::verify_slices_are_equal; -// -// SHA256 -// -pub fn sha256_hex(data: &[u8]) -> String { - HEXLOWER.encode(digest::digest(&digest::SHA256, data).as_ref()) + verify_slices_are_equal(a.as_ref(), b.as_ref()).is_ok() } diff --git a/src/db/mod.rs b/src/db/mod.rs index d2ed9479..fe1ab79b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,14 +1,8 @@ -mod query_logger; - -use std::{ - sync::{Arc, OnceLock}, - time::Duration, -}; +use std::{sync::Arc, time::Duration}; use diesel::{ connection::SimpleConnection, - r2d2::{CustomizeConnection, Pool, PooledConnection}, - Connection, RunQueryDsl, + r2d2::{ConnectionManager, CustomizeConnection, Pool, PooledConnection}, }; use rocket::{ @@ -27,7 +21,20 @@ use crate::{ CONFIG, }; +#[cfg(sqlite)] +#[path = "schemas/sqlite/schema.rs"] +pub mod __sqlite_schema; + +#[cfg(mysql)] +#[path = "schemas/mysql/schema.rs"] +pub mod __mysql_schema; + +#[cfg(postgresql)] +#[path = "schemas/postgresql/schema.rs"] +pub mod __postgresql_schema; + // These changes are based on Rocket 0.5-rc wrapper of Diesel: https://github.com/SergioBenitez/Rocket/blob/v0.5-rc/contrib/sync_db_pools + // A wrapper around spawn_blocking that propagates panics to the calling code. pub async fn run_blocking(job: F) -> R where @@ -44,230 +51,160 @@ where } // This is used to generate the main DbConn and DbPool enums, which contain one variant for each database supported -#[derive(diesel::MultiConnection)] -pub enum DbConnInner { - #[cfg(mysql)] - Mysql(diesel::mysql::MysqlConnection), - #[cfg(postgresql)] - Postgresql(diesel::pg::PgConnection), - #[cfg(sqlite)] - Sqlite(diesel::sqlite::SqliteConnection), -} +macro_rules! generate_connections { + ( $( $name:ident: $ty:ty ),+ ) => { + #[allow(non_camel_case_types, dead_code)] + #[derive(Eq, PartialEq)] + pub enum DbConnType { $( $name, )+ } -/// Custom connection manager that implements manual connection establishment -pub struct DbConnManager { - database_url: String, -} - -impl DbConnManager { - pub fn new(database_url: &str) -> Self { - Self { - database_url: database_url.to_string(), - } - } - - fn establish_connection(&self) -> Result { - match DbConnType::from_url(&self.database_url) { - #[cfg(mysql)] - Ok(DbConnType::Mysql) => { - let conn = diesel::mysql::MysqlConnection::establish(&self.database_url)?; - Ok(DbConnInner::Mysql(conn)) - } - #[cfg(postgresql)] - Ok(DbConnType::Postgresql) => { - let conn = diesel::pg::PgConnection::establish(&self.database_url)?; - Ok(DbConnInner::Postgresql(conn)) - } - #[cfg(sqlite)] - Ok(DbConnType::Sqlite) => { - let conn = diesel::sqlite::SqliteConnection::establish(&self.database_url)?; - Ok(DbConnInner::Sqlite(conn)) - } - - Err(e) => Err(diesel::r2d2::Error::ConnectionError(diesel::ConnectionError::InvalidConnectionUrl( - format!("Unable to estabilsh a connection: {e:?}"), - ))), - } - } -} - -impl diesel::r2d2::ManageConnection for DbConnManager { - type Connection = DbConnInner; - type Error = diesel::r2d2::Error; - - fn connect(&self) -> Result { - self.establish_connection() - } - - fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> { - use diesel::r2d2::R2D2Connection; - conn.ping().map_err(diesel::r2d2::Error::QueryError) - } - - fn has_broken(&self, conn: &mut Self::Connection) -> bool { - use diesel::r2d2::R2D2Connection; - conn.is_broken() - } -} - -#[derive(Eq, PartialEq)] -pub enum DbConnType { - #[cfg(mysql)] - Mysql, - #[cfg(postgresql)] - Postgresql, - #[cfg(sqlite)] - Sqlite, -} - -pub static ACTIVE_DB_TYPE: OnceLock = OnceLock::new(); - -pub struct DbConn { - conn: Arc>>>, - permit: Option, -} - -#[derive(Debug)] -pub struct DbConnOptions { - pub init_stmts: String, -} - -impl CustomizeConnection for DbConnOptions { - fn on_acquire(&self, conn: &mut DbConnInner) -> Result<(), diesel::r2d2::Error> { - if !self.init_stmts.is_empty() { - conn.batch_execute(&self.init_stmts).map_err(diesel::r2d2::Error::QueryError)?; - } - Ok(()) - } -} - -#[derive(Clone)] -pub struct DbPool { - // This is an 'Option' so that we can drop the pool in a 'spawn_blocking'. - pool: Option>, - semaphore: Arc, -} - -impl Drop for DbConn { - fn drop(&mut self) { - let conn = Arc::clone(&self.conn); - let permit = self.permit.take(); - - // Since connection can't be on the stack in an async fn during an - // await, we have to spawn a new blocking-safe thread... - tokio::task::spawn_blocking(move || { - // And then re-enter the runtime to wait on the async mutex, but in a blocking fashion. - let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned()); - - if let Some(conn) = conn.take() { - drop(conn); - } - - // Drop permit after the connection is dropped - drop(permit); - }); - } -} - -impl Drop for DbPool { - fn drop(&mut self) { - let pool = self.pool.take(); - // Only use spawn_blocking if the Tokio runtime is still available - // Otherwise the pool will be dropped on the current thread - if let Ok(handle) = tokio::runtime::Handle::try_current() { - handle.spawn_blocking(move || drop(pool)); - } - } -} - -impl DbPool { - // For the given database URL, guess its type, run migrations, create pool, and return it - pub fn from_config() -> Result { - let db_url = CONFIG.database_url(); - let conn_type = DbConnType::from_url(&db_url)?; - - // Only set the default instrumentation if the log level is specifically set to either warn, info or debug - if log_enabled!(target: "vaultwarden::db::query_logger", log::Level::Warn) - || log_enabled!(target: "vaultwarden::db::query_logger", log::Level::Info) - || log_enabled!(target: "vaultwarden::db::query_logger", log::Level::Debug) - { - drop(diesel::connection::set_default_instrumentation(query_logger::simple_logger)); + pub struct DbConn { + conn: Arc>>, + permit: Option, } - match conn_type { - #[cfg(mysql)] - DbConnType::Mysql => { - mysql_migrations::run_migrations(&db_url)?; + #[allow(non_camel_case_types)] + pub enum DbConnInner { $( #[cfg($name)] $name(PooledConnection>), )+ } + + #[derive(Debug)] + pub struct DbConnOptions { + pub init_stmts: String, + } + + $( // Based on . + #[cfg($name)] + impl CustomizeConnection<$ty, diesel::r2d2::Error> for DbConnOptions { + fn on_acquire(&self, conn: &mut $ty) -> Result<(), diesel::r2d2::Error> { + if !self.init_stmts.is_empty() { + conn.batch_execute(&self.init_stmts).map_err(diesel::r2d2::Error::QueryError)?; + } + Ok(()) } - #[cfg(postgresql)] - DbConnType::Postgresql => { - postgresql_migrations::run_migrations(&db_url)?; - } - #[cfg(sqlite)] - DbConnType::Sqlite => { - sqlite_migrations::run_migrations(&db_url)?; + })+ + + #[derive(Clone)] + pub struct DbPool { + // This is an 'Option' so that we can drop the pool in a 'spawn_blocking'. + pool: Option, + semaphore: Arc + } + + #[allow(non_camel_case_types)] + #[derive(Clone)] + pub enum DbPoolInner { $( #[cfg($name)] $name(Pool>), )+ } + + impl Drop for DbConn { + fn drop(&mut self) { + let conn = Arc::clone(&self.conn); + let permit = self.permit.take(); + + // Since connection can't be on the stack in an async fn during an + // await, we have to spawn a new blocking-safe thread... + tokio::task::spawn_blocking(move || { + // And then re-enter the runtime to wait on the async mutex, but in a blocking fashion. + let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned()); + + if let Some(conn) = conn.take() { + drop(conn); + } + + // Drop permit after the connection is dropped + drop(permit); + }); } } - let max_conns = CONFIG.database_max_conns(); - let manager = DbConnManager::new(&db_url); - let pool = Pool::builder() - .max_size(max_conns) - .min_idle(Some(CONFIG.database_min_conns())) - .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(), - })) - .build(manager) - .map_res("Failed to create pool")?; - - // Set a global to determine the database more easily throughout the rest of the code - if ACTIVE_DB_TYPE.set(conn_type).is_err() { - error!("Tried to set the active database connection type more than once.") + impl Drop for DbPool { + fn drop(&mut self) { + let pool = self.pool.take(); + tokio::task::spawn_blocking(move || drop(pool)); + } } - Ok(DbPool { - pool: Some(pool), - semaphore: Arc::new(Semaphore::new(max_conns as usize)), - }) - } + impl DbPool { + // For the given database URL, guess its type, run migrations, create pool, and return it + pub fn from_config() -> Result { + let url = CONFIG.database_url(); + let conn_type = DbConnType::from_url(&url)?; - // Get a connection from the pool - pub async fn get(&self) -> Result { - 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(_) => { - err!("Timeout waiting for database connection"); + match conn_type { $( + DbConnType::$name => { + #[cfg($name)] + { + paste::paste!{ [< $name _migrations >]::run_migrations()?; } + let manager = ConnectionManager::new(&url); + let pool = Pool::builder() + .max_size(CONFIG.database_max_conns()) + .connection_timeout(Duration::from_secs(CONFIG.database_timeout())) + .connection_customizer(Box::new(DbConnOptions{ + init_stmts: conn_type.get_init_stmts() + })) + .build(manager) + .map_res("Failed to create pool")?; + Ok(DbPool { + pool: Some(DbPoolInner::$name(pool)), + semaphore: Arc::new(Semaphore::new(CONFIG.database_max_conns() as usize)), + }) + } + #[cfg(not($name))] + unreachable!("Trying to use a DB backend when it's feature is disabled") + }, + )+ } } - }; + // Get a connection from the pool + pub async fn get(&self) -> Result { + 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(_) => { + err!("Timeout waiting for database connection"); + } + }; - let p = self.pool.as_ref().expect("DbPool.pool should always be Some()"); - let pool = p.clone(); - let c = - run_blocking(move || pool.get_timeout(duration)).await.map_res("Error retrieving connection from pool")?; - Ok(DbConn { - conn: Arc::new(Mutex::new(Some(c))), - permit: Some(permit), - }) - } + match self.pool.as_ref().expect("DbPool.pool should always be Some()") { $( + #[cfg($name)] + DbPoolInner::$name(p) => { + let pool = p.clone(); + let c = run_blocking(move || pool.get_timeout(duration)).await.map_res("Error retrieving connection from pool")?; + + Ok(DbConn { + conn: Arc::new(Mutex::new(Some(DbConnInner::$name(c)))), + permit: Some(permit) + }) + }, + )+ } + } + } + }; +} + +#[cfg(not(query_logger))] +generate_connections! { + sqlite: diesel::sqlite::SqliteConnection, + mysql: diesel::mysql::MysqlConnection, + postgresql: diesel::pg::PgConnection +} + +#[cfg(query_logger)] +generate_connections! { + sqlite: diesel_logger::LoggingConnection, + mysql: diesel_logger::LoggingConnection, + postgresql: diesel_logger::LoggingConnection } impl DbConnType { - pub fn from_url(url: &str) -> Result { + pub fn from_url(url: &str) -> Result { // Mysql - if url.len() > 6 && &url[..6] == "mysql:" { + if url.starts_with("mysql:") { #[cfg(mysql)] - return Ok(DbConnType::Mysql); + return Ok(DbConnType::mysql); #[cfg(not(mysql))] err!("`DATABASE_URL` is a MySQL URL, but the 'mysql' feature is not enabled") - // Postgresql - } else if url.len() > 11 && (&url[..11] == "postgresql:" || &url[..9] == "postgres:") { + // Postgres + } else if url.starts_with("postgresql:") || url.starts_with("postgres:") { #[cfg(postgresql)] - return Ok(DbConnType::Postgresql); + return Ok(DbConnType::postgresql); #[cfg(not(postgresql))] err!("`DATABASE_URL` is a PostgreSQL URL, but the 'postgresql' feature is not enabled") @@ -275,7 +212,7 @@ impl DbConnType { //Sqlite } else { #[cfg(sqlite)] - return Ok(DbConnType::Sqlite); + return Ok(DbConnType::sqlite); #[cfg(not(sqlite))] err!("`DATABASE_URL` looks like a SQLite URL, but 'sqlite' feature is not enabled") @@ -293,142 +230,181 @@ impl DbConnType { pub fn default_init_stmts(&self) -> String { match self { - #[cfg(mysql)] - Self::Mysql => String::new(), - #[cfg(postgresql)] - Self::Postgresql => String::new(), - #[cfg(sqlite)] - Self::Sqlite => "PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;".to_string(), + Self::sqlite => "PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;".to_string(), + Self::mysql => String::new(), + Self::postgresql => String::new(), } } } -impl DbConn { - pub async fn run(&self, f: F) -> R - where - F: FnOnce(&mut DbConnInner) -> R + Send, - R: Send + 'static, - { - let conn = Arc::clone(&self.conn); - let mut conn = conn.lock_owned().await; - let conn = conn.as_mut().expect("Internal invariant broken: self.conn is Some"); - - // Run blocking can't be used due to the 'static limitation, use block_in_place instead - tokio::task::block_in_place(move || f(conn)) - } -} - #[macro_export] macro_rules! db_run { + // Same for all dbs ( $conn:ident: $body:block ) => { - $conn.run(move |$conn| $body).await + db_run! { $conn: sqlite, mysql, postgresql $body } }; - ( $conn:ident: $( $($db:ident),+ $body:block )+ ) => { - $conn.run(move |$conn| { - match $conn { + ( @raw $conn:ident: $body:block ) => { + db_run! { @raw $conn: sqlite, mysql, postgresql $body } + }; + + // Different code for each db + ( $conn:ident: $( $($db:ident),+ $body:block )+ ) => {{ + #[allow(unused)] use diesel::prelude::*; + #[allow(unused)] use $crate::db::FromDb; + + let conn = $conn.conn.clone(); + let mut conn = conn.lock_owned().await; + match conn.as_mut().expect("internal invariant broken: self.connection is Some") { $($( #[cfg($db)] - pastey::paste!(&mut $crate::db::DbConnInner::[<$db:camel>](ref mut $conn)) => { - $body + $crate::db::DbConnInner::$db($conn) => { + paste::paste! { + #[allow(unused)] use $crate::db::[<__ $db _schema>]::{self as schema, *}; + #[allow(unused)] use [<__ $db _model>]::*; + } + + tokio::task::block_in_place(move || { $body }) // Run blocking can't be used due to the 'static limitation, use block_in_place instead }, - )+)+} - }).await - }; + )+)+ + } + }}; + + ( @raw $conn:ident: $( $($db:ident),+ $body:block )+ ) => {{ + #[allow(unused)] use diesel::prelude::*; + #[allow(unused)] use $crate::db::FromDb; + + let conn = $conn.conn.clone(); + let mut conn = conn.lock_owned().await; + match conn.as_mut().expect("internal invariant broken: self.connection is Some") { + $($( + #[cfg($db)] + $crate::db::DbConnInner::$db($conn) => { + paste::paste! { + #[allow(unused)] use $crate::db::[<__ $db _schema>]::{self as schema, *}; + // @ RAW: #[allow(unused)] use [<__ $db _model>]::*; + } + + tokio::task::block_in_place(move || { $body }) // Run blocking can't be used due to the 'static limitation, use block_in_place instead + }, + )+)+ + } + }}; } -// Write all ToSql and FromSql given a serializable/deserializable type. -#[macro_export] -macro_rules! impl_FromToSqlText { - ($name:ty) => { - #[cfg(mysql)] - impl ToSql for $name { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::mysql::Mysql>) -> diesel::serialize::Result { - serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into) - } - } +pub trait FromDb { + type Output; + #[allow(clippy::wrong_self_convention)] + fn from_db(self) -> Self::Output; +} - #[cfg(postgresql)] - impl ToSql for $name { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::pg::Pg>) -> diesel::serialize::Result { - serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into) - } +impl FromDb for Vec { + type Output = Vec; + #[inline(always)] + fn from_db(self) -> Self::Output { + self.into_iter().map(FromDb::from_db).collect() + } +} + +impl FromDb for Option { + type Output = Option; + #[inline(always)] + fn from_db(self) -> Self::Output { + self.map(FromDb::from_db) + } +} + +// For each struct eg. Cipher, we create a CipherDb inside a module named __$db_model (where $db is sqlite, mysql or postgresql), +// to implement the Diesel traits. We also provide methods to convert between them and the basic structs. Later, that module will be auto imported when using db_run! +#[macro_export] +macro_rules! db_object { + ( $( + $( #[$attr:meta] )* + pub struct $name:ident { + $( $( #[$field_attr:meta] )* $vis:vis $field:ident : $typ:ty ),+ + $(,)? } + )+ ) => { + // Create the normal struct, without attributes + $( pub struct $name { $( /*$( #[$field_attr] )**/ $vis $field : $typ, )+ } )+ #[cfg(sqlite)] - impl ToSql for $name { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::sqlite::Sqlite>) -> diesel::serialize::Result { - serde_json::to_string(self).map_err(Into::into).map(|str| { - out.set_value(str); - diesel::serialize::IsNull::No - }) - } - } + pub mod __sqlite_model { $( db_object! { @db sqlite | $( #[$attr] )* | $name | $( $( #[$field_attr] )* $field : $typ ),+ } )+ } + #[cfg(mysql)] + pub mod __mysql_model { $( db_object! { @db mysql | $( #[$attr] )* | $name | $( $( #[$field_attr] )* $field : $typ ),+ } )+ } + #[cfg(postgresql)] + pub mod __postgresql_model { $( db_object! { @db postgresql | $( #[$attr] )* | $name | $( $( #[$field_attr] )* $field : $typ ),+ } )+ } + }; - impl FromSql for $name - where - String: FromSql, - { - fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result { - >::from_sql(bytes) - .and_then(|str| serde_json::from_str(&str).map_err(Into::into)) + ( @db $db:ident | $( #[$attr:meta] )* | $name:ident | $( $( #[$field_attr:meta] )* $vis:vis $field:ident : $typ:ty),+) => { + paste::paste! { + #[allow(unused)] use super::*; + #[allow(unused)] use diesel::prelude::*; + #[allow(unused)] use $crate::db::[<__ $db _schema>]::*; + + $( #[$attr] )* + pub struct [<$name Db>] { $( + $( #[$field_attr] )* $vis $field : $typ, + )+ } + + impl [<$name Db>] { + #[allow(clippy::wrong_self_convention)] + #[inline(always)] pub fn to_db(x: &super::$name) -> Self { Self { $( $field: x.$field.clone(), )+ } } + } + + impl $crate::db::FromDb for [<$name Db>] { + type Output = super::$name; + #[allow(clippy::wrong_self_convention)] + #[inline(always)] fn from_db(self) -> Self::Output { super::$name { $( $field: self.$field, )+ } } } } }; } -pub mod schema; - // Reexport the models, needs to be after the macros are defined so it can access them pub mod models; /// Creates a back-up of the sqlite database /// MySQL/MariaDB and PostgreSQL are not supported. -#[cfg(sqlite)] -pub fn backup_sqlite() -> Result { - use diesel::Connection; - - let db_url = CONFIG.database_url(); - if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::Sqlite).unwrap_or(false) { - // Since we do not allow any schema for sqlite database_url's like `file:` or `sqlite:` to be set, we can assume here it isn't - // This way we can set a readonly flag on the opening mode without issues. - let mut conn = diesel::sqlite::SqliteConnection::establish(&format!("sqlite://{db_url}?mode=ro"))?; - - let db_path = std::path::Path::new(&db_url).parent().unwrap(); - let backup_file = db_path - .join(format!("db_{}.sqlite3", chrono::Utc::now().format("%Y%m%d_%H%M%S"))) - .to_string_lossy() - .into_owned(); - - diesel::sql_query("VACUUM INTO ?") - .bind::(&backup_file) - .execute(&mut conn) - .map(|_| ()) - .map_res("VACUUM INTO failed")?; - - Ok(backup_file) - } else { - err_silent!("The database type is not SQLite. Backups only works for SQLite databases") +pub async fn backup_database(conn: &mut DbConn) -> Result { + db_run! {@raw conn: + postgresql, mysql { + let _ = conn; + err!("PostgreSQL and MySQL/MariaDB do not support this backup feature"); + } + sqlite { + backup_sqlite_database(conn) + } } } -#[cfg(not(sqlite))] -pub fn backup_sqlite() -> Result { - err_silent!("The database type is not SQLite. Backups only works for SQLite databases") +#[cfg(sqlite)] +pub fn backup_sqlite_database(conn: &mut diesel::sqlite::SqliteConnection) -> Result { + use diesel::RunQueryDsl; + let db_url = CONFIG.database_url(); + let db_path = std::path::Path::new(&db_url).parent().unwrap(); + let backup_file = db_path + .join(format!("db_{}.sqlite3", chrono::Utc::now().format("%Y%m%d_%H%M%S"))) + .to_string_lossy() + .into_owned(); + diesel::sql_query(format!("VACUUM INTO '{backup_file}'")).execute(conn)?; + Ok(backup_file) } /// Get the SQL Server version -pub async fn get_sql_server_version(conn: &DbConn) -> String { - db_run! { conn: - postgresql,mysql { - diesel::select(diesel::dsl::sql::("version();")) - .get_result::(conn) - .unwrap_or_else(|_| "Unknown".to_string()) +pub async fn get_sql_server_version(conn: &mut DbConn) -> String { + db_run! {@raw conn: + postgresql, mysql { + define_sql_function!{ + fn version() -> diesel::sql_types::Text; + } + diesel::select(version()).get_result::(conn).unwrap_or_else(|_| "Unknown".to_string()) } sqlite { - diesel::select(diesel::dsl::sql::("sqlite_version();")) - .get_result::(conn) - .unwrap_or_else(|_| "Unknown".to_string()) + define_sql_function!{ + fn sqlite_version() -> diesel::sql_types::Text; + } + diesel::select(sqlite_version()).get_result::(conn).unwrap_or_else(|_| "Unknown".to_string()) } } } @@ -456,14 +432,16 @@ impl<'r> FromRequest<'r> for DbConn { // https://docs.rs/diesel_migrations/*/diesel_migrations/macro.embed_migrations.html #[cfg(sqlite)] mod sqlite_migrations { - use diesel::{Connection, RunQueryDsl}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/sqlite"); - pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { + pub fn run_migrations() -> Result<(), super::Error> { + use diesel::{Connection, RunQueryDsl}; + let url = crate::CONFIG.database_url(); + // Establish a connection to the sqlite database (this will create a new one, if it does // not exist, and exit if there is an error). - let mut connection = diesel::sqlite::SqliteConnection::establish(db_url)?; + let mut connection = diesel::sqlite::SqliteConnection::establish(&url)?; // Run the migrations after successfully establishing a connection // Disable Foreign Key Checks during migration @@ -484,15 +462,15 @@ mod sqlite_migrations { #[cfg(mysql)] mod mysql_migrations { - use diesel::{Connection, RunQueryDsl}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/mysql"); - pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { + pub fn run_migrations() -> Result<(), super::Error> { + use diesel::{Connection, RunQueryDsl}; // Make sure the database is up to date (create if it doesn't exist, or run the migrations) - let mut connection = diesel::mysql::MysqlConnection::establish(db_url)?; - + let mut connection = diesel::mysql::MysqlConnection::establish(&crate::CONFIG.database_url())?; // Disable Foreign Key Checks during migration + // Scoped to a connection/session. diesel::sql_query("SET FOREIGN_KEY_CHECKS = 0") .execute(&mut connection) @@ -505,14 +483,13 @@ mod mysql_migrations { #[cfg(postgresql)] mod postgresql_migrations { - use diesel::Connection; use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/postgresql"); - pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { + pub fn run_migrations() -> Result<(), super::Error> { + use diesel::Connection; // Make sure the database is up to date (create if it doesn't exist, or run the migrations) - let mut connection = diesel::pg::PgConnection::establish(db_url)?; - + let mut connection = diesel::pg::PgConnection::establish(&crate::CONFIG.database_url())?; connection.run_pending_migrations(MIGRATIONS).expect("Error running migrations"); Ok(()) } diff --git a/src/db/models/archive.rs b/src/db/models/archive.rs deleted file mode 100644 index f576e7ed..00000000 --- a/src/db/models/archive.rs +++ /dev/null @@ -1,91 +0,0 @@ -use chrono::NaiveDateTime; -use diesel::prelude::*; - -use super::{CipherId, User, UserId}; -use crate::api::EmptyResult; -use crate::db::schema::archives; -use crate::db::DbConn; -use crate::error::MapResult; - -#[derive(Identifiable, Queryable, Insertable)] -#[diesel(table_name = archives)] -#[diesel(primary_key(user_uuid, cipher_uuid))] -pub struct Archive { - pub user_uuid: UserId, - pub cipher_uuid: CipherId, - pub archived_at: NaiveDateTime, -} - -impl Archive { - // Returns the date the specified cipher was archived - pub async fn get_archived_at(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> Option { - db_run! { conn: { - archives::table - .filter(archives::cipher_uuid.eq(cipher_uuid)) - .filter(archives::user_uuid.eq(user_uuid)) - .select(archives::archived_at) - .first::(conn).ok() - }} - } - - // Saves (inserts or updates) an archive record with the provided timestamp - pub async fn save( - user_uuid: &UserId, - cipher_uuid: &CipherId, - archived_at: NaiveDateTime, - conn: &DbConn, - ) -> EmptyResult { - User::update_uuid_revision(user_uuid, conn).await; - db_run! { conn: - sqlite, mysql { - diesel::replace_into(archives::table) - .values(( - archives::user_uuid.eq(user_uuid), - archives::cipher_uuid.eq(cipher_uuid), - archives::archived_at.eq(archived_at), - )) - .execute(conn) - .map_res("Error saving archive") - } - postgresql { - diesel::insert_into(archives::table) - .values(( - archives::user_uuid.eq(user_uuid), - archives::cipher_uuid.eq(cipher_uuid), - archives::archived_at.eq(archived_at), - )) - .on_conflict((archives::user_uuid, archives::cipher_uuid)) - .do_update() - .set(archives::archived_at.eq(archived_at)) - .execute(conn) - .map_res("Error saving archive") - } - } - } - - // Deletes an archive record for a specific cipher - pub async fn delete_by_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { - User::update_uuid_revision(user_uuid, conn).await; - db_run! { conn: { - diesel::delete( - archives::table - .filter(archives::user_uuid.eq(user_uuid)) - .filter(archives::cipher_uuid.eq(cipher_uuid)) - ) - .execute(conn) - .map_res("Error deleting archive") - }} - } - - /// Return a vec with (cipher_uuid, archived_at) - /// This is used during a full sync so we only need one query for all archive matches - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, NaiveDateTime)> { - db_run! { conn: { - archives::table - .filter(archives::user_uuid.eq(user_uuid)) - .select((archives::cipher_uuid, archives::archived_at)) - .load::<(CipherId, NaiveDateTime)>(conn) - .unwrap_or_default() - }} - } -} diff --git a/src/db/models/attachment.rs b/src/db/models/attachment.rs index 7611b927..65855cc0 100644 --- a/src/db/models/attachment.rs +++ b/src/db/models/attachment.rs @@ -1,35 +1,27 @@ +use std::io::ErrorKind; + use bigdecimal::{BigDecimal, ToPrimitive}; -use derive_more::{AsRef, Deref, Display}; -use diesel::prelude::*; use serde_json::Value; -use std::time::Duration; -use super::{CipherId, OrganizationId, UserId}; -use crate::db::schema::{attachments, ciphers}; -use crate::{config::PathType, CONFIG}; -use macros::IdFromParam; +use crate::CONFIG; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = attachments)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(id))] -pub struct Attachment { - pub id: AttachmentId, - pub cipher_uuid: CipherId, - pub file_name: String, // encrypted - pub file_size: i64, - pub akey: Option, +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = attachments)] + #[diesel(treat_none_as_null = true)] + #[diesel(primary_key(id))] + pub struct Attachment { + pub id: String, + pub cipher_uuid: String, + pub file_name: String, // encrypted + pub file_size: i64, + pub akey: Option, + } } /// Local methods impl Attachment { - pub const fn new( - id: AttachmentId, - cipher_uuid: CipherId, - file_name: String, - file_size: i64, - akey: Option, - ) -> Self { + pub const fn new(id: String, cipher_uuid: String, file_name: String, file_size: i64, akey: Option) -> Self { Self { id, cipher_uuid, @@ -40,30 +32,24 @@ impl Attachment { } pub fn get_file_path(&self) -> String { - format!("{}/{}", self.cipher_uuid, self.id) + format!("{}/{}/{}", CONFIG.attachments_folder(), self.cipher_uuid, self.id) } - pub async fn get_url(&self, host: &str) -> Result { - let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?; - - if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) { - let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone())); - Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id)) - } else { - Ok(operator.presign_read(&self.get_file_path(), Duration::from_mins(5)).await?.uri().to_string()) - } + pub fn get_url(&self, host: &str) -> String { + let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone())); + format!("{}/attachments/{}/{}?token={}", host, self.cipher_uuid, self.id, token) } - pub async fn to_json(&self, host: &str) -> Result { - Ok(json!({ + pub fn to_json(&self, host: &str) -> Value { + json!({ "id": self.id, - "url": self.get_url(host).await?, + "url": self.get_url(host), "fileName": self.file_name, "size": self.file_size.to_string(), "sizeName": crate::util::get_display_size(self.file_size), "key": self.akey, "object": "attachment" - })) + }) } } @@ -75,11 +61,11 @@ use crate::error::MapResult; /// Database methods impl Attachment { - pub async fn save(&self, conn: &DbConn) -> EmptyResult { + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { match diesel::replace_into(attachments::table) - .values(self) + .values(AttachmentDb::to_db(self)) .execute(conn) { Ok(_) => Ok(()), @@ -87,7 +73,7 @@ impl Attachment { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(attachments::table) .filter(attachments::id.eq(&self.id)) - .set(self) + .set(AttachmentDb::to_db(self)) .execute(conn) .map_res("Error saving attachment") } @@ -95,68 +81,70 @@ impl Attachment { }.map_res("Error saving attachment") } postgresql { + let value = AttachmentDb::to_db(self); diesel::insert_into(attachments::table) - .values(self) + .values(&value) .on_conflict(attachments::id) .do_update() - .set(self) + .set(&value) .execute(conn) .map_res("Error saving attachment") } } } - pub async fn delete(&self, conn: &DbConn) -> EmptyResult { + pub async fn delete(&self, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { - crate::util::retry(|| - diesel::delete(attachments::table.filter(attachments::id.eq(&self.id))) - .execute(conn), + let _: () = crate::util::retry( + || diesel::delete(attachments::table.filter(attachments::id.eq(&self.id))).execute(conn), 10, ) - .map(|_| ()) - .map_res("Error deleting attachment") - }}?; + .map_res("Error deleting attachment")?; - let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?; - let file_path = self.get_file_path(); + let file_path = &self.get_file_path(); - if let Err(e) = operator.delete(&file_path).await { - if e.kind() == opendal::ErrorKind::NotFound { - debug!("File '{file_path}' already deleted."); - } else { - return Err(e.into()); + match std::fs::remove_file(file_path) { + // Ignore "file not found" errors. This can happen when the + // upstream caller has already cleaned up the file as part of + // its own error handling. + Err(e) if e.kind() == ErrorKind::NotFound => { + debug!("File '{}' already deleted.", file_path); + Ok(()) + } + Err(e) => Err(e.into()), + _ => Ok(()), } - } - - Ok(()) + }} } - pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_cipher(cipher_uuid: &str, conn: &mut DbConn) -> EmptyResult { for attachment in Attachment::find_by_cipher(cipher_uuid, conn).await { attachment.delete(conn).await?; } Ok(()) } - pub async fn find_by_id(id: &AttachmentId, conn: &DbConn) -> Option { + pub async fn find_by_id(id: &str, conn: &mut DbConn) -> Option { db_run! { conn: { attachments::table .filter(attachments::id.eq(id.to_lowercase())) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> Vec { + pub async fn find_by_cipher(cipher_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { attachments::table .filter(attachments::cipher_uuid.eq(cipher_uuid)) - .load::(conn) + .load::(conn) .expect("Error loading attachments") + .from_db() }} } - pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 { + pub async fn size_by_user(user_uuid: &str, conn: &mut DbConn) -> i64 { db_run! { conn: { let result: Option = attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) @@ -173,7 +161,7 @@ impl Attachment { }} } - pub async fn count_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 { + pub async fn count_by_user(user_uuid: &str, conn: &mut DbConn) -> i64 { db_run! { conn: { attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) @@ -184,7 +172,7 @@ impl Attachment { }} } - pub async fn size_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { + pub async fn size_by_org(org_uuid: &str, conn: &mut DbConn) -> i64 { db_run! { conn: { let result: Option = attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) @@ -201,7 +189,7 @@ impl Attachment { }} } - pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { + pub async fn count_by_org(org_uuid: &str, conn: &mut DbConn) -> i64 { db_run! { conn: { attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) @@ -215,36 +203,16 @@ impl Attachment { // This will return all attachments linked to the user or org // There is no filtering done here if the user actually has access! // It is used to speed up the sync process, and the matching is done in a different part. - pub async fn find_all_by_user_and_orgs( - user_uuid: &UserId, - org_uuids: &Vec, - conn: &DbConn, - ) -> Vec { + pub async fn find_all_by_user_and_orgs(user_uuid: &str, org_uuids: &Vec, conn: &mut DbConn) -> Vec { db_run! { conn: { attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) .filter(ciphers::user_uuid.eq(user_uuid)) .or_filter(ciphers::organization_uuid.eq_any(org_uuids)) .select(attachments::all_columns) - .load::(conn) + .load::(conn) .expect("Error loading attachments") + .from_db() }} } } - -#[derive( - Clone, - Debug, - AsRef, - Deref, - DieselNewType, - Display, - FromForm, - Hash, - PartialEq, - Eq, - Serialize, - Deserialize, - IdFromParam, -)] -pub struct AttachmentId(pub String); diff --git a/src/db/models/auth_request.rs b/src/db/models/auth_request.rs index 93c6e445..9388c71a 100644 --- a/src/db/models/auth_request.rs +++ b/src/db/models/auth_request.rs @@ -1,44 +1,40 @@ -use super::{DeviceId, OrganizationId, UserId}; -use crate::db::schema::auth_requests; -use crate::{crypto::ct_eq, util::format_date}; +use crate::crypto::ct_eq; use chrono::{NaiveDateTime, Utc}; -use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; -use macros::UuidFromParam; -use serde_json::Value; -#[derive(Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)] -#[diesel(table_name = auth_requests)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid))] -pub struct AuthRequest { - pub uuid: AuthRequestId, - pub user_uuid: UserId, - pub organization_uuid: Option, +db_object! { + #[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)] + #[diesel(table_name = auth_requests)] + #[diesel(treat_none_as_null = true)] + #[diesel(primary_key(uuid))] + pub struct AuthRequest { + pub uuid: String, + pub user_uuid: String, + pub organization_uuid: Option, - pub request_device_identifier: DeviceId, - pub device_type: i32, // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs + pub request_device_identifier: String, + pub device_type: i32, // https://github.com/bitwarden/server/blob/master/src/Core/Enums/DeviceType.cs - pub request_ip: String, - pub response_device_id: Option, + pub request_ip: String, + pub response_device_id: Option, - pub access_code: String, - pub public_key: String, + pub access_code: String, + pub public_key: String, - pub enc_key: Option, + pub enc_key: Option, - pub master_password_hash: Option, - pub approved: Option, - pub creation_date: NaiveDateTime, - pub response_date: Option, + pub master_password_hash: Option, + pub approved: Option, + pub creation_date: NaiveDateTime, + pub response_date: Option, - pub authentication_date: Option, + pub authentication_date: Option, + } } impl AuthRequest { pub fn new( - user_uuid: UserId, - request_device_identifier: DeviceId, + user_uuid: String, + request_device_identifier: String, device_type: i32, request_ip: String, access_code: String, @@ -47,7 +43,7 @@ impl AuthRequest { let now = Utc::now().naive_utc(); Self { - uuid: AuthRequestId(crate::util::get_uuid()), + uuid: crate::util::get_uuid(), user_uuid, organization_uuid: None, @@ -65,13 +61,6 @@ impl AuthRequest { authentication_date: None, } } - - pub fn to_json_for_pending_device(&self) -> Value { - json!({ - "id": self.uuid, - "creationDate": format_date(&self.creation_date), - }) - } } use crate::db::DbConn; @@ -80,11 +69,11 @@ use crate::api::EmptyResult; use crate::error::MapResult; impl AuthRequest { - pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { + pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { match diesel::replace_into(auth_requests::table) - .values(&*self) + .values(AuthRequestDb::to_db(self)) .execute(conn) { Ok(_) => Ok(()), @@ -92,7 +81,7 @@ impl AuthRequest { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(auth_requests::table) .filter(auth_requests::uuid.eq(&self.uuid)) - .set(&*self) + .set(AuthRequestDb::to_db(self)) .execute(conn) .map_res("Error auth_request") } @@ -100,71 +89,45 @@ impl AuthRequest { }.map_res("Error auth_request") } postgresql { + let value = AuthRequestDb::to_db(self); diesel::insert_into(auth_requests::table) - .values(&*self) + .values(&value) .on_conflict(auth_requests::uuid) .do_update() - .set(&*self) + .set(&value) .execute(conn) .map_res("Error saving auth_request") } } } - pub async fn find_by_uuid(uuid: &AuthRequestId, conn: &DbConn) -> Option { - db_run! { conn: { + pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option { + db_run! {conn: { auth_requests::table .filter(auth_requests::uuid.eq(uuid)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_by_uuid_and_user(uuid: &AuthRequestId, user_uuid: &UserId, conn: &DbConn) -> Option { - db_run! { conn: { - auth_requests::table - .filter(auth_requests::uuid.eq(uuid)) - .filter(auth_requests::user_uuid.eq(user_uuid)) - .first::(conn) - .ok() - }} - } - - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - db_run! { conn: { + pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { + db_run! {conn: { auth_requests::table .filter(auth_requests::user_uuid.eq(user_uuid)) - .load::(conn) - .expect("Error loading auth_requests") + .load::(conn).expect("Error loading auth_requests").from_db() }} } - pub async fn find_by_user_and_requested_device( - user_uuid: &UserId, - device_uuid: &DeviceId, - conn: &DbConn, - ) -> Option { - db_run! { conn: { - auth_requests::table - .filter(auth_requests::user_uuid.eq(user_uuid)) - .filter(auth_requests::request_device_identifier.eq(device_uuid)) - .filter(auth_requests::approved.is_null()) - .order_by(auth_requests::creation_date.desc()) - .first::(conn) - .ok() - }} - } - - pub async fn find_created_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec { - db_run! { conn: { + pub async fn find_created_before(dt: &NaiveDateTime, conn: &mut DbConn) -> Vec { + db_run! {conn: { auth_requests::table .filter(auth_requests::creation_date.lt(dt)) - .load::(conn) - .expect("Error loading auth_requests") + .load::(conn).expect("Error loading auth_requests").from_db() }} } - pub async fn delete(&self, conn: &DbConn) -> EmptyResult { + pub async fn delete(&self, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(auth_requests::table.filter(auth_requests::uuid.eq(&self.uuid))) .execute(conn) @@ -176,30 +139,10 @@ impl AuthRequest { ct_eq(&self.access_code, access_code) } - 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::try_minutes(15).unwrap(); + pub async fn purge_expired_auth_requests(conn: &mut DbConn) { + let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(5).unwrap(); //after 5 minutes, clients reject the request for auth_request in Self::find_created_before(&expiry_time, conn).await { auth_request.delete(conn).await.ok(); } } } - -#[derive( - Clone, - Debug, - AsRef, - Deref, - DieselNewType, - Display, - From, - FromForm, - Hash, - PartialEq, - Eq, - Serialize, - Deserialize, - UuidFromParam, -)] -pub struct AuthRequestId(String); diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index db906179..fb2b5021 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -1,59 +1,54 @@ -use crate::db::schema::{ - ciphers, ciphers_collections, collections, collections_groups, folders, folders_ciphers, groups, groups_users, - users_collections, users_organizations, -}; use crate::util::LowerCase; use crate::CONFIG; -use chrono::{NaiveDateTime, TimeDelta, Utc}; -use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; +use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; use serde_json::Value; use super::{ - Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, - MembershipStatus, MembershipType, OrganizationId, User, UserId, + Attachment, CollectionCipher, Favorite, FolderCipher, Group, User, UserOrgStatus, UserOrgType, UserOrganization, }; + use crate::api::core::{CipherData, CipherSyncData, CipherSyncType}; -use macros::UuidFromParam; use std::borrow::Cow; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = ciphers)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid))] -pub struct Cipher { - pub uuid: CipherId, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = ciphers)] + #[diesel(treat_none_as_null = true)] + #[diesel(primary_key(uuid))] + pub struct Cipher { + pub uuid: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, - pub user_uuid: Option, - pub organization_uuid: Option, + pub user_uuid: Option, + pub organization_uuid: Option, - pub key: Option, + pub key: Option, - /* - Login = 1, - SecureNote = 2, - Card = 3, - Identity = 4, - SshKey = 5 - */ - pub atype: i32, - pub name: String, - pub notes: Option, - pub fields: Option, + /* + Login = 1, + SecureNote = 2, + Card = 3, + Identity = 4 + */ + pub atype: i32, + pub name: String, + pub notes: Option, + pub fields: Option, - pub data: String, + pub data: String, - pub password_history: Option, - pub deleted_at: Option, - pub reprompt: Option, + pub password_history: Option, + pub deleted_at: Option, + pub reprompt: Option, + } } +#[allow(dead_code)] pub enum RepromptType { None = 0, - Password = 1, + Password = 1, // not currently used in server } /// Local methods @@ -62,7 +57,7 @@ impl Cipher { let now = Utc::now().naive_utc(); Self { - uuid: CipherId(crate::util::get_uuid()), + uuid: crate::util::get_uuid(), created_at: now, updated_at: now, @@ -88,7 +83,7 @@ impl Cipher { let mut validation_errors = serde_json::Map::new(); let max_note_size = CONFIG._max_note_size(); let max_note_size_msg = - format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."); + format!("The field Notes exceeds the maximum encrypted value length of {} characters.", &max_note_size); for (index, cipher) in cipher_data.iter().enumerate() { // Validate the note size and if it is exceeded return a warning if let Some(note) = &cipher.notes { @@ -140,47 +135,37 @@ impl Cipher { pub async fn to_json( &self, host: &str, - user_uuid: &UserId, + user_uuid: &str, cipher_sync_data: Option<&CipherSyncData>, sync_type: CipherSyncType, - conn: &DbConn, - ) -> Result { - use crate::util::{format_date, validate_and_format_date}; + conn: &mut DbConn, + ) -> Value { + use crate::util::format_date; 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) { - if !attachments.is_empty() { - let mut attachments_json_vec = vec![]; - for attachment in attachments { - attachments_json_vec.push(attachment.to_json(host).await?); - } - attachments_json = Value::Array(attachments_json_vec); - } + attachments_json = attachments.iter().map(|c| c.to_json(host)).collect(); } } else { let attachments = Attachment::find_by_cipher(&self.uuid, conn).await; if !attachments.is_empty() { - let mut attachments_json_vec = vec![]; - for attachment in attachments { - attachments_json_vec.push(attachment.to_json(host).await?); - } - attachments_json = Value::Array(attachments_json_vec); + attachments_json = attachments.iter().map(|c| c.to_json(host)).collect() } } // We don't need these values at all for Organizational syncs // Skip any other database calls if this is the case and just return false. - let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User { + let (read_only, hide_passwords) = if sync_type == CipherSyncType::User { match self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await { - Some((ro, hp, mn)) => (ro, hp, mn), + Some((ro, hp)) => (ro, hp), None => { error!("Cipher ownership assertion failure"); - (true, true, false) + (true, true) } } } else { - (false, false, false) + (false, false) }; let fields_json: Vec<_> = self @@ -231,13 +216,11 @@ impl Cipher { Some(p) if p.is_string() => Some(d.data), _ => None, }) - .map(|mut d| match d.get("lastUsedDate").and_then(|l| l.as_str()) { - Some(l) => { - d["lastUsedDate"] = json!(validate_and_format_date(l)); - d - } + .map(|d| match d.get("lastUsedDate").and_then(|l| l.as_str()) { + Some(l) if DateTime::parse_from_rfc3339(l).is_ok() => d, _ => { - d["lastUsedDate"] = json!("1970-01-01T00:00:00.000000Z"); + let mut d = d; + d["lastUsedDate"] = json!("1970-01-01T00:00:00.000Z"); d } }) @@ -256,28 +239,12 @@ impl Cipher { // 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() { - if !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)); + if type_data_json["uris"].is_array() { + let uri = type_data_json["uris"][0]["uri"].clone(); + type_data_json["uri"] = uri; + } else { + // Upstream always has an Uri key/value + type_data_json["uri"] = Value::Null; } } @@ -292,19 +259,6 @@ impl Cipher { } } - // 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(|v| v.is_empty()) - || type_data_json["privateKey"].as_str().is_none_or(|v| v.is_empty()) - || type_data_json["publicKey"].as_str().is_none_or(|v| v.is_empty())) - { - warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid); - type_data_json = Value::Null; - } - // Clone the type_data and add some default value. let mut data_json = type_data_json.clone(); @@ -322,7 +276,7 @@ impl Cipher { Cow::from(Vec::with_capacity(0)) } } else { - Cow::from(self.get_admin_collections(user_uuid.clone(), conn).await) + Cow::from(self.get_admin_collections(user_uuid.to_string(), conn).await) }; // There are three types of cipher response models in upstream @@ -331,7 +285,7 @@ impl Cipher { // supports the "cipherDetails" type, though it seems like the // Bitwarden clients will ignore extra fields. // - // Ref: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Vault/Models/Response/CipherResponseModel.cs#L14 + // Ref: https://github.com/bitwarden/server/blob/master/src/Core/Models/Api/Response/CipherResponseModel.cs let mut json_object = json!({ "object": "cipherDetails", "id": self.uuid, @@ -339,7 +293,7 @@ impl Cipher { "creationDate": format_date(&self.created_at), "revisionDate": format_date(&self.updated_at), "deletedDate": self.deleted_at.map_or(Value::Null, |d| Value::String(format_date(&d))), - "reprompt": self.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32).unwrap_or(RepromptType::None as i32), + "reprompt": self.reprompt.unwrap_or(RepromptType::None as i32), "organizationId": self.organization_uuid, "key": self.key, "attachments": attachments_json, @@ -363,7 +317,6 @@ impl Cipher { "secureNote": null, "card": null, "identity": null, - "sshKey": null, }); // These values are only needed for user/default syncs @@ -371,7 +324,7 @@ impl Cipher { // Skip adding these fields in that case if sync_type == CipherSyncType::User { json_object["folderId"] = json!(if let Some(cipher_sync_data) = cipher_sync_data { - cipher_sync_data.cipher_folders.get(&self.uuid).cloned() + cipher_sync_data.cipher_folders.get(&self.uuid).map(|c| c.to_string()) } else { self.get_folder_uuid(user_uuid, conn).await }); @@ -380,21 +333,11 @@ impl Cipher { } else { self.is_favorite(user_uuid, conn).await }); - json_object["archivedDate"] = json!(if let Some(cipher_sync_data) = cipher_sync_data { - cipher_sync_data.cipher_archives.get(&self.uuid).map_or(Value::Null, |d| Value::String(format_date(d))) - } else { - self.get_archived_at(user_uuid, conn).await.map_or(Value::Null, |d| Value::String(format_date(&d))) - }); // These values are true by default, but can be false if the // cipher belongs to a collection or group where the org owner has enabled // the "Read Only" or "Hide Passwords" restrictions for the user. json_object["edit"] = json!(!read_only); json_object["viewPassword"] = json!(!hide_passwords); - // The new key used by clients since v2025.6.0 - json_object["permissions"] = json!({ - "delete": !read_only, - "restore": !read_only, - }); } let key = match self.atype { @@ -402,15 +345,14 @@ impl Cipher { 2 => "secureNote", 3 => "card", 4 => "identity", - 5 => "sshKey", - _ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)), + _ => panic!("Wrong type"), }; json_object[key] = type_data_json; - Ok(json_object) + json_object } - pub async fn update_users_revision(&self, conn: &DbConn) -> Vec { + pub async fn update_users_revision(&self, conn: &mut DbConn) -> Vec { let mut user_uuids = Vec::new(); match self.user_uuid { Some(ref user_uuid) => { @@ -421,16 +363,17 @@ impl Cipher { // Belongs to Organization, need to update affected users if let Some(ref org_uuid) = self.organization_uuid { // users having access to the collection - let mut collection_users = Membership::find_by_cipher_and_org(&self.uuid, org_uuid, conn).await; + let mut collection_users = + UserOrganization::find_by_cipher_and_org(&self.uuid, org_uuid, conn).await; if CONFIG.org_groups_enabled() { // members of a group having access to the collection let group_users = - Membership::find_by_cipher_and_org_with_group(&self.uuid, org_uuid, conn).await; + UserOrganization::find_by_cipher_and_org_with_group(&self.uuid, org_uuid, conn).await; collection_users.extend(group_users); } - for member in collection_users { - User::update_uuid_revision(&member.user_uuid, conn).await; - user_uuids.push(member.user_uuid.clone()) + for user_org in collection_users { + User::update_uuid_revision(&user_org.user_uuid, conn).await; + user_uuids.push(user_org.user_uuid.clone()) } } } @@ -438,14 +381,14 @@ impl Cipher { user_uuids } - pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { + pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { self.update_users_revision(conn).await; self.updated_at = Utc::now().naive_utc(); db_run! { conn: sqlite, mysql { match diesel::replace_into(ciphers::table) - .values(&*self) + .values(CipherDb::to_db(self)) .execute(conn) { Ok(_) => Ok(()), @@ -453,7 +396,7 @@ impl Cipher { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(ciphers::table) .filter(ciphers::uuid.eq(&self.uuid)) - .set(&*self) + .set(CipherDb::to_db(self)) .execute(conn) .map_res("Error saving cipher") } @@ -461,18 +404,19 @@ impl Cipher { }.map_res("Error saving cipher") } postgresql { + let value = CipherDb::to_db(self); diesel::insert_into(ciphers::table) - .values(&*self) + .values(&value) .on_conflict(ciphers::uuid) .do_update() - .set(&*self) + .set(&value) .execute(conn) .map_res("Error saving cipher") } } } - pub async fn delete(&self, conn: &DbConn) -> EmptyResult { + pub async fn delete(&self, conn: &mut DbConn) -> EmptyResult { self.update_users_revision(conn).await; FolderCipher::delete_all_by_cipher(&self.uuid, conn).await?; @@ -487,7 +431,7 @@ impl Cipher { }} } - pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_organization(org_uuid: &str, conn: &mut DbConn) -> EmptyResult { // TODO: Optimize this by executing a DELETE directly on the database, instead of first fetching. for cipher in Self::find_by_org(org_uuid, conn).await { cipher.delete(conn).await?; @@ -495,7 +439,7 @@ impl Cipher { Ok(()) } - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult { for cipher in Self::find_owned_by_user(user_uuid, conn).await { cipher.delete(conn).await?; } @@ -503,7 +447,7 @@ impl Cipher { } /// Purge all ciphers that are old enough to be auto-deleted. - pub async fn purge_trash(conn: &DbConn) { + pub async fn purge_trash(conn: &mut DbConn) { 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(); @@ -513,59 +457,52 @@ impl Cipher { } } - pub async fn move_to_folder( - &self, - folder_uuid: Option, - user_uuid: &UserId, - conn: &DbConn, - ) -> EmptyResult { + pub async fn move_to_folder(&self, folder_uuid: Option, user_uuid: &str, conn: &mut DbConn) -> EmptyResult { User::update_uuid_revision(user_uuid, conn).await; match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) { // No changes (None, None) => Ok(()), - (Some(ref old_folder), Some(ref new_folder)) if old_folder == new_folder => Ok(()), + (Some(ref old), Some(ref new)) if old == new => Ok(()), // Add to folder - (None, Some(new_folder)) => FolderCipher::new(new_folder, self.uuid.clone()).save(conn).await, + (None, Some(new)) => FolderCipher::new(&new, &self.uuid).save(conn).await, // Remove from folder - (Some(old_folder), None) => { - match FolderCipher::find_by_folder_and_cipher(&old_folder, &self.uuid, conn).await { - Some(old_folder) => old_folder.delete(conn).await, - None => err!("Couldn't move from previous folder"), - } - } + (Some(old), None) => match FolderCipher::find_by_folder_and_cipher(&old, &self.uuid, conn).await { + Some(old) => old.delete(conn).await, + None => err!("Couldn't move from previous folder"), + }, // Move to another folder - (Some(old_folder), Some(new_folder)) => { - if let Some(old_folder) = FolderCipher::find_by_folder_and_cipher(&old_folder, &self.uuid, conn).await { - old_folder.delete(conn).await?; + (Some(old), Some(new)) => { + if let Some(old) = FolderCipher::find_by_folder_and_cipher(&old, &self.uuid, conn).await { + old.delete(conn).await?; } - FolderCipher::new(new_folder, self.uuid.clone()).save(conn).await + FolderCipher::new(&new, &self.uuid).save(conn).await } } } /// Returns whether this cipher is directly owned by the user. - pub fn is_owned_by_user(&self, user_uuid: &UserId) -> bool { + pub fn is_owned_by_user(&self, user_uuid: &str) -> bool { self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid } /// Returns whether this cipher is owned by an org in which the user has full access. async fn is_in_full_access_org( &self, - user_uuid: &UserId, + user_uuid: &str, cipher_sync_data: Option<&CipherSyncData>, - conn: &DbConn, + conn: &mut DbConn, ) -> bool { if let Some(ref org_uuid) = self.organization_uuid { if let Some(cipher_sync_data) = cipher_sync_data { - if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) { - return cached_member.has_full_access(); + if let Some(cached_user_org) = cipher_sync_data.user_organizations.get(org_uuid) { + return cached_user_org.has_full_access(); } - } else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await { - return member.has_full_access(); + } else if let Some(user_org) = UserOrganization::find_by_user_and_org(user_uuid, org_uuid, conn).await { + return user_org.has_full_access(); } } false @@ -574,9 +511,9 @@ impl Cipher { /// Returns whether this cipher is owned by an group in which the user has full access. async fn is_in_full_access_group( &self, - user_uuid: &UserId, + user_uuid: &str, cipher_sync_data: Option<&CipherSyncData>, - conn: &DbConn, + conn: &mut DbConn, ) -> bool { if !CONFIG.org_groups_enabled() { return false; @@ -594,14 +531,14 @@ impl Cipher { /// Returns the user's access restrictions to this cipher. A return value /// of None means that this cipher does not belong to the user, and is /// not in any collection the user has access to. Otherwise, the user has - /// access to this cipher, and Some(read_only, hide_passwords, manage) represents + /// access to this cipher, and Some(read_only, hide_passwords) represents /// the access restrictions. pub async fn get_access_restrictions( &self, - user_uuid: &UserId, + user_uuid: &str, cipher_sync_data: Option<&CipherSyncData>, - conn: &DbConn, - ) -> Option<(bool, bool, bool)> { + conn: &mut DbConn, + ) -> Option<(bool, bool)> { // Check whether this cipher is directly owned by the user, or is in // a collection that the user has full access to. If so, there are no // access restrictions. @@ -609,30 +546,29 @@ impl Cipher { || self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await || self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await { - return Some((false, false, true)); + return Some((false, false)); } let rows = if let Some(cipher_sync_data) = cipher_sync_data { - let mut rows: Vec<(bool, bool, bool)> = Vec::new(); + let mut rows: Vec<(bool, bool)> = Vec::new(); if let Some(collections) = cipher_sync_data.cipher_collections.get(&self.uuid) { for collection in collections { - // User permissions - if let Some(cu) = cipher_sync_data.user_collections.get(collection) { - rows.push((cu.read_only, cu.hide_passwords, cu.manage)); - // Group permissions - } else if let Some(cg) = cipher_sync_data.user_collections_groups.get(collection) { - rows.push((cg.read_only, cg.hide_passwords, cg.manage)); + //User permissions + if let Some(uc) = cipher_sync_data.user_collections.get(collection) { + rows.push((uc.read_only, uc.hide_passwords)); + } + + //Group permissions + if let Some(cg) = cipher_sync_data.user_collections_groups.get(collection) { + rows.push((cg.read_only, cg.hide_passwords)); } } } rows } else { - let user_permissions = self.get_user_collections_access_flags(user_uuid, conn).await; - if !user_permissions.is_empty() { - user_permissions - } else { - self.get_group_collections_access_flags(user_uuid, conn).await - } + let mut access_flags = self.get_user_collections_access_flags(user_uuid, conn).await; + access_flags.append(&mut self.get_group_collections_access_flags(user_uuid, conn).await); + access_flags }; if rows.is_empty() { @@ -641,9 +577,6 @@ impl Cipher { } // A cipher can be in multiple collections with inconsistent access flags. - // Also, user permission overrule group permissions - // and only user permissions are returned by the code above. - // // For example, a cipher could be in one collection where the user has // read-only access, but also in another collection where the user has // read/write access. For a flag to be in effect for a cipher, upstream @@ -652,44 +585,38 @@ impl Cipher { // and `hide_passwords` columns. This could ideally be done as part of the // query, but Diesel doesn't support a min() or bool_and() function on // booleans and this behavior isn't portable anyway. - // - // The only exception is for the `manage` flag, that needs a boolean OR! let mut read_only = true; let mut hide_passwords = true; - let mut manage = false; - for (ro, hp, mn) in rows.iter() { + for (ro, hp) in rows.iter() { read_only &= ro; hide_passwords &= hp; - manage |= mn; } - Some((read_only, hide_passwords, manage)) + Some((read_only, hide_passwords)) } - async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> { - db_run! { conn: { + async fn get_user_collections_access_flags(&self, user_uuid: &str, conn: &mut DbConn) -> Vec<(bool, bool)> { + db_run! {conn: { // Check whether this cipher is in any collections accessible to the // user. If so, retrieve the access flags for each collection. ciphers::table .filter(ciphers::uuid.eq(&self.uuid)) .inner_join(ciphers_collections::table.on( - ciphers::uuid.eq(ciphers_collections::cipher_uuid) - )) + ciphers::uuid.eq(ciphers_collections::cipher_uuid))) .inner_join(users_collections::table.on( ciphers_collections::collection_uuid.eq(users_collections::collection_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) + .and(users_collections::user_uuid.eq(user_uuid)))) + .select((users_collections::read_only, users_collections::hide_passwords)) + .load::<(bool, bool)>(conn) .expect("Error getting user access restrictions") }} } - async fn get_group_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> { + async fn get_group_collections_access_flags(&self, user_uuid: &str, conn: &mut DbConn) -> Vec<(bool, bool)> { if !CONFIG.org_groups_enabled() { return Vec::new(); } - db_run! { conn: { + db_run! {conn: { ciphers::table .filter(ciphers::uuid.eq(&self.uuid)) .inner_join(ciphers_collections::table.on( @@ -704,93 +631,67 @@ impl Cipher { .inner_join(users_organizations::table.on( users_organizations::uuid.eq(groups_users::users_organizations_uuid) )) - .inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) - )) .filter(users_organizations::user_uuid.eq(user_uuid)) - .select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage)) - .load::<(bool, bool, bool)>(conn) + .select((collections_groups::read_only, collections_groups::hide_passwords)) + .load::<(bool, bool)>(conn) .expect("Error getting group access restrictions") }} } - pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { + pub async fn is_write_accessible_to_user(&self, user_uuid: &str, conn: &mut DbConn) -> bool { match self.get_access_restrictions(user_uuid, None, conn).await { - Some((read_only, _hide_passwords, manage)) => !read_only || manage, + Some((read_only, _hide_passwords)) => !read_only, None => false, } } - // used for checking if collection can be edited (only if user has access to a collection they - // can write to and also passwords are not hidden to prevent privilege escalation) - pub async fn is_in_editable_collection_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { - match self.get_access_restrictions(user_uuid, None, conn).await { - Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage, - None => false, - } - } - - pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { + pub async fn is_accessible_to_user(&self, user_uuid: &str, conn: &mut DbConn) -> bool { self.get_access_restrictions(user_uuid, None, conn).await.is_some() } // Returns whether this cipher is a favorite of the specified user. - pub async fn is_favorite(&self, user_uuid: &UserId, conn: &DbConn) -> bool { + pub async fn is_favorite(&self, user_uuid: &str, conn: &mut DbConn) -> bool { Favorite::is_favorite(&self.uuid, user_uuid, conn).await } // Sets whether this cipher is a favorite of the specified user. - pub async fn set_favorite(&self, favorite: Option, user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + pub async fn set_favorite(&self, favorite: Option, user_uuid: &str, conn: &mut DbConn) -> EmptyResult { match favorite { None => Ok(()), // No change requested. Some(status) => Favorite::set_favorite(status, &self.uuid, user_uuid, conn).await, } } - pub async fn get_archived_at(&self, user_uuid: &UserId, conn: &DbConn) -> Option { - Archive::get_archived_at(&self.uuid, user_uuid, conn).await - } - - pub async fn set_archived_at(&self, archived_at: NaiveDateTime, user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - Archive::save(user_uuid, &self.uuid, archived_at, conn).await - } - - pub async fn unarchive(&self, user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - Archive::delete_by_cipher(user_uuid, &self.uuid, conn).await - } - - pub async fn get_folder_uuid(&self, user_uuid: &UserId, conn: &DbConn) -> Option { - db_run! { conn: { + pub async fn get_folder_uuid(&self, user_uuid: &str, conn: &mut DbConn) -> Option { + db_run! {conn: { folders_ciphers::table .inner_join(folders::table) .filter(folders::user_uuid.eq(&user_uuid)) .filter(folders_ciphers::cipher_uuid.eq(&self.uuid)) .select(folders_ciphers::folder_uuid) - .first::(conn) + .first::(conn) .ok() }} } - pub async fn find_by_uuid(uuid: &CipherId, conn: &DbConn) -> Option { - db_run! { conn: { + pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option { + db_run! {conn: { ciphers::table .filter(ciphers::uuid.eq(uuid)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_by_uuid_and_org( - cipher_uuid: &CipherId, - org_uuid: &OrganizationId, - conn: &DbConn, - ) -> Option { - db_run! { conn: { + pub async fn find_by_uuid_and_org(cipher_uuid: &str, org_uuid: &str, conn: &mut DbConn) -> Option { + db_run! {conn: { ciphers::table .filter(ciphers::uuid.eq(cipher_uuid)) .filter(ciphers::organization_uuid.eq(org_uuid)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } @@ -806,39 +707,34 @@ impl Cipher { // true, then the non-interesting ciphers will not be returned. As a // result, those ciphers will not appear in "My Vault" for the org // owner/admin, but they can still be accessed via the org vault view. - pub async fn find_by_user( - user_uuid: &UserId, - visible_only: bool, - cipher_uuids: &Vec, - conn: &DbConn, - ) -> Vec { + pub async fn find_by_user(user_uuid: &str, visible_only: bool, conn: &mut DbConn) -> Vec { if CONFIG.org_groups_enabled() { - db_run! { conn: { + db_run! {conn: { let mut query = ciphers::table .left_join(ciphers_collections::table.on( ciphers::uuid.eq(ciphers_collections::cipher_uuid) - )) + )) .left_join(users_organizations::table.on( - ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()) + 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)) - )) + .and(users_organizations::status.eq(UserOrgStatus::Confirmed as i32)) + )) .left_join(users_collections::table.on( - ciphers_collections::collection_uuid.eq(users_collections::collection_uuid) + ciphers_collections::collection_uuid.eq(users_collections::collection_uuid) // Ensure that users_collections::user_uuid is NULL for unconfirmed users. .and(users_organizations::user_uuid.eq(users_collections::user_uuid)) - )) + )) .left_join(groups_users::table.on( - groups_users::users_organizations_uuid.eq(users_organizations::uuid) - )) - .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) - // Ensure that group and membership belong to the same org - .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) - )) + groups_users::users_organizations_uuid.eq(users_organizations::uuid) + )) + .left_join(groups::table.on( + groups::uuid.eq(groups_users::groups_uuid) + )) .left_join(collections_groups::table.on( - collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid) - .and(collections_groups::groups_uuid.eq(groups::uuid)) - )) + collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and( + collections_groups::groups_uuid.eq(groups::uuid) + ) + )) .filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner .or_filter(users_organizations::access_all.eq(true)) // access_all in org .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection @@ -848,25 +744,17 @@ impl Cipher { if !visible_only { query = query.or_filter( - users_organizations::atype.le(MembershipType::Admin as i32) // Org admin/owner - ); - } - - // Only filter for one specific cipher - if !cipher_uuids.is_empty() { - query = query.filter( - ciphers::uuid.eq_any(cipher_uuids) - ); + users_organizations::atype.le(UserOrgType::Admin as i32) // Org admin/owner + ); } query .select(ciphers::all_columns) .distinct() - .load::(conn) - .expect("Error loading ciphers") + .load::(conn).expect("Error loading ciphers").from_db() }} } else { - db_run! { conn: { + db_run! {conn: { let mut query = ciphers::table .left_join(ciphers_collections::table.on( ciphers::uuid.eq(ciphers_collections::cipher_uuid) @@ -874,7 +762,7 @@ impl Cipher { .left_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)) + .and(users_organizations::status.eq(UserOrgStatus::Confirmed as i32)) )) .left_join(users_collections::table.on( ciphers_collections::collection_uuid.eq(users_collections::collection_uuid) @@ -886,60 +774,39 @@ impl Cipher { .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection .into_boxed(); - if !visible_only { - query = query.or_filter( - users_organizations::atype.le(MembershipType::Admin as i32) // Org admin/owner - ); - } - - // Only filter for one specific cipher - if !cipher_uuids.is_empty() { - query = query.filter( - ciphers::uuid.eq_any(cipher_uuids) - ); - } + if !visible_only { + query = query.or_filter( + users_organizations::atype.le(UserOrgType::Admin as i32) // Org admin/owner + ); + } query .select(ciphers::all_columns) .distinct() - .load::(conn) - .expect("Error loading ciphers") + .load::(conn).expect("Error loading ciphers").from_db() }} } } // Find all ciphers visible to the specified user. - pub async fn find_by_user_visible(user_uuid: &UserId, conn: &DbConn) -> Vec { - Self::find_by_user(user_uuid, true, &vec![], conn).await - } - - pub async fn find_by_user_and_ciphers( - user_uuid: &UserId, - cipher_uuids: &Vec, - conn: &DbConn, - ) -> Vec { - Self::find_by_user(user_uuid, true, cipher_uuids, conn).await - } - - pub async fn find_by_user_and_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> Option { - Self::find_by_user(user_uuid, true, &vec![cipher_uuid.clone()], conn).await.pop() + pub async fn find_by_user_visible(user_uuid: &str, conn: &mut DbConn) -> Vec { + Self::find_by_user(user_uuid, true, conn).await } // Find all ciphers directly owned by the specified user. - pub async fn find_owned_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - db_run! { conn: { + pub async fn find_owned_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { + db_run! {conn: { ciphers::table .filter( ciphers::user_uuid.eq(user_uuid) .and(ciphers::organization_uuid.is_null()) ) - .load::(conn) - .expect("Error loading ciphers") + .load::(conn).expect("Error loading ciphers").from_db() }} } - pub async fn count_owned_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 { - db_run! { conn: { + pub async fn count_owned_by_user(user_uuid: &str, conn: &mut DbConn) -> i64 { + db_run! {conn: { ciphers::table .filter(ciphers::user_uuid.eq(user_uuid)) .count() @@ -949,17 +816,16 @@ impl Cipher { }} } - pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - db_run! { conn: { + pub async fn find_by_org(org_uuid: &str, conn: &mut DbConn) -> Vec { + db_run! {conn: { ciphers::table .filter(ciphers::organization_uuid.eq(org_uuid)) - .load::(conn) - .expect("Error loading ciphers") + .load::(conn).expect("Error loading ciphers").from_db() }} } - pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { - db_run! { conn: { + pub async fn count_by_org(org_uuid: &str, conn: &mut DbConn) -> i64 { + db_run! {conn: { ciphers::table .filter(ciphers::organization_uuid.eq(org_uuid)) .count() @@ -969,29 +835,27 @@ impl Cipher { }} } - pub async fn find_by_folder(folder_uuid: &FolderId, conn: &DbConn) -> Vec { - db_run! { conn: { + pub async fn find_by_folder(folder_uuid: &str, conn: &mut DbConn) -> Vec { + db_run! {conn: { folders_ciphers::table.inner_join(ciphers::table) .filter(folders_ciphers::folder_uuid.eq(folder_uuid)) .select(ciphers::all_columns) - .load::(conn) - .expect("Error loading ciphers") + .load::(conn).expect("Error loading ciphers").from_db() }} } /// Find all ciphers that were deleted before the specified datetime. - pub async fn find_deleted_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec { - db_run! { conn: { + pub async fn find_deleted_before(dt: &NaiveDateTime, conn: &mut DbConn) -> Vec { + db_run! {conn: { ciphers::table .filter(ciphers::deleted_at.lt(dt)) - .load::(conn) - .expect("Error loading ciphers") + .load::(conn).expect("Error loading ciphers").from_db() }} } - pub async fn get_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec { + pub async fn get_collections(&self, user_id: String, conn: &mut DbConn) -> Vec { if CONFIG.org_groups_enabled() { - db_run! { conn: { + db_run! {conn: { ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(&self.uuid)) .inner_join(collections::table.on( @@ -999,35 +863,32 @@ impl Cipher { )) .left_join(users_organizations::table.on( users_organizations::org_uuid.eq(collections::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone())) + .and(users_organizations::user_uuid.eq(user_id.clone())) )) .left_join(users_collections::table.on( users_collections::collection_uuid.eq(ciphers_collections::collection_uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone())) + .and(users_collections::user_uuid.eq(user_id.clone())) )) .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) - .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) - )) + .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid))) .left_join(collections_groups::table.on( collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid) .and(collections_groups::groups_uuid.eq(groups::uuid)) )) .filter(users_organizations::access_all.eq(true) // User has access all - .or(users_collections::user_uuid.eq(user_uuid) // User has access to collection + .or(users_collections::user_uuid.eq(user_id) // User has access to collection .and(users_collections::read_only.eq(false))) .or(groups::access_all.eq(true)) // Access via groups .or(collections_groups::collections_uuid.is_not_null() // Access via groups .and(collections_groups::read_only.eq(false))) ) .select(ciphers_collections::collection_uuid) - .load::(conn) - .unwrap_or_default() + .load::(conn).unwrap_or_default() }} } else { - db_run! { conn: { + db_run! {conn: { ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(&self.uuid)) .inner_join(collections::table.on( @@ -1035,26 +896,25 @@ impl Cipher { )) .inner_join(users_organizations::table.on( users_organizations::org_uuid.eq(collections::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone())) + .and(users_organizations::user_uuid.eq(user_id.clone())) )) .left_join(users_collections::table.on( users_collections::collection_uuid.eq(ciphers_collections::collection_uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone())) + .and(users_collections::user_uuid.eq(user_id.clone())) )) .filter(users_organizations::access_all.eq(true) // User has access all - .or(users_collections::user_uuid.eq(user_uuid) // User has access to collection + .or(users_collections::user_uuid.eq(user_id) // User has access to collection .and(users_collections::read_only.eq(false))) ) .select(ciphers_collections::collection_uuid) - .load::(conn) - .unwrap_or_default() + .load::(conn).unwrap_or_default() }} } } - pub async fn get_admin_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec { + pub async fn get_admin_collections(&self, user_id: String, conn: &mut DbConn) -> Vec { if CONFIG.org_groups_enabled() { - db_run! { conn: { + db_run! {conn: { ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(&self.uuid)) .inner_join(collections::table.on( @@ -1062,36 +922,33 @@ impl Cipher { )) .left_join(users_organizations::table.on( users_organizations::org_uuid.eq(collections::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone())) + .and(users_organizations::user_uuid.eq(user_id.clone())) )) .left_join(users_collections::table.on( users_collections::collection_uuid.eq(ciphers_collections::collection_uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone())) + .and(users_collections::user_uuid.eq(user_id.clone())) )) .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) - .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) - )) + .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid))) .left_join(collections_groups::table.on( collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid) .and(collections_groups::groups_uuid.eq(groups::uuid)) )) .filter(users_organizations::access_all.eq(true) // User has access all - .or(users_collections::user_uuid.eq(user_uuid) // User has access to collection + .or(users_collections::user_uuid.eq(user_id) // User has access to collection .and(users_collections::read_only.eq(false))) .or(groups::access_all.eq(true)) // Access via groups .or(collections_groups::collections_uuid.is_not_null() // Access via groups .and(collections_groups::read_only.eq(false))) - .or(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner + .or(users_organizations::atype.le(UserOrgType::Admin as i32)) // User is admin or owner ) .select(ciphers_collections::collection_uuid) - .load::(conn) - .unwrap_or_default() + .load::(conn).unwrap_or_default() }} } else { - db_run! { conn: { + db_run! {conn: { ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(&self.uuid)) .inner_join(collections::table.on( @@ -1099,83 +956,60 @@ impl Cipher { )) .inner_join(users_organizations::table.on( users_organizations::org_uuid.eq(collections::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid.clone())) + .and(users_organizations::user_uuid.eq(user_id.clone())) )) .left_join(users_collections::table.on( users_collections::collection_uuid.eq(ciphers_collections::collection_uuid) - .and(users_collections::user_uuid.eq(user_uuid.clone())) + .and(users_collections::user_uuid.eq(user_id.clone())) )) .filter(users_organizations::access_all.eq(true) // User has access all - .or(users_collections::user_uuid.eq(user_uuid) // User has access to collection + .or(users_collections::user_uuid.eq(user_id) // User has access to collection .and(users_collections::read_only.eq(false))) - .or(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner + .or(users_organizations::atype.le(UserOrgType::Admin as i32)) // User is admin or owner ) .select(ciphers_collections::collection_uuid) - .load::(conn) - .unwrap_or_default() + .load::(conn).unwrap_or_default() }} } } /// Return a Vec with (cipher_uuid, collection_uuid) /// This is used during a full sync so we only need one query for all collections accessible. - pub async fn get_collections_with_cipher_by_user( - user_uuid: UserId, - conn: &DbConn, - ) -> Vec<(CipherId, CollectionId)> { - db_run! { conn: { + pub async fn get_collections_with_cipher_by_user(user_id: String, conn: &mut DbConn) -> Vec<(String, String)> { + db_run! {conn: { ciphers_collections::table .inner_join(collections::table.on( collections::uuid.eq(ciphers_collections::collection_uuid) )) .inner_join(users_organizations::table.on( users_organizations::org_uuid.eq(collections::org_uuid).and( - users_organizations::user_uuid.eq(user_uuid.clone()) + users_organizations::user_uuid.eq(user_id.clone()) ) )) .left_join(users_collections::table.on( users_collections::collection_uuid.eq(ciphers_collections::collection_uuid).and( - users_collections::user_uuid.eq(user_uuid.clone()) + users_collections::user_uuid.eq(user_id.clone()) ) )) .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) - .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + .left_join(groups::table.on( + groups::uuid.eq(groups_users::groups_uuid) )) .left_join(collections_groups::table.on( collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and( collections_groups::groups_uuid.eq(groups::uuid) ) )) - .or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection + .or_filter(users_collections::user_uuid.eq(user_id)) // User has access to collection .or_filter(users_organizations::access_all.eq(true)) // User has access all - .or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner + .or_filter(users_organizations::atype.le(UserOrgType::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 .select(ciphers_collections::all_columns) .distinct() - .load::<(CipherId, CollectionId)>(conn) - .unwrap_or_default() + .load::<(String, String)>(conn).unwrap_or_default() }} } } - -#[derive( - Clone, - Debug, - AsRef, - Deref, - DieselNewType, - Display, - From, - FromForm, - Hash, - PartialEq, - Eq, - Serialize, - Deserialize, - UuidFromParam, -)] -pub struct CipherId(String); diff --git a/src/db/models/collection.rs b/src/db/models/collection.rs index b1f82335..a26f22c7 100644 --- a/src/db/models/collection.rs +++ b/src/db/models/collection.rs @@ -1,52 +1,43 @@ -use derive_more::{AsRef, Deref, Display, From}; use serde_json::Value; -use super::{ - CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, - User, UserId, -}; -use crate::db::schema::{ - ciphers_collections, collections, collections_groups, groups, groups_users, users_collections, users_organizations, -}; +use super::{CollectionGroup, GroupUser, User, UserOrgStatus, UserOrgType, UserOrganization}; use crate::CONFIG; -use diesel::prelude::*; -use macros::UuidFromParam; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = collections)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid))] -pub struct Collection { - pub uuid: CollectionId, - pub org_uuid: OrganizationId, - pub name: String, - pub external_id: Option, -} +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = collections)] + #[diesel(primary_key(uuid))] + pub struct Collection { + pub uuid: String, + pub org_uuid: String, + pub name: String, + pub external_id: Option, + } -#[derive(Identifiable, Queryable, Insertable)] -#[diesel(table_name = users_collections)] -#[diesel(primary_key(user_uuid, collection_uuid))] -pub struct CollectionUser { - pub user_uuid: UserId, - pub collection_uuid: CollectionId, - pub read_only: bool, - pub hide_passwords: bool, - pub manage: bool, -} + #[derive(Identifiable, Queryable, Insertable)] + #[diesel(table_name = users_collections)] + #[diesel(primary_key(user_uuid, collection_uuid))] + pub struct CollectionUser { + pub user_uuid: String, + pub collection_uuid: String, + pub read_only: bool, + pub hide_passwords: bool, + } -#[derive(Identifiable, Queryable, Insertable)] -#[diesel(table_name = ciphers_collections)] -#[diesel(primary_key(cipher_uuid, collection_uuid))] -pub struct CollectionCipher { - pub cipher_uuid: CipherId, - pub collection_uuid: CollectionId, + #[derive(Identifiable, Queryable, Insertable)] + #[diesel(table_name = ciphers_collections)] + #[diesel(primary_key(cipher_uuid, collection_uuid))] + pub struct CollectionCipher { + pub cipher_uuid: String, + pub collection_uuid: String, + } } /// Local methods impl Collection { - pub fn new(org_uuid: OrganizationId, name: String, external_id: Option) -> Self { + pub fn new(org_uuid: String, name: String, external_id: Option) -> Self { let mut new_model = Self { - uuid: CollectionId(crate::util::get_uuid()), + uuid: crate::util::get_uuid(), org_uuid, name, external_id: None, @@ -83,30 +74,22 @@ impl Collection { pub async fn to_json_details( &self, - user_uuid: &UserId, + user_uuid: &str, cipher_sync_data: Option<&crate::api::core::CipherSyncData>, - conn: &DbConn, + conn: &mut DbConn, ) -> Value { - let (read_only, hide_passwords, manage) = if let Some(cipher_sync_data) = cipher_sync_data { - match cipher_sync_data.members.get(&self.org_uuid) { - // Only for Manager types Bitwarden returns true for the manage option - // Owners and Admins always have true. Users are not able to have full access - Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager), - Some(m) => { + let (read_only, hide_passwords, can_manage) = if let Some(cipher_sync_data) = cipher_sync_data { + match cipher_sync_data.user_organizations.get(&self.org_uuid) { + // Only for Manager types Bitwarden returns true for the can_manage option + // Owners and Admins always have true + Some(uo) if uo.has_full_access() => (false, false, uo.atype >= UserOrgType::Manager), + Some(uo) => { // Only let a manager manage collections when the have full read/write access - let is_manager = m.atype == MembershipType::Manager; - if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) { - ( - cu.read_only, - cu.hide_passwords, - is_manager && (cu.manage || (!cu.read_only && !cu.hide_passwords)), - ) + let is_manager = uo.atype == UserOrgType::Manager; + if let Some(uc) = cipher_sync_data.user_collections.get(&self.uuid) { + (uc.read_only, uc.hide_passwords, is_manager && !uc.read_only && !uc.hide_passwords) } else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) { - ( - cg.read_only, - cg.hide_passwords, - is_manager && (cg.manage || (!cg.read_only && !cg.hide_passwords)), - ) + (cg.read_only, cg.hide_passwords, is_manager && !cg.read_only && !cg.hide_passwords) } else { (false, false, false) } @@ -114,18 +97,19 @@ impl Collection { _ => (true, true, false), } } else { - match Membership::find_confirmed_by_user_and_org(user_uuid, &self.org_uuid, conn).await { - Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager), - Some(m) if m.atype == MembershipType::Manager && self.is_manageable_by_user(user_uuid, conn).await => { - (false, false, true) - } - Some(m) => { - let is_manager = m.atype == MembershipType::Manager; + match UserOrganization::find_confirmed_by_user_and_org(user_uuid, &self.org_uuid, conn).await { + Some(ou) if ou.has_full_access() => (false, false, ou.atype >= UserOrgType::Manager), + Some(ou) => { + let is_manager = ou.atype == UserOrgType::Manager; let read_only = !self.is_writable_by_user(user_uuid, conn).await; let hide_passwords = self.hide_passwords_for_user(user_uuid, conn).await; (read_only, hide_passwords, is_manager && !read_only && !hide_passwords) } - _ => (true, true, false), + _ => ( + !self.is_writable_by_user(user_uuid, conn).await, + self.hide_passwords_for_user(user_uuid, conn).await, + false, + ), } }; @@ -133,17 +117,17 @@ impl Collection { json_object["object"] = json!("collectionDetails"); json_object["readOnly"] = json!(read_only); json_object["hidePasswords"] = json!(hide_passwords); - json_object["manage"] = json!(manage); + json_object["manage"] = json!(can_manage); json_object } - pub async fn can_access_collection(member: &Membership, col_id: &CollectionId, conn: &DbConn) -> bool { - member.has_status(MembershipStatus::Confirmed) - && (member.has_full_access() - || CollectionUser::has_access_to_collection_by_user(col_id, &member.user_uuid, conn).await + pub async fn can_access_collection(org_user: &UserOrganization, col_id: &str, conn: &mut DbConn) -> bool { + org_user.has_status(UserOrgStatus::Confirmed) + && (org_user.has_full_access() + || CollectionUser::has_access_to_collection_by_user(col_id, &org_user.user_uuid, conn).await || (CONFIG.org_groups_enabled() - && (GroupUser::has_full_access_by_member(&member.org_uuid, &member.uuid, conn).await - || GroupUser::has_access_to_collection_by_member(col_id, &member.uuid, conn).await))) + && (GroupUser::has_full_access_by_member(&org_user.org_uuid, &org_user.uuid, conn).await + || GroupUser::has_access_to_collection_by_member(col_id, &org_user.uuid, conn).await))) } } @@ -154,13 +138,13 @@ use crate::error::MapResult; /// Database methods impl Collection { - pub async fn save(&self, conn: &DbConn) -> EmptyResult { + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { self.update_users_revision(conn).await; db_run! { conn: sqlite, mysql { match diesel::replace_into(collections::table) - .values(self) + .values(CollectionDb::to_db(self)) .execute(conn) { Ok(_) => Ok(()), @@ -168,7 +152,7 @@ impl Collection { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(collections::table) .filter(collections::uuid.eq(&self.uuid)) - .set(self) + .set(CollectionDb::to_db(self)) .execute(conn) .map_res("Error saving collection") } @@ -176,22 +160,23 @@ impl Collection { }.map_res("Error saving collection") } postgresql { + let value = CollectionDb::to_db(self); diesel::insert_into(collections::table) - .values(self) + .values(&value) .on_conflict(collections::uuid) .do_update() - .set(self) + .set(&value) .execute(conn) .map_res("Error saving collection") } } } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { self.update_users_revision(conn).await; CollectionCipher::delete_all_by_collection(&self.uuid, conn).await?; CollectionUser::delete_all_by_collection(&self.uuid, conn).await?; - CollectionGroup::delete_all_by_collection(&self.uuid, &self.org_uuid, conn).await?; + CollectionGroup::delete_all_by_collection(&self.uuid, conn).await?; db_run! { conn: { diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid))) @@ -200,29 +185,30 @@ impl Collection { }} } - pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_organization(org_uuid: &str, conn: &mut DbConn) -> EmptyResult { for collection in Self::find_by_organization(org_uuid, conn).await { collection.delete(conn).await?; } Ok(()) } - pub async fn update_users_revision(&self, conn: &DbConn) { - for member in Membership::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn).await.iter() { - User::update_uuid_revision(&member.user_uuid, conn).await; + pub async fn update_users_revision(&self, conn: &mut DbConn) { + for user_org in UserOrganization::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn).await.iter() { + User::update_uuid_revision(&user_org.user_uuid, conn).await; } } - pub async fn find_by_uuid(uuid: &CollectionId, conn: &DbConn) -> Option { + pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { collections::table .filter(collections::uuid.eq(uuid)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_by_user_uuid(user_uuid: UserId, conn: &DbConn) -> Vec { + pub async fn find_by_user_uuid(user_uuid: String, conn: &mut DbConn) -> Vec { if CONFIG.org_groups_enabled() { db_run! { conn: { collections::table @@ -239,8 +225,8 @@ impl Collection { .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) - .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + .left_join(groups::table.on( + groups::uuid.eq(groups_users::groups_uuid) )) .left_join(collections_groups::table.on( collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( @@ -248,7 +234,7 @@ impl Collection { ) )) .filter( - users_organizations::status.eq(MembershipStatus::Confirmed as i32) + users_organizations::status.eq(UserOrgStatus::Confirmed as i32) ) .filter( users_collections::user_uuid.eq(user_uuid).or( // Directly accessed collection @@ -263,8 +249,7 @@ impl Collection { ) .select(collections::all_columns) .distinct() - .load::(conn) - .expect("Error loading collections") + .load::(conn).expect("Error loading collections").from_db() }} } else { db_run! { conn: { @@ -280,7 +265,7 @@ impl Collection { ) )) .filter( - users_organizations::status.eq(MembershipStatus::Confirmed as i32) + users_organizations::status.eq(UserOrgStatus::Confirmed as i32) ) .filter( users_collections::user_uuid.eq(user_uuid).or( // Directly accessed collection @@ -289,34 +274,30 @@ impl Collection { ) .select(collections::all_columns) .distinct() - .load::(conn) - .expect("Error loading collections") + .load::(conn).expect("Error loading collections").from_db() }} } } - pub async fn find_by_organization_and_user_uuid( - org_uuid: &OrganizationId, - user_uuid: &UserId, - conn: &DbConn, - ) -> Vec { + pub async fn find_by_organization_and_user_uuid(org_uuid: &str, user_uuid: &str, conn: &mut DbConn) -> Vec { Self::find_by_user_uuid(user_uuid.to_owned(), conn) .await .into_iter() - .filter(|c| &c.org_uuid == org_uuid) + .filter(|c| c.org_uuid == org_uuid) .collect() } - pub async fn find_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { + pub async fn find_by_organization(org_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { collections::table .filter(collections::org_uuid.eq(org_uuid)) - .load::(conn) + .load::(conn) .expect("Error loading collections") + .from_db() }} } - pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { + pub async fn count_by_org(org_uuid: &str, conn: &mut DbConn) -> i64 { db_run! { conn: { collections::table .filter(collections::org_uuid.eq(org_uuid)) @@ -327,18 +308,19 @@ impl Collection { }} } - pub async fn find_by_uuid_and_org(uuid: &CollectionId, org_uuid: &OrganizationId, conn: &DbConn) -> Option { + pub async fn find_by_uuid_and_org(uuid: &str, org_uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { collections::table .filter(collections::uuid.eq(uuid)) .filter(collections::org_uuid.eq(org_uuid)) .select(collections::all_columns) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_by_uuid_and_user(uuid: &CollectionId, user_uuid: UserId, conn: &DbConn) -> Option { + pub async fn find_by_uuid_and_user(uuid: &str, user_uuid: String, conn: &mut DbConn) -> Option { if CONFIG.org_groups_enabled() { db_run! { conn: { collections::table @@ -355,8 +337,8 @@ impl Collection { .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) - .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + .left_join(groups::table.on( + groups::uuid.eq(groups_users::groups_uuid) )) .left_join(collections_groups::table.on( collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( @@ -367,7 +349,7 @@ impl Collection { .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.le(MembershipType::Admin as i32) // Org admin or owner + users_organizations::atype.le(UserOrgType::Admin as i32) // Org admin or owner )).or( groups::access_all.eq(true) // access_all in groups ).or( // access via groups @@ -376,8 +358,8 @@ impl Collection { ) ) ).select(collections::all_columns) - .first::(conn) - .ok() + .first::(conn).ok() + .from_db() }} } else { db_run! { conn: { @@ -396,16 +378,16 @@ impl Collection { .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.le(MembershipType::Admin as i32) // Org admin or owner + users_organizations::atype.le(UserOrgType::Admin as i32) // Org admin or owner )) ).select(collections::all_columns) - .first::(conn) - .ok() + .first::(conn).ok() + .from_db() }} } } - pub async fn is_writable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { + pub async fn is_writable_by_user(&self, user_uuid: &str, conn: &mut DbConn) -> bool { let user_uuid = user_uuid.to_string(); if CONFIG.org_groups_enabled() { db_run! { conn: { @@ -422,14 +404,14 @@ impl Collection { .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) - .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + .left_join(groups::table.on( + groups::uuid.eq(groups_users::groups_uuid) )) .left_join(collections_groups::table.on( collections_groups::groups_uuid.eq(groups_users::groups_uuid) .and(collections_groups::collections_uuid.eq(collections::uuid)) )) - .filter(users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner + .filter(users_organizations::atype.le(UserOrgType::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 .and(users_collections::read_only.eq(false))) @@ -454,7 +436,7 @@ impl Collection { users_collections::collection_uuid.eq(collections::uuid) .and(users_collections::user_uuid.eq(user_uuid)) )) - .filter(users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner + .filter(users_organizations::atype.le(UserOrgType::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 .and(users_collections::read_only.eq(false))) @@ -467,7 +449,7 @@ impl Collection { } } - pub async fn hide_passwords_for_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { + pub async fn hide_passwords_for_user(&self, user_uuid: &str, conn: &mut DbConn) -> bool { let user_uuid = user_uuid.to_string(); db_run! { conn: { collections::table @@ -484,8 +466,8 @@ impl Collection { .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) - .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + .left_join(groups::table.on( + groups::uuid.eq(groups_users::groups_uuid) )) .left_join(collections_groups::table.on( collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( @@ -496,7 +478,7 @@ impl Collection { .filter( users_collections::collection_uuid.eq(&self.uuid).and(users_collections::hide_passwords.eq(true)).or(// Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization - users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner + users_organizations::atype.le(UserOrgType::Admin as i32) // Org admin or owner )).or( groups::access_all.eq(true) // access_all in groups ).or( // access via groups @@ -512,101 +494,42 @@ impl Collection { .unwrap_or(0) != 0 }} } - - pub async fn is_coll_manageable_by_user(uuid: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool { - let uuid = uuid.to_string(); - let user_uuid = user_uuid.to_string(); - db_run! { conn: { - collections::table - .left_join(users_collections::table.on( - users_collections::collection_uuid.eq(collections::uuid).and( - users_collections::user_uuid.eq(user_uuid.clone()) - ) - )) - .left_join(users_organizations::table.on( - collections::org_uuid.eq(users_organizations::org_uuid).and( - users_organizations::user_uuid.eq(user_uuid) - ) - )) - .left_join(groups_users::table.on( - groups_users::users_organizations_uuid.eq(users_organizations::uuid) - )) - .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) - )) - .left_join(collections_groups::table.on( - collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( - collections_groups::collections_uuid.eq(collections::uuid) - ) - )) - .filter(collections::uuid.eq(&uuid)) - .filter( - users_collections::collection_uuid.eq(&uuid).and(users_collections::manage.eq(true)).or(// Directly accessed collection - users_organizations::access_all.eq(true).or( // access_all in Organization - users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner - )).or( - groups::access_all.eq(true) // access_all in groups - ).or( // access via groups - groups_users::users_organizations_uuid.eq(users_organizations::uuid).and( - collections_groups::collections_uuid.is_not_null().and( - collections_groups::manage.eq(true)) - ) - ) - ) - .count() - .first::(conn) - .ok() - .unwrap_or(0) != 0 - }} - } - - pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { - Self::is_coll_manageable_by_user(&self.uuid, user_uuid, conn).await - } } /// Database methods impl CollectionUser { - pub async fn find_by_organization_and_user_uuid( - org_uuid: &OrganizationId, - user_uuid: &UserId, - conn: &DbConn, - ) -> Vec { + pub async fn find_by_organization_and_user_uuid(org_uuid: &str, user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_collections::table .filter(users_collections::user_uuid.eq(user_uuid)) .inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid))) .filter(collections::org_uuid.eq(org_uuid)) .select(users_collections::all_columns) - .load::(conn) + .load::(conn) .expect("Error loading users_collections") + .from_db() }} } - pub async fn find_by_organization_swap_user_uuid_with_member_uuid( - org_uuid: &OrganizationId, - conn: &DbConn, - ) -> Vec { - let col_users = db_run! { conn: { + pub async fn find_by_organization(org_uuid: &str, conn: &mut DbConn) -> Vec { + db_run! { conn: { users_collections::table .inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid))) .filter(collections::org_uuid.eq(org_uuid)) .inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid))) - .filter(users_organizations::org_uuid.eq(org_uuid)) - .select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) - .load::(conn) + .select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords)) + .load::(conn) .expect("Error loading users_collections") - }}; - col_users.into_iter().map(|c| c.into()).collect() + .from_db() + }} } pub async fn save( - user_uuid: &UserId, - collection_uuid: &CollectionId, + user_uuid: &str, + collection_uuid: &str, read_only: bool, hide_passwords: bool, - manage: bool, - conn: &DbConn, + conn: &mut DbConn, ) -> EmptyResult { User::update_uuid_revision(user_uuid, conn).await; @@ -618,7 +541,6 @@ impl CollectionUser { users_collections::collection_uuid.eq(collection_uuid), users_collections::read_only.eq(read_only), users_collections::hide_passwords.eq(hide_passwords), - users_collections::manage.eq(manage), )) .execute(conn) { @@ -633,7 +555,6 @@ impl CollectionUser { users_collections::collection_uuid.eq(collection_uuid), users_collections::read_only.eq(read_only), users_collections::hide_passwords.eq(hide_passwords), - users_collections::manage.eq(manage), )) .execute(conn) .map_res("Error adding user to collection") @@ -648,14 +569,12 @@ impl CollectionUser { users_collections::collection_uuid.eq(collection_uuid), users_collections::read_only.eq(read_only), users_collections::hide_passwords.eq(hide_passwords), - users_collections::manage.eq(manage), )) .on_conflict((users_collections::user_uuid, users_collections::collection_uuid)) .do_update() .set(( users_collections::read_only.eq(read_only), users_collections::hide_passwords.eq(hide_passwords), - users_collections::manage.eq(manage), )) .execute(conn) .map_res("Error adding user to collection") @@ -663,7 +582,7 @@ impl CollectionUser { } } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { User::update_uuid_revision(&self.user_uuid, conn).await; db_run! { conn: { @@ -677,59 +596,60 @@ impl CollectionUser { }} } - pub async fn find_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> Vec { + pub async fn find_by_collection(collection_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_collections::table .filter(users_collections::collection_uuid.eq(collection_uuid)) .select(users_collections::all_columns) - .load::(conn) + .load::(conn) .expect("Error loading users_collections") + .from_db() }} } - pub async fn find_by_org_and_coll_swap_user_uuid_with_member_uuid( - org_uuid: &OrganizationId, - collection_uuid: &CollectionId, - conn: &DbConn, - ) -> Vec { - let col_users = db_run! { conn: { + pub async fn find_by_collection_swap_user_uuid_with_org_user_uuid( + collection_uuid: &str, + conn: &mut DbConn, + ) -> Vec { + db_run! { conn: { users_collections::table .filter(users_collections::collection_uuid.eq(collection_uuid)) - .filter(users_organizations::org_uuid.eq(org_uuid)) .inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid))) - .select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) - .load::(conn) + .select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords)) + .load::(conn) .expect("Error loading users_collections") - }}; - col_users.into_iter().map(|c| c.into()).collect() + .from_db() + }} } pub async fn find_by_collection_and_user( - collection_uuid: &CollectionId, - user_uuid: &UserId, - conn: &DbConn, + collection_uuid: &str, + user_uuid: &str, + conn: &mut DbConn, ) -> Option { db_run! { conn: { users_collections::table .filter(users_collections::collection_uuid.eq(collection_uuid)) .filter(users_collections::user_uuid.eq(user_uuid)) .select(users_collections::all_columns) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_collections::table .filter(users_collections::user_uuid.eq(user_uuid)) .select(users_collections::all_columns) - .load::(conn) + .load::(conn) .expect("Error loading users_collections") + .from_db() }} } - pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_collection(collection_uuid: &str, conn: &mut DbConn) -> EmptyResult { for collection in CollectionUser::find_by_collection(collection_uuid, conn).await.iter() { User::update_uuid_revision(&collection.user_uuid, conn).await; } @@ -741,11 +661,7 @@ impl CollectionUser { }} } - pub async fn delete_all_by_user_and_org( - user_uuid: &UserId, - org_uuid: &OrganizationId, - conn: &DbConn, - ) -> EmptyResult { + pub async fn delete_all_by_user_and_org(user_uuid: &str, org_uuid: &str, conn: &mut DbConn) -> EmptyResult { let collectionusers = Self::find_by_organization_and_user_uuid(org_uuid, user_uuid, conn).await; db_run! { conn: { @@ -761,14 +677,14 @@ impl CollectionUser { }} } - pub async fn has_access_to_collection_by_user(col_id: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool { + pub async fn has_access_to_collection_by_user(col_id: &str, user_uuid: &str, conn: &mut DbConn) -> bool { Self::find_by_collection_and_user(col_id, user_uuid, conn).await.is_some() } } /// Database methods impl CollectionCipher { - pub async fn save(cipher_uuid: &CipherId, collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult { + pub async fn save(cipher_uuid: &str, collection_uuid: &str, conn: &mut DbConn) -> EmptyResult { Self::update_users_revision(collection_uuid, conn).await; db_run! { conn: @@ -798,7 +714,7 @@ impl CollectionCipher { } } - pub async fn delete(cipher_uuid: &CipherId, collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult { + pub async fn delete(cipher_uuid: &str, collection_uuid: &str, conn: &mut DbConn) -> EmptyResult { Self::update_users_revision(collection_uuid, conn).await; db_run! { conn: { @@ -812,7 +728,7 @@ impl CollectionCipher { }} } - pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_cipher(cipher_uuid: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(ciphers_collections::table.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid))) .execute(conn) @@ -820,7 +736,7 @@ impl CollectionCipher { }} } - pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_collection(collection_uuid: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(ciphers_collections::table.filter(ciphers_collections::collection_uuid.eq(collection_uuid))) .execute(conn) @@ -828,63 +744,9 @@ impl CollectionCipher { }} } - pub async fn update_users_revision(collection_uuid: &CollectionId, conn: &DbConn) { + pub async fn update_users_revision(collection_uuid: &str, conn: &mut DbConn) { if let Some(collection) = Collection::find_by_uuid(collection_uuid, conn).await { collection.update_users_revision(conn).await; } } } - -// Added in case we need the membership_uuid instead of the user_uuid -pub struct CollectionMembership { - pub membership_uuid: MembershipId, - pub collection_uuid: CollectionId, - pub read_only: bool, - pub hide_passwords: bool, - pub manage: bool, -} - -impl CollectionMembership { - pub fn to_json_details_for_member(&self, membership_type: i32) -> Value { - json!({ - "id": self.membership_uuid, - "readOnly": self.read_only, - "hidePasswords": self.hide_passwords, - "manage": membership_type >= MembershipType::Admin - || self.manage - || (membership_type == MembershipType::Manager - && !self.read_only - && !self.hide_passwords), - }) - } -} - -impl From for CollectionMembership { - fn from(c: CollectionUser) -> Self { - Self { - membership_uuid: c.user_uuid.to_string().into(), - collection_uuid: c.collection_uuid, - read_only: c.read_only, - hide_passwords: c.hide_passwords, - manage: c.manage, - } - } -} - -#[derive( - Clone, - Debug, - AsRef, - Deref, - DieselNewType, - Display, - From, - FromForm, - Hash, - PartialEq, - Eq, - Serialize, - Deserialize, - UuidFromParam, -)] -pub struct CollectionId(String); diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 7364a2ec..8feab49d 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -1,41 +1,34 @@ use chrono::{NaiveDateTime, Utc}; -use data_encoding::BASE64URL; -use derive_more::{Display, From}; -use serde_json::Value; +use crate::{crypto, CONFIG}; +use core::fmt; -use super::{AuthRequest, UserId}; -use crate::db::schema::devices; -use crate::{ - crypto, - util::{format_date, get_uuid}, -}; -use diesel::prelude::*; -use macros::{IdFromParam, UuidFromParam}; +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = devices)] + #[diesel(treat_none_as_null = true)] + #[diesel(primary_key(uuid, user_uuid))] + pub struct Device { + pub uuid: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = devices)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid, user_uuid))] -pub struct Device { - pub uuid: DeviceId, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, + pub user_uuid: String, - pub user_uuid: UserId, + pub name: String, + pub atype: i32, // https://github.com/bitwarden/server/blob/dcc199bcce4aa2d5621f6fab80f1b49d8b143418/src/Core/Enums/DeviceType.cs + pub push_uuid: Option, + pub push_token: Option, - pub name: String, - pub atype: i32, // https://github.com/bitwarden/server/blob/8d547dcc280babab70dd4a3c94ced6a34b12dfbf/src/Core/Enums/DeviceType.cs - pub push_uuid: Option, - pub push_token: Option, + pub refresh_token: String, - pub refresh_token: String, - pub twofactor_remember: Option, + pub twofactor_remember: Option, + } } /// Local methods impl Device { - pub fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32) -> Self { + pub fn new(uuid: String, user_uuid: String, name: String, atype: i32) -> Self { let now = Utc::now().naive_utc(); Self { @@ -47,94 +40,89 @@ impl Device { name, atype, - push_uuid: Some(PushId(get_uuid())), + push_uuid: None, push_token: None, - refresh_token: Device::generate_refresh_token(), + refresh_token: String::new(), twofactor_remember: None, } } - #[inline(always)] - pub fn generate_refresh_token() -> String { - crypto::encode_random_bytes::<64>(&BASE64URL) - } - - pub fn to_json(&self) -> Value { - json!({ - "id": self.uuid, - "name": self.name, - "type": self.atype, - "identifier": self.uuid, - "creationDate": format_date(&self.created_at), - "isTrusted": false, - "object":"device" - }) - } - pub fn refresh_twofactor_remember(&mut self) -> String { - use crate::auth::{encode_jwt, generate_2fa_remember_claims}; + use data_encoding::BASE64; + let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64); + self.twofactor_remember = Some(twofactor_remember.clone()); - let two_factor_remember_claim = generate_2fa_remember_claims(self.uuid.clone(), self.user_uuid.clone()); - let two_factor_remember_string = encode_jwt(&two_factor_remember_claim); - self.twofactor_remember = Some(two_factor_remember_string.clone()); - - two_factor_remember_string + twofactor_remember } pub fn delete_twofactor_remember(&mut self) { self.twofactor_remember = None; } - // This rely on the fact we only update the device after a successful login - pub fn is_new(&self) -> bool { - self.created_at == self.updated_at + pub fn refresh_tokens(&mut self, user: &super::User, scope: Vec) -> (String, i64) { + // If there is no refresh token, we create one + if self.refresh_token.is_empty() { + use data_encoding::BASE64URL; + self.refresh_token = crypto::encode_random_bytes::<64>(BASE64URL); + } + + // Update the expiration of the device and the last update date + let time_now = Utc::now(); + self.updated_at = time_now.naive_utc(); + + // --- + // Disabled these keys to be added to the JWT since they could cause the JWT to get too large + // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients + // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out + // --- + // fn arg: orgs: Vec, + // --- + // let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect(); + // let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect(); + // let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect(); + // let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect(); + + // Create the JWT claims struct, to send to the client + use crate::auth::{encode_jwt, LoginJwtClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER}; + let claims = LoginJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + *DEFAULT_VALIDITY).timestamp(), + iss: JWT_LOGIN_ISSUER.to_string(), + sub: user.uuid.clone(), + + premium: true, + name: user.name.clone(), + email: user.email.clone(), + 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 + // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients + // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out + // See: https://github.com/dani-garcia/vaultwarden/issues/4156 + // --- + // orgowner, + // orgadmin, + // orguser, + // orgmanager, + sstamp: user.security_stamp.clone(), + device: self.uuid.clone(), + scope, + amr: vec!["Application".into()], + }; + + (encode_jwt(&claims), DEFAULT_VALIDITY.num_seconds()) } pub fn is_push_device(&self) -> bool { matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios) } - pub fn is_cli(&self) -> bool { - matches!(DeviceType::from_i32(self.atype), DeviceType::WindowsCLI | DeviceType::MacOsCLI | DeviceType::LinuxCLI) - } - - pub fn is_mobile(&self) -> bool { - matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios) + pub fn is_registered(&self) -> bool { + self.push_uuid.is_some() } } -pub struct DeviceWithAuthRequest { - pub device: Device, - pub pending_auth_request: Option, -} - -impl DeviceWithAuthRequest { - pub fn to_json(&self) -> Value { - let auth_request = match &self.pending_auth_request { - Some(auth_request) => auth_request.to_json_for_pending_device(), - None => Value::Null, - }; - json!({ - "id": self.device.uuid, - "name": self.device.name, - "type": self.device.atype, - "identifier": self.device.uuid, - "creationDate": format_date(&self.device.created_at), - "devicePendingAuthRequest": auth_request, - "isTrusted": false, - "encryptedPublicKey": null, - "encryptedUserKey": null, - "object": "device", - }) - } - - pub fn from(c: Device, a: Option) -> Self { - Self { - device: c, - pending_auth_request: a, - } - } -} use crate::db::DbConn; use crate::api::EmptyResult; @@ -142,35 +130,27 @@ use crate::error::MapResult; /// Database methods impl Device { - pub async fn save(&mut self, update_time: bool, conn: &DbConn) -> EmptyResult { - if update_time { - self.updated_at = Utc::now().naive_utc(); - } + pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { + self.updated_at = Utc::now().naive_utc(); db_run! { conn: sqlite, mysql { - crate::util::retry(|| - diesel::replace_into(devices::table) - .values(&*self) - .execute(conn), + crate::util::retry( + || diesel::replace_into(devices::table).values(DeviceDb::to_db(self)).execute(conn), 10, ).map_res("Error saving device") } postgresql { - crate::util::retry(|| - diesel::insert_into(devices::table) - .values(&*self) - .on_conflict((devices::uuid, devices::user_uuid)) - .do_update() - .set(&*self) - .execute(conn), + let value = DeviceDb::to_db(self); + crate::util::retry( + || diesel::insert_into(devices::table).values(&value).on_conflict((devices::uuid, devices::user_uuid)).do_update().set(&value).execute(conn), 10, ).map_res("Error saving device") } } } - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid))) .execute(conn) @@ -178,45 +158,38 @@ impl Device { }} } - pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option { + pub async fn find_by_uuid_and_user(uuid: &str, user_uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { devices::table .filter(devices::uuid.eq(uuid)) .filter(devices::user_uuid.eq(user_uuid)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_with_auth_request_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - let devices = Self::find_by_user(user_uuid, conn).await; - let mut result = Vec::new(); - for device in devices { - let auth_request = AuthRequest::find_by_user_and_requested_device(user_uuid, &device.uuid, conn).await; - result.push(DeviceWithAuthRequest::from(device, auth_request)); - } - result - } - - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { devices::table .filter(devices::user_uuid.eq(user_uuid)) - .load::(conn) + .load::(conn) .expect("Error loading devices") + .from_db() }} } - pub async fn find_by_uuid(uuid: &DeviceId, conn: &DbConn) -> Option { + pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { devices::table .filter(devices::uuid.eq(uuid)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn clear_push_token_by_uuid(uuid: &DeviceId, conn: &DbConn) -> EmptyResult { + pub async fn clear_push_token_by_uuid(uuid: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::update(devices::table) .filter(devices::uuid.eq(uuid)) @@ -225,36 +198,39 @@ impl Device { .map_res("Error removing push token") }} } - pub async fn find_by_refresh_token(refresh_token: &str, conn: &DbConn) -> Option { + pub async fn find_by_refresh_token(refresh_token: &str, conn: &mut DbConn) -> Option { db_run! { conn: { devices::table .filter(devices::refresh_token.eq(refresh_token)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_latest_active_by_user(user_uuid: &UserId, conn: &DbConn) -> Option { + pub async fn find_latest_active_by_user(user_uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { devices::table .filter(devices::user_uuid.eq(user_uuid)) .order(devices::updated_at.desc()) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_push_devices_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn find_push_devices_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { devices::table .filter(devices::user_uuid.eq(user_uuid)) .filter(devices::push_token.is_not_null()) - .load::(conn) + .load::(conn) .expect("Error loading push devices") + .from_db() }} } - pub async fn check_user_has_push_device(user_uuid: &UserId, conn: &DbConn) -> bool { + pub async fn check_user_has_push_device(user_uuid: &str, conn: &mut DbConn) -> bool { db_run! { conn: { devices::table .filter(devices::user_uuid.eq(user_uuid)) @@ -265,75 +241,68 @@ impl Device { .unwrap_or(0) != 0 }} } - - pub async fn rotate_refresh_tokens_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - // Generate a new token per device. - // We cannot do a single UPDATE with one value because each device needs a unique token. - let devices = Self::find_by_user(user_uuid, conn).await; - for mut device in devices { - device.refresh_token = Device::generate_refresh_token(); - device.save(false, conn).await?; - } - Ok(()) - } } -#[derive(Display)] pub enum DeviceType { - #[display("Android")] Android = 0, - #[display("iOS")] Ios = 1, - #[display("Chrome Extension")] ChromeExtension = 2, - #[display("Firefox Extension")] FirefoxExtension = 3, - #[display("Opera Extension")] OperaExtension = 4, - #[display("Edge Extension")] EdgeExtension = 5, - #[display("Windows")] WindowsDesktop = 6, - #[display("macOS")] MacOsDesktop = 7, - #[display("Linux")] LinuxDesktop = 8, - #[display("Chrome")] ChromeBrowser = 9, - #[display("Firefox")] FirefoxBrowser = 10, - #[display("Opera")] OperaBrowser = 11, - #[display("Edge")] EdgeBrowser = 12, - #[display("Internet Explorer")] IEBrowser = 13, - #[display("Unknown Browser")] UnknownBrowser = 14, - #[display("Android")] AndroidAmazon = 15, - #[display("UWP")] Uwp = 16, - #[display("Safari")] SafariBrowser = 17, - #[display("Vivaldi")] VivaldiBrowser = 18, - #[display("Vivaldi Extension")] VivaldiExtension = 19, - #[display("Safari Extension")] SafariExtension = 20, - #[display("SDK")] Sdk = 21, - #[display("Server")] Server = 22, - #[display("Windows CLI")] WindowsCLI = 23, - #[display("macOS CLI")] MacOsCLI = 24, - #[display("Linux CLI")] LinuxCLI = 25, - #[display("DuckDuckGo")] - DuckDuckGoBrowser = 26, +} + +impl fmt::Display for DeviceType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DeviceType::Android => write!(f, "Android"), + DeviceType::Ios => write!(f, "iOS"), + DeviceType::ChromeExtension => write!(f, "Chrome Extension"), + DeviceType::FirefoxExtension => write!(f, "Firefox Extension"), + DeviceType::OperaExtension => write!(f, "Opera Extension"), + DeviceType::EdgeExtension => write!(f, "Edge Extension"), + DeviceType::WindowsDesktop => write!(f, "Windows"), + DeviceType::MacOsDesktop => write!(f, "macOS"), + DeviceType::LinuxDesktop => write!(f, "Linux"), + DeviceType::ChromeBrowser => write!(f, "Chrome"), + DeviceType::FirefoxBrowser => write!(f, "Firefox"), + DeviceType::OperaBrowser => write!(f, "Opera"), + DeviceType::EdgeBrowser => write!(f, "Edge"), + DeviceType::IEBrowser => write!(f, "Internet Explorer"), + DeviceType::UnknownBrowser => write!(f, "Unknown Browser"), + DeviceType::AndroidAmazon => write!(f, "Android"), + DeviceType::Uwp => write!(f, "UWP"), + DeviceType::SafariBrowser => write!(f, "Safari"), + DeviceType::VivaldiBrowser => write!(f, "Vivaldi"), + DeviceType::VivaldiExtension => write!(f, "Vivaldi Extension"), + DeviceType::SafariExtension => write!(f, "Safari Extension"), + DeviceType::Sdk => write!(f, "SDK"), + DeviceType::Server => write!(f, "Server"), + DeviceType::WindowsCLI => write!(f, "Windows CLI"), + DeviceType::MacOsCLI => write!(f, "macOS CLI"), + DeviceType::LinuxCLI => write!(f, "Linux CLI"), + } + } } impl DeviceType { @@ -365,16 +334,7 @@ impl DeviceType { 23 => DeviceType::WindowsCLI, 24 => DeviceType::MacOsCLI, 25 => DeviceType::LinuxCLI, - 26 => DeviceType::DuckDuckGoBrowser, _ => DeviceType::UnknownBrowser, } } } - -#[derive( - Clone, Debug, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam, -)] -pub struct DeviceId(String); - -#[derive(Clone, Debug, DieselNewType, Display, From, FromForm, Serialize, Deserialize, UuidFromParam)] -pub struct PushId(pub String); diff --git a/src/db/models/emergency_access.rs b/src/db/models/emergency_access.rs index 5ea334a4..f4f3b9a9 100644 --- a/src/db/models/emergency_access.rs +++ b/src/db/models/emergency_access.rs @@ -1,39 +1,39 @@ use chrono::{NaiveDateTime, Utc}; -use derive_more::{AsRef, Deref, Display, From}; use serde_json::Value; -use super::{User, UserId}; -use crate::db::schema::emergency_access; use crate::{api::EmptyResult, db::DbConn, error::MapResult}; -use diesel::prelude::*; -use macros::UuidFromParam; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = emergency_access)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid))] -pub struct EmergencyAccess { - pub uuid: EmergencyAccessId, - pub grantor_uuid: UserId, - pub grantee_uuid: Option, - pub email: Option, - pub key_encrypted: Option, - pub atype: i32, //EmergencyAccessType - pub status: i32, //EmergencyAccessStatus - pub wait_time_days: i32, - pub recovery_initiated_at: Option, - pub last_notification_at: Option, - pub updated_at: NaiveDateTime, - pub created_at: NaiveDateTime, +use super::User; + +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = emergency_access)] + #[diesel(treat_none_as_null = true)] + #[diesel(primary_key(uuid))] + pub struct EmergencyAccess { + pub uuid: String, + pub grantor_uuid: String, + pub grantee_uuid: Option, + pub email: Option, + pub key_encrypted: Option, + pub atype: i32, //EmergencyAccessType + pub status: i32, //EmergencyAccessStatus + pub wait_time_days: i32, + pub recovery_initiated_at: Option, + pub last_notification_at: Option, + pub updated_at: NaiveDateTime, + pub created_at: NaiveDateTime, + } } // Local methods + impl EmergencyAccess { - pub fn new(grantor_uuid: UserId, email: String, status: i32, atype: i32, wait_time_days: i32) -> Self { + pub fn new(grantor_uuid: String, email: String, status: i32, atype: i32, wait_time_days: i32) -> Self { let now = Utc::now().naive_utc(); Self { - uuid: EmergencyAccessId(crate::util::get_uuid()), + uuid: crate::util::get_uuid(), grantor_uuid, grantee_uuid: None, email: Some(email), @@ -66,7 +66,7 @@ impl EmergencyAccess { }) } - pub async fn to_json_grantor_details(&self, conn: &DbConn) -> Value { + pub async fn to_json_grantor_details(&self, conn: &mut DbConn) -> Value { let grantor_user = User::find_by_uuid(&self.grantor_uuid, conn).await.expect("Grantor user not found."); json!({ @@ -77,16 +77,14 @@ impl EmergencyAccess { "grantorId": grantor_user.uuid, "email": grantor_user.email, "name": grantor_user.name, - "avatarColor": grantor_user.avatar_color, "object": "emergencyAccessGrantorDetails", }) } - pub async fn to_json_grantee_details(&self, conn: &DbConn) -> Option { - let grantee_user = if let Some(grantee_uuid) = &self.grantee_uuid { + pub async fn to_json_grantee_details(&self, conn: &mut DbConn) -> Option { + let grantee_user = if let Some(grantee_uuid) = self.grantee_uuid.as_deref() { User::find_by_uuid(grantee_uuid, conn).await.expect("Grantee user not found.") - } else { - let email = self.email.as_deref()?; + } else if let Some(email) = self.email.as_deref() { match User::find_by_mail(email, conn).await { Some(user) => user, None => { @@ -95,6 +93,8 @@ impl EmergencyAccess { return None; } } + } else { + return None; }; Some(json!({ @@ -105,7 +105,6 @@ impl EmergencyAccess { "granteeId": grantee_user.uuid, "email": grantee_user.email, "name": grantee_user.name, - "avatarColor": grantee_user.avatar_color, "object": "emergencyAccessGranteeDetails", })) } @@ -138,14 +137,14 @@ pub enum EmergencyAccessStatus { // region Database methods impl EmergencyAccess { - pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { + pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { User::update_uuid_revision(&self.grantor_uuid, conn).await; self.updated_at = Utc::now().naive_utc(); db_run! { conn: sqlite, mysql { match diesel::replace_into(emergency_access::table) - .values(&*self) + .values(EmergencyAccessDb::to_db(self)) .execute(conn) { Ok(_) => Ok(()), @@ -153,7 +152,7 @@ impl EmergencyAccess { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(emergency_access::table) .filter(emergency_access::uuid.eq(&self.uuid)) - .set(&*self) + .set(EmergencyAccessDb::to_db(self)) .execute(conn) .map_res("Error updating emergency access") } @@ -161,11 +160,12 @@ impl EmergencyAccess { }.map_res("Error saving emergency access") } postgresql { + let value = EmergencyAccessDb::to_db(self); diesel::insert_into(emergency_access::table) - .values(&*self) + .values(&value) .on_conflict(emergency_access::uuid) .do_update() - .set(&*self) + .set(&value) .execute(conn) .map_res("Error saving emergency access") } @@ -176,14 +176,14 @@ impl EmergencyAccess { &mut self, status: i32, date: &NaiveDateTime, - conn: &DbConn, + conn: &mut DbConn, ) -> EmptyResult { // 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; self.status = status; date.clone_into(&mut self.updated_at); - db_run! { conn: { + db_run! {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))) @@ -193,11 +193,15 @@ impl EmergencyAccess { }} } - pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { + pub async fn update_last_notification_date_and_save( + &mut self, + date: &NaiveDateTime, + conn: &mut DbConn, + ) -> EmptyResult { self.last_notification_at = Some(date.to_owned()); date.clone_into(&mut self.updated_at); - db_run! { conn: { + db_run! {conn: { crate::util::retry(|| { diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid))) .set((emergency_access::last_notification_at.eq(date), emergency_access::updated_at.eq(date))) @@ -207,7 +211,7 @@ impl EmergencyAccess { }} } - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult { for ea in Self::find_all_by_grantor_uuid(user_uuid, conn).await { ea.delete(conn).await?; } @@ -217,14 +221,14 @@ impl EmergencyAccess { Ok(()) } - pub async fn delete_all_by_grantee_email(grantee_email: &str, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_grantee_email(grantee_email: &str, conn: &mut DbConn) -> EmptyResult { for ea in Self::find_all_invited_by_grantee_email(grantee_email, conn).await { ea.delete(conn).await?; } Ok(()) } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { User::update_uuid_revision(&self.grantor_uuid, conn).await; db_run! { conn: { @@ -235,121 +239,95 @@ impl EmergencyAccess { } pub async fn find_by_grantor_uuid_and_grantee_uuid_or_email( - grantor_uuid: &UserId, - grantee_uuid: &UserId, + grantor_uuid: &str, + grantee_uuid: &str, email: &str, - conn: &DbConn, + conn: &mut DbConn, ) -> Option { db_run! { conn: { emergency_access::table .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) .filter(emergency_access::grantee_uuid.eq(grantee_uuid).or(emergency_access::email.eq(email))) - .first::(conn) - .ok() + .first::(conn) + .ok().from_db() }} } - pub async fn find_all_recoveries_initiated(conn: &DbConn) -> Vec { + pub async fn find_all_recoveries_initiated(conn: &mut DbConn) -> Vec { db_run! { conn: { emergency_access::table .filter(emergency_access::status.eq(EmergencyAccessStatus::RecoveryInitiated as i32)) .filter(emergency_access::recovery_initiated_at.is_not_null()) - .load::(conn) - .expect("Error loading emergency_access") + .load::(conn).expect("Error loading emergency_access").from_db() }} } - pub async fn find_by_uuid_and_grantor_uuid( - uuid: &EmergencyAccessId, - grantor_uuid: &UserId, - conn: &DbConn, - ) -> Option { + pub async fn find_by_uuid_and_grantor_uuid(uuid: &str, grantor_uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { emergency_access::table .filter(emergency_access::uuid.eq(uuid)) .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) - .first::(conn) - .ok() + .first::(conn) + .ok().from_db() }} } - pub async fn find_by_uuid_and_grantee_uuid( - uuid: &EmergencyAccessId, - grantee_uuid: &UserId, - conn: &DbConn, - ) -> Option { + pub async fn find_by_uuid_and_grantee_uuid(uuid: &str, grantee_uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { emergency_access::table .filter(emergency_access::uuid.eq(uuid)) .filter(emergency_access::grantee_uuid.eq(grantee_uuid)) - .first::(conn) - .ok() + .first::(conn) + .ok().from_db() }} } - pub async fn find_by_uuid_and_grantee_email( - uuid: &EmergencyAccessId, - grantee_email: &str, - conn: &DbConn, - ) -> Option { + pub async fn find_by_uuid_and_grantee_email(uuid: &str, grantee_email: &str, conn: &mut DbConn) -> Option { db_run! { conn: { emergency_access::table .filter(emergency_access::uuid.eq(uuid)) .filter(emergency_access::email.eq(grantee_email)) - .first::(conn) - .ok() + .first::(conn) + .ok().from_db() }} } - pub async fn find_all_by_grantee_uuid(grantee_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn find_all_by_grantee_uuid(grantee_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { emergency_access::table .filter(emergency_access::grantee_uuid.eq(grantee_uuid)) - .load::(conn) - .expect("Error loading emergency_access") + .load::(conn).expect("Error loading emergency_access").from_db() }} } - pub async fn find_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Option { + pub async fn find_invited_by_grantee_email(grantee_email: &str, conn: &mut DbConn) -> Option { db_run! { conn: { emergency_access::table .filter(emergency_access::email.eq(grantee_email)) .filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32)) - .first::(conn) - .ok() + .first::(conn) + .ok().from_db() }} } - pub async fn find_all_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Vec { + pub async fn find_all_invited_by_grantee_email(grantee_email: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { emergency_access::table .filter(emergency_access::email.eq(grantee_email)) .filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32)) - .load::(conn) - .expect("Error loading emergency_access") + .load::(conn).expect("Error loading emergency_access").from_db() }} } - pub async fn find_all_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn find_all_by_grantor_uuid(grantor_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { emergency_access::table .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) - .load::(conn) - .expect("Error loading emergency_access") + .load::(conn).expect("Error loading emergency_access").from_db() }} } - pub async fn find_all_confirmed_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec { - db_run! { conn: { - emergency_access::table - .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) - .filter(emergency_access::status.ge(EmergencyAccessStatus::Confirmed as i32)) - .load::(conn) - .expect("Error loading emergency_access") - }} - } - - pub async fn accept_invite(&mut self, grantee_uuid: &UserId, grantee_email: &str, conn: &DbConn) -> EmptyResult { + pub async fn accept_invite(&mut self, grantee_uuid: &str, grantee_email: &str, conn: &mut DbConn) -> EmptyResult { if self.email.is_none() || self.email.as_ref().unwrap() != grantee_email { err!("User email does not match invite."); } @@ -359,28 +337,10 @@ impl EmergencyAccess { } self.status = EmergencyAccessStatus::Accepted as i32; - self.grantee_uuid = Some(grantee_uuid.clone()); + self.grantee_uuid = Some(String::from(grantee_uuid)); self.email = None; self.save(conn).await } } // endregion - -#[derive( - Clone, - Debug, - AsRef, - Deref, - DieselNewType, - Display, - From, - FromForm, - Hash, - PartialEq, - Eq, - Serialize, - Deserialize, - UuidFromParam, -)] -pub struct EmergencyAccessId(String); diff --git a/src/db/models/event.rs b/src/db/models/event.rs index bd4b2310..22d8fb00 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -1,42 +1,41 @@ -use chrono::{NaiveDateTime, TimeDelta, Utc}; -//use derive_more::{AsRef, Deref, Display, From}; +use crate::db::DbConn; use serde_json::Value; -use super::{CipherId, CollectionId, GroupId, MembershipId, OrgPolicyId, OrganizationId, UserId}; -use crate::db::schema::{event, users_organizations}; -use crate::{api::EmptyResult, db::DbConn, error::MapResult, CONFIG}; -use diesel::prelude::*; +use crate::{api::EmptyResult, error::MapResult, CONFIG}; + +use chrono::{NaiveDateTime, TimeDelta, Utc}; // https://bitwarden.com/help/event-logs/ -// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs -// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs -// Upstream SQL: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Sql/dbo/Tables/Event.sql -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = event)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid))] -pub struct Event { - pub uuid: EventId, - pub event_type: i32, // EventType - pub user_uuid: Option, - pub org_uuid: Option, - pub cipher_uuid: Option, - pub collection_uuid: Option, - pub group_uuid: Option, - pub org_user_uuid: Option, - pub act_user_uuid: Option, - // Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs - pub device_type: Option, - pub ip_address: Option, - pub event_date: NaiveDateTime, - pub policy_uuid: Option, - pub provider_uuid: Option, - pub provider_user_uuid: Option, - pub provider_org_uuid: Option, +db_object! { + // Upstream: https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Core/Services/Implementations/EventService.cs + // Upstream: https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Api/Models/Public/Response/EventResponseModel.cs + // Upstream SQL: https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Sql/dbo/Tables/Event.sql + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = event)] + #[diesel(primary_key(uuid))] + pub struct Event { + pub uuid: String, + pub event_type: i32, // EventType + pub user_uuid: Option, + pub org_uuid: Option, + pub cipher_uuid: Option, + pub collection_uuid: Option, + pub group_uuid: Option, + pub org_user_uuid: Option, + pub act_user_uuid: Option, + // Upstream enum: https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Core/Enums/DeviceType.cs + pub device_type: Option, + pub ip_address: Option, + pub event_date: NaiveDateTime, + pub policy_uuid: Option, + pub provider_uuid: Option, + pub provider_user_uuid: Option, + pub provider_org_uuid: Option, + } } -// Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/EventType.cs +// Upstream enum: https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Core/Enums/EventType.cs #[derive(Debug, Copy, Clone)] pub enum EventType { // User @@ -50,8 +49,6 @@ pub enum EventType { UserClientExportedVault = 1007, // UserUpdatedTempPassword = 1008, // Not supported // UserMigratedKeyToKeyConnector = 1009, // Not supported - UserRequestedDeviceApproval = 1010, - // UserTdeOffboardingPasswordSet = 1011, // Not supported // Cipher CipherCreated = 1100, @@ -87,9 +84,9 @@ pub enum EventType { OrganizationUserInvited = 1500, OrganizationUserConfirmed = 1501, OrganizationUserUpdated = 1502, - OrganizationUserRemoved = 1503, // Organization user data was deleted + OrganizationUserRemoved = 1503, OrganizationUserUpdatedGroups = 1504, - OrganizationUserUnlinkedSso = 1505, + // OrganizationUserUnlinkedSso = 1505, // Not supported OrganizationUserResetPasswordEnroll = 1506, OrganizationUserResetPasswordWithdraw = 1507, OrganizationUserAdminResetPassword = 1508, @@ -97,10 +94,6 @@ pub enum EventType { // OrganizationUserFirstSsoLogin = 1510, // Not supported OrganizationUserRevoked = 1511, OrganizationUserRestored = 1512, - OrganizationUserApprovedAuthRequest = 1513, - OrganizationUserRejectedAuthRequest = 1514, - OrganizationUserDeleted = 1515, // Both user and organization user data were deleted - OrganizationUserLeft = 1516, // User voluntarily left the organization // Organization OrganizationUpdated = 1600, @@ -112,7 +105,6 @@ pub enum EventType { // OrganizationEnabledKeyConnector = 1606, // Not supported // OrganizationDisabledKeyConnector = 1607, // Not supported // OrganizationSponsorshipsSynced = 1608, // Not supported - // OrganizationCollectionManagementUpdated = 1609, // Not supported // Policy PolicyUpdated = 1700, @@ -125,13 +117,6 @@ pub enum EventType { // ProviderOrganizationAdded = 1901, // Not supported // ProviderOrganizationRemoved = 1902, // Not supported // ProviderOrganizationVaultAccessed = 1903, // Not supported - - // OrganizationDomainAdded = 2000, // Not supported - // OrganizationDomainRemoved = 2001, // Not supported - // OrganizationDomainVerified = 2002, // Not supported - // OrganizationDomainNotVerified = 2003, // Not supported - - // SecretRetrieved = 2100, // Not supported } /// Local methods @@ -143,7 +128,7 @@ impl Event { }; Self { - uuid: EventId(crate::util::get_uuid()), + uuid: crate::util::get_uuid(), event_type, user_uuid: None, org_uuid: None, @@ -187,33 +172,33 @@ impl Event { } /// Database methods -/// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs +/// https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Core/Services/Implementations/EventService.cs impl Event { pub const PAGE_SIZE: i64 = 30; /// ############# /// Basic Queries - pub async fn save(&self, conn: &DbConn) -> EmptyResult { + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { diesel::replace_into(event::table) - .values(self) + .values(EventDb::to_db(self)) .execute(conn) .map_res("Error saving event") } postgresql { diesel::insert_into(event::table) - .values(self) + .values(EventDb::to_db(self)) .on_conflict(event::uuid) .do_update() - .set(self) + .set(EventDb::to_db(self)) .execute(conn) .map_res("Error saving event") } } } - pub async fn save_user_event(events: Vec, conn: &DbConn) -> EmptyResult { + pub async fn save_user_event(events: Vec, conn: &mut DbConn) -> EmptyResult { // Special save function which is able to handle multiple events. // SQLite doesn't support the DEFAULT argument, and does not support inserting multiple values at the same time. // MySQL and PostgreSQL do. @@ -224,13 +209,14 @@ impl Event { sqlite { for event in events { diesel::insert_or_ignore_into(event::table) - .values(&event) + .values(EventDb::to_db(&event)) .execute(conn) .unwrap_or_default(); } Ok(()) } mysql { + let events: Vec = events.iter().map(EventDb::to_db).collect(); diesel::insert_or_ignore_into(event::table) .values(&events) .execute(conn) @@ -238,6 +224,7 @@ impl Event { Ok(()) } postgresql { + let events: Vec = events.iter().map(EventDb::to_db).collect(); diesel::insert_into(event::table) .values(&events) .on_conflict_do_nothing() @@ -248,7 +235,7 @@ impl Event { } } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(event::table.filter(event::uuid.eq(self.uuid))) .execute(conn) @@ -259,10 +246,10 @@ impl Event { /// ############## /// Custom Queries pub async fn find_by_organization_uuid( - org_uuid: &OrganizationId, + org_uuid: &str, start: &NaiveDateTime, end: &NaiveDateTime, - conn: &DbConn, + conn: &mut DbConn, ) -> Vec { db_run! { conn: { event::table @@ -270,12 +257,13 @@ impl Event { .filter(event::event_date.between(start, end)) .order_by(event::event_date.desc()) .limit(Self::PAGE_SIZE) - .load::(conn) + .load::(conn) .expect("Error filtering events") + .from_db() }} } - pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { + pub async fn count_by_org(org_uuid: &str, conn: &mut DbConn) -> i64 { db_run! { conn: { event::table .filter(event::org_uuid.eq(org_uuid)) @@ -286,32 +274,33 @@ impl Event { }} } - pub async fn find_by_org_and_member( - org_uuid: &OrganizationId, - member_uuid: &MembershipId, + pub async fn find_by_org_and_user_org( + org_uuid: &str, + user_org_uuid: &str, start: &NaiveDateTime, end: &NaiveDateTime, - conn: &DbConn, + conn: &mut DbConn, ) -> Vec { db_run! { conn: { event::table - .inner_join(users_organizations::table.on(users_organizations::uuid.eq(member_uuid))) + .inner_join(users_organizations::table.on(users_organizations::uuid.eq(user_org_uuid))) .filter(event::org_uuid.eq(org_uuid)) .filter(event::event_date.between(start, end)) .filter(event::user_uuid.eq(users_organizations::user_uuid.nullable()).or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable()))) .select(event::all_columns) .order_by(event::event_date.desc()) .limit(Self::PAGE_SIZE) - .load::(conn) + .load::(conn) .expect("Error filtering events") + .from_db() }} } pub async fn find_by_cipher_uuid( - cipher_uuid: &CipherId, + cipher_uuid: &str, start: &NaiveDateTime, end: &NaiveDateTime, - conn: &DbConn, + conn: &mut DbConn, ) -> Vec { db_run! { conn: { event::table @@ -319,12 +308,13 @@ impl Event { .filter(event::event_date.between(start, end)) .order_by(event::event_date.desc()) .limit(Self::PAGE_SIZE) - .load::(conn) + .load::(conn) .expect("Error filtering events") + .from_db() }} } - pub async fn clean_events(conn: &DbConn) -> EmptyResult { + pub async fn clean_events(conn: &mut DbConn) -> EmptyResult { if let Some(days_to_retain) = CONFIG.events_days_retain() { let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap(); db_run! { conn: { @@ -337,6 +327,3 @@ impl Event { } } } - -#[derive(Clone, Debug, DieselNewType, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub struct EventId(String); diff --git a/src/db/models/favorite.rs b/src/db/models/favorite.rs index d7aa74bb..a301f597 100644 --- a/src/db/models/favorite.rs +++ b/src/db/models/favorite.rs @@ -1,13 +1,13 @@ -use super::{CipherId, User, UserId}; -use crate::db::schema::favorites; -use diesel::prelude::*; +use super::User; -#[derive(Identifiable, Queryable, Insertable)] -#[diesel(table_name = favorites)] -#[diesel(primary_key(user_uuid, cipher_uuid))] -pub struct Favorite { - pub user_uuid: UserId, - pub cipher_uuid: CipherId, +db_object! { + #[derive(Identifiable, Queryable, Insertable)] + #[diesel(table_name = favorites)] + #[diesel(primary_key(user_uuid, cipher_uuid))] + pub struct Favorite { + pub user_uuid: String, + pub cipher_uuid: String, + } } use crate::db::DbConn; @@ -17,26 +17,19 @@ use crate::error::MapResult; impl Favorite { // Returns whether the specified cipher is a favorite of the specified user. - pub async fn is_favorite(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> bool { + pub async fn is_favorite(cipher_uuid: &str, user_uuid: &str, conn: &mut DbConn) -> bool { db_run! { conn: { let query = favorites::table .filter(favorites::cipher_uuid.eq(cipher_uuid)) .filter(favorites::user_uuid.eq(user_uuid)) .count(); - query.first::(conn) - .ok() - .unwrap_or(0) != 0 + query.first::(conn).ok().unwrap_or(0) != 0 }} } // Sets whether the specified cipher is a favorite of the specified user. - pub async fn set_favorite( - favorite: bool, - cipher_uuid: &CipherId, - user_uuid: &UserId, - conn: &DbConn, - ) -> EmptyResult { + pub async fn set_favorite(favorite: bool, cipher_uuid: &str, user_uuid: &str, conn: &mut DbConn) -> EmptyResult { let (old, new) = (Self::is_favorite(cipher_uuid, user_uuid, conn).await, favorite); match (old, new) { (false, true) => { @@ -69,7 +62,7 @@ impl Favorite { } // Delete all favorite entries associated with the specified cipher. - pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_cipher(cipher_uuid: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(favorites::table.filter(favorites::cipher_uuid.eq(cipher_uuid))) .execute(conn) @@ -78,7 +71,7 @@ impl Favorite { } // Delete all favorite entries associated with the specified user. - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(favorites::table.filter(favorites::user_uuid.eq(user_uuid))) .execute(conn) @@ -88,12 +81,12 @@ impl Favorite { /// Return a vec with (cipher_uuid) this will only contain favorite flagged ciphers /// This is used during a full sync so we only need one query for all favorite cipher matches. - pub async fn get_all_cipher_uuid_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn get_all_cipher_uuid_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { favorites::table .filter(favorites::user_uuid.eq(user_uuid)) .select(favorites::cipher_uuid) - .load::(conn) + .load::(conn) .unwrap_or_default() }} } diff --git a/src/db/models/folder.rs b/src/db/models/folder.rs index b4cbc7ff..5370c9dd 100644 --- a/src/db/models/folder.rs +++ b/src/db/models/folder.rs @@ -1,38 +1,36 @@ use chrono::{NaiveDateTime, Utc}; -use derive_more::{AsRef, Deref, Display, From}; use serde_json::Value; -use super::{CipherId, User, UserId}; -use crate::db::schema::{folders, folders_ciphers}; -use diesel::prelude::*; -use macros::UuidFromParam; +use super::User; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = folders)] -#[diesel(primary_key(uuid))] -pub struct Folder { - pub uuid: FolderId, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, - pub user_uuid: UserId, - pub name: String, -} +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = folders)] + #[diesel(primary_key(uuid))] + pub struct Folder { + pub uuid: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub user_uuid: String, + pub name: String, + } -#[derive(Identifiable, Queryable, Insertable)] -#[diesel(table_name = folders_ciphers)] -#[diesel(primary_key(cipher_uuid, folder_uuid))] -pub struct FolderCipher { - pub cipher_uuid: CipherId, - pub folder_uuid: FolderId, + #[derive(Identifiable, Queryable, Insertable)] + #[diesel(table_name = folders_ciphers)] + #[diesel(primary_key(cipher_uuid, folder_uuid))] + pub struct FolderCipher { + pub cipher_uuid: String, + pub folder_uuid: String, + } } /// Local methods impl Folder { - pub fn new(user_uuid: UserId, name: String) -> Self { + pub fn new(user_uuid: String, name: String) -> Self { let now = Utc::now().naive_utc(); Self { - uuid: FolderId(crate::util::get_uuid()), + uuid: crate::util::get_uuid(), created_at: now, updated_at: now, @@ -54,10 +52,10 @@ impl Folder { } impl FolderCipher { - pub fn new(folder_uuid: FolderId, cipher_uuid: CipherId) -> Self { + pub fn new(folder_uuid: &str, cipher_uuid: &str) -> Self { Self { - folder_uuid, - cipher_uuid, + folder_uuid: folder_uuid.to_string(), + cipher_uuid: cipher_uuid.to_string(), } } } @@ -69,14 +67,14 @@ use crate::error::MapResult; /// Database methods impl Folder { - pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { + pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { User::update_uuid_revision(&self.user_uuid, conn).await; self.updated_at = Utc::now().naive_utc(); db_run! { conn: sqlite, mysql { match diesel::replace_into(folders::table) - .values(&*self) + .values(FolderDb::to_db(self)) .execute(conn) { Ok(_) => Ok(()), @@ -84,7 +82,7 @@ impl Folder { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(folders::table) .filter(folders::uuid.eq(&self.uuid)) - .set(&*self) + .set(FolderDb::to_db(self)) .execute(conn) .map_res("Error saving folder") } @@ -92,18 +90,19 @@ impl Folder { }.map_res("Error saving folder") } postgresql { + let value = FolderDb::to_db(self); diesel::insert_into(folders::table) - .values(&*self) + .values(&value) .on_conflict(folders::uuid) .do_update() - .set(&*self) + .set(&value) .execute(conn) .map_res("Error saving folder") } } } - pub async fn delete(&self, conn: &DbConn) -> EmptyResult { + pub async fn delete(&self, conn: &mut DbConn) -> EmptyResult { User::update_uuid_revision(&self.user_uuid, conn).await; FolderCipher::delete_all_by_folder(&self.uuid, conn).await?; @@ -114,48 +113,49 @@ impl Folder { }} } - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult { for folder in Self::find_by_user(user_uuid, conn).await { folder.delete(conn).await?; } Ok(()) } - pub async fn find_by_uuid_and_user(uuid: &FolderId, user_uuid: &UserId, conn: &DbConn) -> Option { + pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { folders::table .filter(folders::uuid.eq(uuid)) - .filter(folders::user_uuid.eq(user_uuid)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { folders::table .filter(folders::user_uuid.eq(user_uuid)) - .load::(conn) + .load::(conn) .expect("Error loading folders") + .from_db() }} } } impl FolderCipher { - pub async fn save(&self, conn: &DbConn) -> EmptyResult { + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { // Not checking for ForeignKey Constraints here. // Table folders_ciphers does not have ForeignKey Constraints which would cause conflicts. // This table has no constraints pointing to itself, but only to others. diesel::replace_into(folders_ciphers::table) - .values(self) + .values(FolderCipherDb::to_db(self)) .execute(conn) .map_res("Error adding cipher to folder") } postgresql { diesel::insert_into(folders_ciphers::table) - .values(self) + .values(FolderCipherDb::to_db(self)) .on_conflict((folders_ciphers::cipher_uuid, folders_ciphers::folder_uuid)) .do_nothing() .execute(conn) @@ -164,7 +164,7 @@ impl FolderCipher { } } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete( folders_ciphers::table @@ -176,7 +176,7 @@ impl FolderCipher { }} } - pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_cipher(cipher_uuid: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(folders_ciphers::table.filter(folders_ciphers::cipher_uuid.eq(cipher_uuid))) .execute(conn) @@ -184,7 +184,7 @@ impl FolderCipher { }} } - pub async fn delete_all_by_folder(folder_uuid: &FolderId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_folder(folder_uuid: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(folders_ciphers::table.filter(folders_ciphers::folder_uuid.eq(folder_uuid))) .execute(conn) @@ -192,57 +192,37 @@ impl FolderCipher { }} } - pub async fn find_by_folder_and_cipher( - folder_uuid: &FolderId, - cipher_uuid: &CipherId, - conn: &DbConn, - ) -> Option { + pub async fn find_by_folder_and_cipher(folder_uuid: &str, cipher_uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { folders_ciphers::table .filter(folders_ciphers::folder_uuid.eq(folder_uuid)) .filter(folders_ciphers::cipher_uuid.eq(cipher_uuid)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_by_folder(folder_uuid: &FolderId, conn: &DbConn) -> Vec { + pub async fn find_by_folder(folder_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { folders_ciphers::table .filter(folders_ciphers::folder_uuid.eq(folder_uuid)) - .load::(conn) + .load::(conn) .expect("Error loading folders") + .from_db() }} } /// Return a vec with (cipher_uuid, folder_uuid) /// This is used during a full sync so we only need one query for all folder matches. - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, FolderId)> { + pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<(String, String)> { db_run! { conn: { folders_ciphers::table .inner_join(folders::table) .filter(folders::user_uuid.eq(user_uuid)) .select(folders_ciphers::all_columns) - .load::<(CipherId, FolderId)>(conn) + .load::<(String, String)>(conn) .unwrap_or_default() }} } } - -#[derive( - Clone, - Debug, - AsRef, - Deref, - DieselNewType, - Display, - From, - FromForm, - Hash, - PartialEq, - Eq, - Serialize, - Deserialize, - UuidFromParam, -)] -pub struct FolderId(String); diff --git a/src/db/models/group.rs b/src/db/models/group.rs index f41ad9ca..66ad338a 100644 --- a/src/db/models/group.rs +++ b/src/db/models/group.rs @@ -1,59 +1,50 @@ -use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId}; +use super::{User, UserOrgType, UserOrganization}; use crate::api::EmptyResult; -use crate::db::schema::{collections, collections_groups, groups, groups_users, users_organizations}; use crate::db::DbConn; use crate::error::MapResult; use chrono::{NaiveDateTime, Utc}; -use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; -use macros::UuidFromParam; use serde_json::Value; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = groups)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid))] -pub struct Group { - pub uuid: GroupId, - pub organizations_uuid: OrganizationId, - pub name: String, - pub access_all: bool, - pub external_id: Option, - pub creation_date: NaiveDateTime, - pub revision_date: NaiveDateTime, -} +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = groups)] + #[diesel(primary_key(uuid))] + pub struct Group { + pub uuid: String, + pub organizations_uuid: String, + pub name: String, + pub access_all: bool, + pub external_id: Option, + pub creation_date: NaiveDateTime, + pub revision_date: NaiveDateTime, + } -#[derive(Identifiable, Queryable, Insertable)] -#[diesel(table_name = collections_groups)] -#[diesel(primary_key(collections_uuid, groups_uuid))] -pub struct CollectionGroup { - pub collections_uuid: CollectionId, - pub groups_uuid: GroupId, - pub read_only: bool, - pub hide_passwords: bool, - pub manage: bool, -} + #[derive(Identifiable, Queryable, Insertable)] + #[diesel(table_name = collections_groups)] + #[diesel(primary_key(collections_uuid, groups_uuid))] + pub struct CollectionGroup { + pub collections_uuid: String, + pub groups_uuid: String, + pub read_only: bool, + pub hide_passwords: bool, + } -#[derive(Identifiable, Queryable, Insertable)] -#[diesel(table_name = groups_users)] -#[diesel(primary_key(groups_uuid, users_organizations_uuid))] -pub struct GroupUser { - pub groups_uuid: GroupId, - pub users_organizations_uuid: MembershipId, + #[derive(Identifiable, Queryable, Insertable)] + #[diesel(table_name = groups_users)] + #[diesel(primary_key(groups_uuid, users_organizations_uuid))] + pub struct GroupUser { + pub groups_uuid: String, + pub users_organizations_uuid: String + } } /// Local methods impl Group { - pub fn new( - organizations_uuid: OrganizationId, - name: String, - access_all: bool, - external_id: Option, - ) -> Self { + pub fn new(organizations_uuid: String, name: String, access_all: bool, external_id: Option) -> Self { let now = Utc::now().naive_utc(); let mut new_model = Self { - uuid: GroupId(crate::util::get_uuid()), + uuid: crate::util::get_uuid(), organizations_uuid, name, access_all, @@ -68,20 +59,22 @@ impl Group { } pub fn to_json(&self) -> Value { + use crate::util::format_date; + json!({ "id": self.uuid, "organizationId": self.organizations_uuid, "name": self.name, + "accessAll": self.access_all, "externalId": self.external_id, + "creationDate": format_date(&self.creation_date), + "revisionDate": format_date(&self.revision_date), "object": "group" }) } - pub async fn to_json_details(&self, conn: &DbConn) -> Value { - // 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 - let collections_groups: Vec = CollectionGroup::find_by_group(&self.uuid, &self.organizations_uuid, conn) + pub async fn to_json_details(&self, user_org_type: &i32, conn: &mut DbConn) -> Value { + let collections_groups: Vec = CollectionGroup::find_by_group(&self.uuid, conn) .await .iter() .map(|entry| { @@ -89,7 +82,7 @@ impl Group { "id": entry.collections_uuid, "readOnly": entry.read_only, "hidePasswords": entry.hide_passwords, - "manage": entry.manage, + "manage": *user_org_type >= UserOrgType::Admin || (*user_org_type == UserOrgType::Manager && !entry.read_only && !entry.hide_passwords) }) }) .collect(); @@ -115,38 +108,18 @@ impl Group { } impl CollectionGroup { - pub fn new( - collections_uuid: CollectionId, - groups_uuid: GroupId, - read_only: bool, - hide_passwords: bool, - manage: bool, - ) -> Self { + pub fn new(collections_uuid: String, groups_uuid: String, read_only: bool, hide_passwords: bool) -> Self { Self { collections_uuid, groups_uuid, read_only, hide_passwords, - manage, } } - - pub fn to_json_details_for_group(&self) -> Value { - // 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 || (!self.read_only && !self.hide_passwords), - }) - } } impl GroupUser { - pub fn new(groups_uuid: GroupId, users_organizations_uuid: MembershipId) -> Self { + pub fn new(groups_uuid: String, users_organizations_uuid: String) -> Self { Self { groups_uuid, users_organizations_uuid, @@ -156,13 +129,13 @@ impl GroupUser { /// Database methods impl Group { - pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { + pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { self.revision_date = Utc::now().naive_utc(); db_run! { conn: sqlite, mysql { match diesel::replace_into(groups::table) - .values(&*self) + .values(GroupDb::to_db(self)) .execute(conn) { Ok(_) => Ok(()), @@ -170,7 +143,7 @@ impl Group { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(groups::table) .filter(groups::uuid.eq(&self.uuid)) - .set(&*self) + .set(GroupDb::to_db(self)) .execute(conn) .map_res("Error saving group") } @@ -178,37 +151,39 @@ impl Group { }.map_res("Error saving group") } postgresql { + let value = GroupDb::to_db(self); diesel::insert_into(groups::table) - .values(&*self) + .values(&value) .on_conflict(groups::uuid) .do_update() - .set(&*self) + .set(&value) .execute(conn) .map_res("Error saving group") } } } - pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_organization(org_uuid: &str, conn: &mut DbConn) -> EmptyResult { for group in Self::find_by_organization(org_uuid, conn).await { - group.delete(org_uuid, conn).await?; + group.delete(conn).await?; } Ok(()) } - pub async fn find_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { + pub async fn find_by_organization(organizations_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { groups::table - .filter(groups::organizations_uuid.eq(org_uuid)) - .load::(conn) + .filter(groups::organizations_uuid.eq(organizations_uuid)) + .load::(conn) .expect("Error loading groups") + .from_db() }} } - pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { + pub async fn count_by_org(organizations_uuid: &str, conn: &mut DbConn) -> i64 { db_run! { conn: { groups::table - .filter(groups::organizations_uuid.eq(org_uuid)) + .filter(groups::organizations_uuid.eq(organizations_uuid)) .count() .first::(conn) .ok() @@ -216,49 +191,46 @@ impl Group { }} } - pub async fn find_by_uuid_and_org(uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Option { + pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { groups::table .filter(groups::uuid.eq(uuid)) - .filter(groups::organizations_uuid.eq(org_uuid)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_by_external_id_and_org( - external_id: &str, - org_uuid: &OrganizationId, - conn: &DbConn, - ) -> Option { + pub async fn find_by_external_id_and_org(external_id: &str, org_uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { groups::table .filter(groups::external_id.eq(external_id)) .filter(groups::organizations_uuid.eq(org_uuid)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } //Returns all organizations the user has full access to - pub async fn get_orgs_by_user_with_full_access(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn gather_user_organizations_full_access(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { groups_users::table .inner_join(users_organizations::table.on( users_organizations::uuid.eq(groups_users::users_organizations_uuid) )) - .inner_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) + .inner_join(groups::table.on( + groups::uuid.eq(groups_users::groups_uuid) )) .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(groups::access_all.eq(true)) .select(groups::organizations_uuid) .distinct() - .load::(conn) + .load::(conn) .expect("Error loading organization group full access information for user") }} } - pub async fn is_in_full_access_group(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> bool { + pub async fn is_in_full_access_group(user_uuid: &str, org_uuid: &str, conn: &mut DbConn) -> bool { db_run! { conn: { groups::table .inner_join(groups_users::table.on( @@ -276,9 +248,9 @@ impl Group { }} } - pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { - CollectionGroup::delete_all_by_group(&self.uuid, org_uuid, conn).await?; - GroupUser::delete_all_by_group(&self.uuid, org_uuid, conn).await?; + pub async fn delete(&self, conn: &mut DbConn) -> EmptyResult { + CollectionGroup::delete_all_by_group(&self.uuid, conn).await?; + GroupUser::delete_all_by_group(&self.uuid, conn).await?; db_run! { conn: { diesel::delete(groups::table.filter(groups::uuid.eq(&self.uuid))) @@ -287,14 +259,14 @@ impl Group { }} } - pub async fn update_revision(uuid: &GroupId, conn: &DbConn) { + pub async fn update_revision(uuid: &str, conn: &mut DbConn) { if let Err(e) = Self::_update_revision(uuid, &Utc::now().naive_utc(), conn).await { - warn!("Failed to update revision for {uuid}: {e:#?}"); + warn!("Failed to update revision for {}: {:#?}", uuid, e); } } - async fn _update_revision(uuid: &GroupId, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { - db_run! { conn: { + async fn _update_revision(uuid: &str, date: &NaiveDateTime, conn: &mut DbConn) -> EmptyResult { + db_run! {conn: { crate::util::retry(|| { diesel::update(groups::table.filter(groups::uuid.eq(uuid))) .set(groups::revision_date.eq(date)) @@ -306,8 +278,8 @@ impl Group { } impl CollectionGroup { - pub async fn save(&mut self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { - let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await; + pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { + let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await; for group_user in group_users { group_user.update_user_revision(conn).await; } @@ -320,7 +292,6 @@ impl CollectionGroup { collections_groups::groups_uuid.eq(&self.groups_uuid), collections_groups::read_only.eq(&self.read_only), collections_groups::hide_passwords.eq(&self.hide_passwords), - collections_groups::manage.eq(&self.manage), )) .execute(conn) { @@ -335,7 +306,6 @@ impl CollectionGroup { collections_groups::groups_uuid.eq(&self.groups_uuid), collections_groups::read_only.eq(&self.read_only), collections_groups::hide_passwords.eq(&self.hide_passwords), - collections_groups::manage.eq(&self.manage), )) .execute(conn) .map_res("Error adding group to collection") @@ -350,14 +320,12 @@ impl CollectionGroup { collections_groups::groups_uuid.eq(&self.groups_uuid), collections_groups::read_only.eq(self.read_only), collections_groups::hide_passwords.eq(self.hide_passwords), - collections_groups::manage.eq(self.manage), )) .on_conflict((collections_groups::collections_uuid, collections_groups::groups_uuid)) .do_update() .set(( collections_groups::read_only.eq(self.read_only), collections_groups::hide_passwords.eq(self.hide_passwords), - collections_groups::manage.eq(self.manage), )) .execute(conn) .map_res("Error adding group to collection") @@ -365,25 +333,17 @@ impl CollectionGroup { } } - pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec { + pub async fn find_by_group(group_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { collections_groups::table - .inner_join(groups::table.on( - groups::uuid.eq(collections_groups::groups_uuid) - )) - .inner_join(collections::table.on( - collections::uuid.eq(collections_groups::collections_uuid) - .and(collections::org_uuid.eq(groups::organizations_uuid)) - )) .filter(collections_groups::groups_uuid.eq(group_uuid)) - .filter(collections::org_uuid.eq(org_uuid)) - .select(collections_groups::all_columns) - .load::(conn) + .load::(conn) .expect("Error loading collection groups") + .from_db() }} } - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { collections_groups::table .inner_join(groups_users::table.on( @@ -392,38 +352,27 @@ impl CollectionGroup { .inner_join(users_organizations::table.on( users_organizations::uuid.eq(groups_users::users_organizations_uuid) )) - .inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) - )) - .inner_join(collections::table.on( - collections::uuid.eq(collections_groups::collections_uuid) - .and(collections::org_uuid.eq(groups::organizations_uuid)) - )) .filter(users_organizations::user_uuid.eq(user_uuid)) .select(collections_groups::all_columns) - .load::(conn) + .load::(conn) .expect("Error loading user collection groups") + .from_db() }} } - pub async fn find_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> Vec { + pub async fn find_by_collection(collection_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { collections_groups::table .filter(collections_groups::collections_uuid.eq(collection_uuid)) - .inner_join(collections::table.on( - collections::uuid.eq(collections_groups::collections_uuid) - )) - .inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid) - .and(groups::organizations_uuid.eq(collections::org_uuid)) - )) .select(collections_groups::all_columns) - .load::(conn) + .load::(conn) .expect("Error loading collection groups") + .from_db() }} } - pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { - let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await; + pub async fn delete(&self, conn: &mut DbConn) -> EmptyResult { + let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await; for group_user in group_users { group_user.update_user_revision(conn).await; } @@ -437,8 +386,8 @@ impl CollectionGroup { }} } - 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; + pub async fn delete_all_by_group(group_uuid: &str, conn: &mut DbConn) -> EmptyResult { + let group_users = GroupUser::find_by_group(group_uuid, conn).await; for group_user in group_users { group_user.update_user_revision(conn).await; } @@ -451,14 +400,10 @@ impl CollectionGroup { }} } - pub async fn delete_all_by_collection( - collection_uuid: &CollectionId, - org_uuid: &OrganizationId, - conn: &DbConn, - ) -> EmptyResult { + pub async fn delete_all_by_collection(collection_uuid: &str, conn: &mut DbConn) -> EmptyResult { let collection_assigned_to_groups = CollectionGroup::find_by_collection(collection_uuid, conn).await; for collection_assigned_to_group in collection_assigned_to_groups { - let group_users = GroupUser::find_by_group(&collection_assigned_to_group.groups_uuid, org_uuid, conn).await; + let group_users = GroupUser::find_by_group(&collection_assigned_to_group.groups_uuid, conn).await; for group_user in group_users { group_user.update_user_revision(conn).await; } @@ -474,7 +419,7 @@ impl CollectionGroup { } impl GroupUser { - pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { + pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { self.update_user_revision(conn).await; db_run! { conn: @@ -520,50 +465,36 @@ impl GroupUser { } } - pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec { + pub async fn find_by_group(group_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { groups_users::table - .inner_join(groups::table.on( - groups::uuid.eq(groups_users::groups_uuid) - )) - .inner_join(users_organizations::table.on( - users_organizations::uuid.eq(groups_users::users_organizations_uuid) - .and(users_organizations::org_uuid.eq(groups::organizations_uuid)) - )) .filter(groups_users::groups_uuid.eq(group_uuid)) - .filter(groups::organizations_uuid.eq(org_uuid)) - .select(groups_users::all_columns) - .load::(conn) + .load::(conn) .expect("Error loading group users") + .from_db() }} } - pub async fn find_by_member(member_uuid: &MembershipId, conn: &DbConn) -> Vec { + pub async fn find_by_user(users_organizations_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { groups_users::table - .filter(groups_users::users_organizations_uuid.eq(member_uuid)) - .load::(conn) + .filter(groups_users::users_organizations_uuid.eq(users_organizations_uuid)) + .load::(conn) .expect("Error loading groups for user") + .from_db() }} } pub async fn has_access_to_collection_by_member( - collection_uuid: &CollectionId, - member_uuid: &MembershipId, - conn: &DbConn, + collection_uuid: &str, + member_uuid: &str, + conn: &mut DbConn, ) -> bool { db_run! { conn: { groups_users::table .inner_join(collections_groups::table.on( collections_groups::groups_uuid.eq(groups_users::groups_uuid) )) - .inner_join(groups::table.on( - groups::uuid.eq(groups_users::groups_uuid) - )) - .inner_join(collections::table.on( - collections::uuid.eq(collections_groups::collections_uuid) - .and(collections::org_uuid.eq(groups::organizations_uuid)) - )) .filter(collections_groups::collections_uuid.eq(collection_uuid)) .filter(groups_users::users_organizations_uuid.eq(member_uuid)) .count() @@ -572,11 +503,7 @@ impl GroupUser { }} } - pub async fn has_full_access_by_member( - org_uuid: &OrganizationId, - member_uuid: &MembershipId, - conn: &DbConn, - ) -> bool { + pub async fn has_full_access_by_member(org_uuid: &str, member_uuid: &str, conn: &mut DbConn) -> bool { db_run! { conn: { groups_users::table .inner_join(groups::table.on( @@ -591,34 +518,34 @@ impl GroupUser { }} } - pub async fn update_user_revision(&self, conn: &DbConn) { - match Membership::find_by_uuid(&self.users_organizations_uuid, conn).await { - Some(member) => User::update_uuid_revision(&member.user_uuid, conn).await, - None => warn!("Member could not be found!"), + pub async fn update_user_revision(&self, conn: &mut DbConn) { + match UserOrganization::find_by_uuid(&self.users_organizations_uuid, conn).await { + Some(user) => User::update_uuid_revision(&user.user_uuid, conn).await, + None => warn!("User could not be found!"), } } - pub async fn delete_by_group_and_member( - group_uuid: &GroupId, - member_uuid: &MembershipId, - conn: &DbConn, + pub async fn delete_by_group_id_and_user_id( + group_uuid: &str, + users_organizations_uuid: &str, + conn: &mut 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!"), + match UserOrganization::find_by_uuid(users_organizations_uuid, conn).await { + Some(user) => User::update_uuid_revision(&user.user_uuid, conn).await, + None => warn!("User could not be found!"), }; db_run! { conn: { diesel::delete(groups_users::table) .filter(groups_users::groups_uuid.eq(group_uuid)) - .filter(groups_users::users_organizations_uuid.eq(member_uuid)) + .filter(groups_users::users_organizations_uuid.eq(users_organizations_uuid)) .execute(conn) .map_res("Error deleting group users") }} } - 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; + pub async fn delete_all_by_group(group_uuid: &str, conn: &mut DbConn) -> EmptyResult { + let group_users = GroupUser::find_by_group(group_uuid, conn).await; for group_user in group_users { group_user.update_user_revision(conn).await; } @@ -631,35 +558,17 @@ impl GroupUser { }} } - pub async fn delete_all_by_member(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!"), + pub async fn delete_all_by_user(users_organizations_uuid: &str, conn: &mut DbConn) -> EmptyResult { + match UserOrganization::find_by_uuid(users_organizations_uuid, conn).await { + Some(user) => User::update_uuid_revision(&user.user_uuid, conn).await, + None => warn!("User could not be found!"), } db_run! { conn: { diesel::delete(groups_users::table) - .filter(groups_users::users_organizations_uuid.eq(member_uuid)) + .filter(groups_users::users_organizations_uuid.eq(users_organizations_uuid)) .execute(conn) .map_res("Error deleting user groups") }} } } - -#[derive( - Clone, - Debug, - AsRef, - Deref, - DieselNewType, - Display, - From, - FromForm, - Hash, - PartialEq, - Eq, - Serialize, - Deserialize, - UuidFromParam, -)] -pub struct GroupId(String); diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 2d31259c..c336cb1a 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -1,4 +1,3 @@ -mod archive; mod attachment; mod auth_request; mod cipher; @@ -12,34 +11,25 @@ mod group; mod org_policy; mod organization; mod send; -mod sso_auth; mod two_factor; mod two_factor_duo_context; mod two_factor_incomplete; mod user; -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}; -pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; -pub use self::device::{Device, DeviceId, DeviceType, PushId}; -pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType}; +pub use self::attachment::Attachment; +pub use self::auth_request::AuthRequest; +pub use self::cipher::Cipher; +pub use self::collection::{Collection, CollectionCipher, CollectionUser}; +pub use self::device::{Device, DeviceType}; +pub use self::emergency_access::{EmergencyAccess, 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::{OrgPolicy, OrgPolicyId, OrgPolicyType}; -pub use self::organization::{ - Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, - OrganizationId, -}; -pub use self::send::{ - id::{SendFileId, SendId}, - Send, SendType, -}; -pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth}; +pub use self::folder::{Folder, FolderCipher}; +pub use self::group::{CollectionGroup, Group, GroupUser}; +pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyType}; +pub use self::organization::{Organization, OrganizationApiKey, UserOrgStatus, UserOrgType, UserOrganization}; +pub use self::send::{Send, SendType}; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; -pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException}; +pub use self::user::{Invitation, User, UserKdfType, UserStampException}; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 7e922f35..23e583b4 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -1,29 +1,26 @@ -use derive_more::{AsRef, From}; use serde::Deserialize; use serde_json::Value; -use crate::api::core::two_factor; use crate::api::EmptyResult; -use crate::db::schema::{org_policies, users_organizations}; use crate::db::DbConn; use crate::error::MapResult; -use crate::CONFIG; -use diesel::prelude::*; -use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId}; +use super::{TwoFactor, UserOrgStatus, UserOrgType, UserOrganization}; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = org_policies)] -#[diesel(primary_key(uuid))] -pub struct OrgPolicy { - pub uuid: OrgPolicyId, - pub org_uuid: OrganizationId, - pub atype: i32, - pub enabled: bool, - pub data: String, +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = org_policies)] + #[diesel(primary_key(uuid))] + pub struct OrgPolicy { + pub uuid: String, + pub org_uuid: String, + pub atype: i32, + pub enabled: bool, + pub data: String, + } } -// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs +// https://github.com/bitwarden/server/blob/b86a04cef9f1e1b82cf18e49fc94e017c641130c/src/Core/Enums/PolicyType.cs #[derive(Copy, Clone, Eq, PartialEq, num_derive::FromPrimitive)] pub enum OrgPolicyType { TwoFactorAuthentication = 0, @@ -37,18 +34,9 @@ pub enum OrgPolicyType { ResetPassword = 8, // MaximumVaultTimeout = 9, // Not supported (Not AGPLv3 Licensed) // DisablePersonalVaultExport = 10, // Not supported (Not AGPLv3 Licensed) - // ActivateAutofill = 11, - // AutomaticAppLogIn = 12, - // FreeFamiliesSponsorshipPolicy = 13, - RemoveUnlockWithPin = 14, - RestrictedItemTypes = 15, - UriMatchDefaults = 16, - // AutotypeDefaultSetting = 17, // Not supported yet - // AutoConfirm = 18, // Not supported (not implemented yet) - // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) } -// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5 +// https://github.com/bitwarden/server/blob/5cbdee137921a19b1f722920f0fa3cd45af2ef0f/src/Core/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendOptionsPolicyData { @@ -56,7 +44,7 @@ pub struct SendOptionsPolicyData { pub disable_hide_email: bool, } -// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/ResetPasswordDataModel.cs +// https://github.com/bitwarden/server/blob/5cbdee137921a19b1f722920f0fa3cd45af2ef0f/src/Core/Models/Data/Organizations/Policies/ResetPasswordDataModel.cs #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResetPasswordDataModel { @@ -64,14 +52,22 @@ pub struct ResetPasswordDataModel { pub auto_enroll_enabled: bool, } +pub type OrgPolicyResult = Result<(), OrgPolicyErr>; + +#[derive(Debug)] +pub enum OrgPolicyErr { + TwoFactorMissing, + SingleOrgEnforced, +} + /// Local methods impl OrgPolicy { - pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self { + pub fn new(org_uuid: String, atype: OrgPolicyType, data: String) -> Self { Self { - uuid: OrgPolicyId(crate::util::get_uuid()), + uuid: crate::util::get_uuid(), org_uuid, atype: atype as i32, - enabled, + enabled: false, data, } } @@ -82,34 +78,24 @@ impl OrgPolicy { pub fn to_json(&self) -> Value { let data_json: Value = serde_json::from_str(&self.data).unwrap_or(Value::Null); - let mut policy = json!({ + json!({ "id": self.uuid, "organizationId": self.org_uuid, "type": self.atype, "data": data_json, "enabled": self.enabled, "object": "policy", - }); - - // Upstream adds this key/value - // Allow enabling Single Org policy when the organization has claimed domains. - // See: (https://github.com/bitwarden/server/pull/5565) - // We return the same to prevent possible issues - if self.atype == 8i32 { - policy["canToggleState"] = json!(true); - } - - policy + }) } } /// Database methods impl OrgPolicy { - pub async fn save(&self, conn: &DbConn) -> EmptyResult { + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { match diesel::replace_into(org_policies::table) - .values(self) + .values(OrgPolicyDb::to_db(self)) .execute(conn) { Ok(_) => Ok(()), @@ -117,7 +103,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(OrgPolicyDb::to_db(self)) .execute(conn) .map_res("Error saving org_policy") } @@ -125,6 +111,7 @@ impl OrgPolicy { }.map_res("Error saving org_policy") } postgresql { + let value = OrgPolicyDb::to_db(self); // We need to make sure we're not going to violate the unique constraint on org_uuid and atype. // This happens automatically on other DBMS backends due to replace_into(). PostgreSQL does // not support multiple constraints on ON CONFLICT clauses. @@ -137,17 +124,17 @@ impl OrgPolicy { .map_res("Error deleting org_policy for insert")?; diesel::insert_into(org_policies::table) - .values(self) + .values(&value) .on_conflict(org_policies::uuid) .do_update() - .set(self) + .set(&value) .execute(conn) .map_res("Error saving org_policy") } } } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(org_policies::table.filter(org_policies::uuid.eq(self.uuid))) .execute(conn) @@ -155,16 +142,27 @@ impl OrgPolicy { }} } - pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { + pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { org_policies::table - .filter(org_policies::org_uuid.eq(org_uuid)) - .load::(conn) - .expect("Error loading org_policy") + .filter(org_policies::uuid.eq(uuid)) + .first::(conn) + .ok() + .from_db() }} } - pub async fn find_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn find_by_org(org_uuid: &str, conn: &mut DbConn) -> Vec { + db_run! { conn: { + org_policies::table + .filter(org_policies::org_uuid.eq(org_uuid)) + .load::(conn) + .expect("Error loading org_policy") + .from_db() + }} + } + + pub async fn find_confirmed_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { org_policies::table .inner_join( @@ -173,29 +171,27 @@ impl OrgPolicy { .and(users_organizations::user_uuid.eq(user_uuid))) ) .filter( - users_organizations::status.eq(MembershipStatus::Confirmed as i32) + users_organizations::status.eq(UserOrgStatus::Confirmed as i32) ) .select(org_policies::all_columns) - .load::(conn) + .load::(conn) .expect("Error loading org_policy") + .from_db() }} } - pub async fn find_by_org_and_type( - org_uuid: &OrganizationId, - policy_type: OrgPolicyType, - conn: &DbConn, - ) -> Option { + pub async fn find_by_org_and_type(org_uuid: &str, policy_type: OrgPolicyType, conn: &mut DbConn) -> Option { db_run! { conn: { org_policies::table .filter(org_policies::org_uuid.eq(org_uuid)) .filter(org_policies::atype.eq(policy_type as i32)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_organization(org_uuid: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(org_policies::table.filter(org_policies::org_uuid.eq(org_uuid))) .execute(conn) @@ -204,9 +200,9 @@ impl OrgPolicy { } pub async fn find_accepted_and_confirmed_by_user_and_active_policy( - user_uuid: &UserId, + user_uuid: &str, policy_type: OrgPolicyType, - conn: &DbConn, + conn: &mut DbConn, ) -> Vec { db_run! { conn: { org_policies::table @@ -216,23 +212,24 @@ impl OrgPolicy { .and(users_organizations::user_uuid.eq(user_uuid))) ) .filter( - users_organizations::status.eq(MembershipStatus::Accepted as i32) + users_organizations::status.eq(UserOrgStatus::Accepted as i32) ) .or_filter( - users_organizations::status.eq(MembershipStatus::Confirmed as i32) + users_organizations::status.eq(UserOrgStatus::Confirmed as i32) ) .filter(org_policies::atype.eq(policy_type as i32)) .filter(org_policies::enabled.eq(true)) .select(org_policies::all_columns) - .load::(conn) + .load::(conn) .expect("Error loading org_policy") + .from_db() }} } pub async fn find_confirmed_by_user_and_active_policy( - user_uuid: &UserId, + user_uuid: &str, policy_type: OrgPolicyType, - conn: &DbConn, + conn: &mut DbConn, ) -> Vec { db_run! { conn: { org_policies::table @@ -242,13 +239,14 @@ impl OrgPolicy { .and(users_organizations::user_uuid.eq(user_uuid))) ) .filter( - users_organizations::status.eq(MembershipStatus::Confirmed as i32) + users_organizations::status.eq(UserOrgStatus::Confirmed as i32) ) .filter(org_policies::atype.eq(policy_type as i32)) .filter(org_policies::enabled.eq(true)) .select(org_policies::all_columns) - .load::(conn) + .load::(conn) .expect("Error loading org_policy") + .from_db() }} } @@ -256,21 +254,21 @@ impl OrgPolicy { /// and the user is not an owner or admin of that org. This is only useful for checking /// applicability of policy types that have these particular semantics. pub async fn is_applicable_to_user( - user_uuid: &UserId, + user_uuid: &str, policy_type: OrgPolicyType, - exclude_org_uuid: Option<&OrganizationId>, - conn: &DbConn, + exclude_org_uuid: Option<&str>, + conn: &mut DbConn, ) -> bool { for policy in OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy(user_uuid, policy_type, conn).await { // Check if we need to skip this organization. - if exclude_org_uuid.is_some() && *exclude_org_uuid.unwrap() == policy.org_uuid { + if exclude_org_uuid.is_some() && exclude_org_uuid.unwrap() == policy.org_uuid { continue; } - if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await { - if user.atype < MembershipType::Admin { + if let Some(user) = UserOrganization::find_by_user_and_org(user_uuid, &policy.org_uuid, conn).await { + if user.atype < UserOrgType::Admin { return true; } } @@ -278,41 +276,37 @@ impl OrgPolicy { false } - pub async fn check_user_allowed(m: &Membership, action: &str, conn: &DbConn) -> EmptyResult { - if m.atype < MembershipType::Admin && m.status > (MembershipStatus::Invited as i32) { - // Enforce TwoFactor/TwoStep login - if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::TwoFactorAuthentication, conn).await - { - if p.enabled && TwoFactor::find_by_user(&m.user_uuid, conn).await.is_empty() { - if CONFIG.email_2fa_auto_fallback() { - two_factor::email::find_and_activate_email_2fa(&m.user_uuid, conn).await?; - } else { - err!(format!("Cannot {} because 2FA is required (membership {})", action, m.uuid)); - } + pub async fn is_user_allowed( + user_uuid: &str, + org_uuid: &str, + exclude_current_org: bool, + conn: &mut DbConn, + ) -> OrgPolicyResult { + // Enforce TwoFactor/TwoStep login + if TwoFactor::find_by_user(user_uuid, conn).await.is_empty() { + match Self::find_by_org_and_type(org_uuid, OrgPolicyType::TwoFactorAuthentication, conn).await { + Some(p) if p.enabled => { + return Err(OrgPolicyErr::TwoFactorMissing); } - } + _ => {} + }; + } - // Check if the user is part of another Organization with SingleOrg activated - if Self::is_applicable_to_user(&m.user_uuid, OrgPolicyType::SingleOrg, Some(&m.org_uuid), conn).await { - err!(format!( - "Cannot {} because another organization policy forbids it (membership {})", - action, m.uuid - )); - } - - if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::SingleOrg, conn).await { - if p.enabled - && Membership::count_accepted_and_confirmed_by_user(&m.user_uuid, &m.org_uuid, conn).await > 0 - { - err!(format!("Cannot {} because the organization policy forbids being part of other organization (membership {})", action, m.uuid)); - } - } + // Enforce Single Organization Policy of other organizations user is a member of + // This check here needs to exclude this current org-id, else an accepted user can not be confirmed. + let exclude_org = if exclude_current_org { + Some(org_uuid) + } else { + None + }; + if Self::is_applicable_to_user(user_uuid, OrgPolicyType::SingleOrg, exclude_org, conn).await { + return Err(OrgPolicyErr::SingleOrgEnforced); } Ok(()) } - pub async fn org_is_reset_password_auto_enroll(org_uuid: &OrganizationId, conn: &DbConn) -> bool { + pub async fn org_is_reset_password_auto_enroll(org_uuid: &str, conn: &mut DbConn) -> bool { match OrgPolicy::find_by_org_and_type(org_uuid, OrgPolicyType::ResetPassword, conn).await { Some(policy) => match serde_json::from_str::(&policy.data) { Ok(opts) => { @@ -328,12 +322,12 @@ impl OrgPolicy { /// Returns true if the user belongs to an org that has enabled the `DisableHideEmail` /// option of the `Send Options` policy, and the user is not an owner or admin of that org. - pub async fn is_hide_email_disabled(user_uuid: &UserId, conn: &DbConn) -> bool { + pub async fn is_hide_email_disabled(user_uuid: &str, conn: &mut DbConn) -> bool { for policy in OrgPolicy::find_confirmed_by_user_and_active_policy(user_uuid, OrgPolicyType::SendOptions, conn).await { - if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await { - if user.atype < MembershipType::Admin { + if let Some(user) = UserOrganization::find_by_user_and_org(user_uuid, &policy.org_uuid, conn).await { + if user.atype < UserOrgType::Admin { match serde_json::from_str::(&policy.data) { Ok(opts) => { if opts.disable_hide_email { @@ -348,15 +342,12 @@ impl OrgPolicy { false } - pub async fn is_enabled_for_member(member_uuid: &MembershipId, policy_type: OrgPolicyType, conn: &DbConn) -> bool { - if let Some(member) = Membership::find_by_uuid(member_uuid, conn).await { - if let Some(policy) = OrgPolicy::find_by_org_and_type(&member.org_uuid, policy_type, conn).await { + pub async fn is_enabled_for_member(org_user_uuid: &str, policy_type: OrgPolicyType, conn: &mut DbConn) -> bool { + if let Some(membership) = UserOrganization::find_by_uuid(org_user_uuid, conn).await { + if let Some(policy) = OrgPolicy::find_by_org_and_type(&membership.org_uuid, policy_type, conn).await { return policy.enabled; } } false } } - -#[derive(Clone, Debug, AsRef, DieselNewType, From, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct OrgPolicyId(String); diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index ae19b30c..5426fff0 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -1,6 +1,4 @@ use chrono::{NaiveDateTime, Utc}; -use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; use num_traits::FromPrimitive; use serde_json::Value; use std::{ @@ -8,129 +6,104 @@ use std::{ collections::{HashMap, HashSet}, }; -use super::{ - CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, - OrgPolicyType, TwoFactor, User, UserId, -}; -use crate::db::schema::{ - ciphers, ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key, - organizations, users, users_collections, users_organizations, -}; +use super::{CollectionUser, Group, GroupUser, OrgPolicy, OrgPolicyType, TwoFactor, User}; +use crate::db::models::{Collection, CollectionGroup}; use crate::CONFIG; -use macros::UuidFromParam; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = organizations)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid))] -pub struct Organization { - pub uuid: OrganizationId, - pub name: String, - pub billing_email: String, - pub private_key: Option, - pub public_key: Option, +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = organizations)] + #[diesel(primary_key(uuid))] + pub struct Organization { + pub uuid: String, + pub name: String, + pub billing_email: String, + pub private_key: Option, + pub public_key: Option, + } + + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = users_organizations)] + #[diesel(primary_key(uuid))] + pub struct UserOrganization { + pub uuid: String, + pub user_uuid: String, + pub org_uuid: String, + + pub access_all: bool, + pub akey: String, + pub status: i32, + pub atype: i32, + pub reset_password_key: Option, + pub external_id: Option, + } + + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = organization_api_key)] + #[diesel(primary_key(uuid, org_uuid))] + pub struct OrganizationApiKey { + pub uuid: String, + pub org_uuid: String, + pub atype: i32, + pub api_key: String, + pub revision_date: NaiveDateTime, + } } -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = users_organizations)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid))] -pub struct Membership { - pub uuid: MembershipId, - pub user_uuid: UserId, - pub org_uuid: OrganizationId, - - pub invited_by_email: Option, - - pub access_all: bool, - pub akey: String, - pub status: i32, - pub atype: i32, - pub reset_password_key: Option, - pub external_id: Option, -} - -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = organization_api_key)] -#[diesel(primary_key(uuid, org_uuid))] -pub struct OrganizationApiKey { - pub uuid: OrgApiKeyId, - pub org_uuid: OrganizationId, - pub atype: i32, - pub api_key: String, - pub revision_date: NaiveDateTime, -} - -// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs -#[derive(PartialEq)] -pub enum MembershipStatus { +// https://github.com/bitwarden/server/blob/b86a04cef9f1e1b82cf18e49fc94e017c641130c/src/Core/Enums/OrganizationUserStatusType.cs +pub enum UserOrgStatus { Revoked = -1, Invited = 0, Accepted = 1, Confirmed = 2, } -impl MembershipStatus { - pub fn from_i32(status: i32) -> Option { - match status { - 0 => Some(Self::Invited), - 1 => Some(Self::Accepted), - 2 => Some(Self::Confirmed), - // NOTE: we don't care about revoked members where this is used - // if this ever changes also adapt the OrgHeaders check. - _ => None, - } - } -} - #[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] -pub enum MembershipType { +pub enum UserOrgType { Owner = 0, Admin = 1, User = 2, Manager = 3, } -impl MembershipType { +impl UserOrgType { pub fn from_str(s: &str) -> Option { match s { - "0" | "Owner" => Some(MembershipType::Owner), - "1" | "Admin" => Some(MembershipType::Admin), - "2" | "User" => Some(MembershipType::User), - "3" | "Manager" => Some(MembershipType::Manager), - // HACK: We convert the custom role to a manager role - "4" | "Custom" => Some(MembershipType::Manager), + "0" | "Owner" => Some(UserOrgType::Owner), + "1" | "Admin" => Some(UserOrgType::Admin), + "2" | "User" => Some(UserOrgType::User), + "3" | "Manager" => Some(UserOrgType::Manager), _ => None, } } } -impl Ord for MembershipType { - fn cmp(&self, other: &MembershipType) -> Ordering { +impl Ord for UserOrgType { + fn cmp(&self, other: &UserOrgType) -> Ordering { // For easy comparison, map each variant to an access level (where 0 is lowest). - const ACCESS_LEVEL: [i32; 4] = [ + static ACCESS_LEVEL: [i32; 4] = [ 3, // Owner 2, // Admin 0, // User - 1, // Manager && Custom + 1, // Manager ]; ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize]) } } -impl PartialOrd for MembershipType { - fn partial_cmp(&self, other: &MembershipType) -> Option { +impl PartialOrd for UserOrgType { + fn partial_cmp(&self, other: &UserOrgType) -> Option { Some(self.cmp(other)) } } -impl PartialEq for MembershipType { +impl PartialEq for UserOrgType { fn eq(&self, other: &i32) -> bool { *other == *self as i32 } } -impl PartialOrd for MembershipType { +impl PartialOrd for UserOrgType { fn partial_cmp(&self, other: &i32) -> Option { if let Some(other) = Self::from_i32(*other) { return Some(self.cmp(&other)); @@ -147,84 +120,71 @@ impl PartialOrd for MembershipType { } } -impl PartialEq for i32 { - fn eq(&self, other: &MembershipType) -> bool { +impl PartialEq for i32 { + fn eq(&self, other: &UserOrgType) -> bool { *self == *other as i32 } } -impl PartialOrd for i32 { - fn partial_cmp(&self, other: &MembershipType) -> Option { - if let Some(self_type) = MembershipType::from_i32(*self) { +impl PartialOrd for i32 { + fn partial_cmp(&self, other: &UserOrgType) -> Option { + if let Some(self_type) = UserOrgType::from_i32(*self) { return Some(self_type.cmp(other)); } None } - fn lt(&self, other: &MembershipType) -> bool { + fn lt(&self, other: &UserOrgType) -> bool { matches!(self.partial_cmp(other), Some(Ordering::Less) | None) } - fn le(&self, other: &MembershipType) -> bool { + fn le(&self, other: &UserOrgType) -> bool { matches!(self.partial_cmp(other), Some(Ordering::Less | Ordering::Equal) | None) } } /// Local methods impl Organization { - pub fn new(name: String, billing_email: &str, private_key: Option, public_key: Option) -> Self { - let billing_email = billing_email.to_lowercase(); + pub fn new(name: String, billing_email: String, private_key: Option, public_key: Option) -> Self { Self { - uuid: OrganizationId(crate::util::get_uuid()), + uuid: crate::util::get_uuid(), name, billing_email, private_key, public_key, } } - // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs + // https://github.com/bitwarden/server/blob/13d1e74d6960cf0d042620b72d85bf583a4236f7/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs pub fn to_json(&self) -> Value { json!({ "id": self.uuid, + "identifier": null, // not supported by us "name": self.name, "seats": null, "maxCollections": null, "maxStorageGb": i16::MAX, // The value doesn't matter, we don't check server-side "use2fa": true, - "useCustomPermissions": true, + "useCustomPermissions": false, "useDirectory": false, // Is supported, but this value isn't checked anywhere (yet) "useEvents": CONFIG.org_events_enabled(), "useGroups": CONFIG.org_groups_enabled(), "useTotp": true, "usePolicies": true, - "useScim": false, // Not supported (Not AGPLv3 Licensed) + // "useScim": false, // Not supported (Not AGPLv3 Licensed) "useSso": false, // Not supported - "useKeyConnector": false, // Not supported - "usePasswordManager": true, - "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) + // "useKeyConnector": false, // Not supported "selfHost": true, "useApi": true, "hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), "useResetPassword": CONFIG.mail_enabled(), - "allowAdminAccessToAllCollectionItems": true, - "limitCollectionCreation": true, - "limitCollectionDeletion": true, - "businessName": self.name, + "businessName": null, "businessAddress1": null, "businessAddress2": null, "businessAddress3": null, "businessCountry": null, "businessTaxNumber": null, - "maxAutoscaleSeats": null, - "maxAutoscaleSmSeats": null, - "maxAutoscaleSmServiceAccounts": null, - - "secretsManagerPlan": null, - "smSeats": null, - "smServiceAccounts": null, - "billingEmail": self.billing_email, "planType": 6, // Custom plan "usersGetPremium": true, @@ -237,28 +197,27 @@ impl Organization { // The number 128 should be fine, it is well within the range of an i32 // The same goes for the database where we only use INTEGER (the same as an i32) // It should also provide enough room for 100+ types, which i doubt will ever happen. -const ACTIVATE_REVOKE_DIFF: i32 = 128; +static ACTIVATE_REVOKE_DIFF: i32 = 128; -impl Membership { - pub fn new(user_uuid: UserId, org_uuid: OrganizationId, invited_by_email: Option) -> Self { +impl UserOrganization { + pub fn new(user_uuid: String, org_uuid: String) -> Self { Self { - uuid: MembershipId(crate::util::get_uuid()), + uuid: crate::util::get_uuid(), user_uuid, org_uuid, - invited_by_email, access_all: false, akey: String::new(), - status: MembershipStatus::Accepted as i32, - atype: MembershipType::User as i32, + status: UserOrgStatus::Accepted as i32, + atype: UserOrgType::User as i32, reset_password_key: None, external_id: None, } } pub fn restore(&mut self) -> bool { - if self.status < MembershipStatus::Invited as i32 { + if self.status < UserOrgStatus::Invited as i32 { self.status += ACTIVATE_REVOKE_DIFF; return true; } @@ -266,7 +225,7 @@ impl Membership { } pub fn revoke(&mut self) -> bool { - if self.status > MembershipStatus::Revoked as i32 { + if self.status > UserOrgStatus::Revoked as i32 { self.status -= ACTIVATE_REVOKE_DIFF; return true; } @@ -275,7 +234,7 @@ impl Membership { /// Return the status of the user in an unrevoked state pub fn get_unrevoked_status(&self) -> i32 { - if self.status <= MembershipStatus::Revoked as i32 { + if self.status <= UserOrgStatus::Revoked as i32 { return self.status + ACTIVATE_REVOKE_DIFF; } self.status @@ -293,21 +252,12 @@ impl Membership { } false } - - /// HACK: Convert the manager type to a custom type - /// It will be converted back on other locations - pub fn type_manager_as_custom(&self) -> i32 { - match self.atype { - 3 => 4, - _ => self.atype, - } - } } impl OrganizationApiKey { - pub fn new(org_uuid: OrganizationId, api_key: String) -> Self { + pub fn new(org_uuid: String, api_key: String) -> Self { Self { - uuid: OrgApiKeyId(crate::util::get_uuid()), + uuid: crate::util::get_uuid(), org_uuid, atype: 0, // Type 0 is the default and only type we support currently @@ -328,19 +278,19 @@ use crate::error::MapResult; /// Database methods 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)) + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { + if !email_address::EmailAddress::is_valid(self.billing_email.trim()) { + err!(format!("BillingEmail {} is not a valid email address", self.billing_email.trim())) } - for member in Membership::find_by_org(&self.uuid, conn).await.iter() { - User::update_uuid_revision(&member.user_uuid, conn).await; + for user_org in UserOrganization::find_by_org(&self.uuid, conn).await.iter() { + User::update_uuid_revision(&user_org.user_uuid, conn).await; } db_run! { conn: sqlite, mysql { match diesel::replace_into(organizations::table) - .values(self) + .values(OrganizationDb::to_db(self)) .execute(conn) { Ok(_) => Ok(()), @@ -348,7 +298,7 @@ impl Organization { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(organizations::table) .filter(organizations::uuid.eq(&self.uuid)) - .set(self) + .set(OrganizationDb::to_db(self)) .execute(conn) .map_res("Error saving organization") } @@ -357,23 +307,24 @@ impl Organization { } postgresql { + let value = OrganizationDb::to_db(self); diesel::insert_into(organizations::table) - .values(self) + .values(&value) .on_conflict(organizations::uuid) .do_update() - .set(self) + .set(&value) .execute(conn) .map_res("Error saving organization") } } } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { use super::{Cipher, Collection}; Cipher::delete_all_by_organization(&self.uuid, conn).await?; Collection::delete_all_by_organization(&self.uuid, conn).await?; - Membership::delete_all_by_organization(&self.uuid, conn).await?; + UserOrganization::delete_all_by_organization(&self.uuid, conn).await?; OrgPolicy::delete_all_by_organization(&self.uuid, conn).await?; Group::delete_all_by_organization(&self.uuid, conn).await?; OrganizationApiKey::delete_all_by_organization(&self.uuid, conn).await?; @@ -385,84 +336,37 @@ impl Organization { }} } - pub async fn find_by_uuid(uuid: &OrganizationId, conn: &DbConn) -> Option { + pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { organizations::table .filter(organizations::uuid.eq(uuid)) - .first::(conn) - .ok() + .first::(conn) + .ok().from_db() }} } - pub async fn find_by_name(name: &str, conn: &DbConn) -> Option { + pub async fn get_all(conn: &mut DbConn) -> Vec { db_run! { conn: { - organizations::table - .filter(organizations::name.eq(name)) - .first::(conn) - .ok() - }} - } - - pub async fn get_all(conn: &DbConn) -> Vec { - db_run! { conn: { - organizations::table - .load::(conn) - .expect("Error loading organizations") - }} - } - - pub async fn find_main_org_user_email(user_email: &str, conn: &DbConn) -> Option { - let lower_mail = user_email.to_lowercase(); - - db_run! { conn: { - organizations::table - .inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) - .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid))) - .filter(users::email.eq(lower_mail)) - .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) - .order(users_organizations::atype.asc()) - .select(organizations::all_columns) - .first::(conn) - .ok() - }} - } - - pub async fn find_org_user_email(user_email: &str, conn: &DbConn) -> Vec { - let lower_mail = user_email.to_lowercase(); - - db_run! { conn: { - organizations::table - .inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) - .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid))) - .filter(users::email.eq(lower_mail)) - .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) - .order(users_organizations::atype.asc()) - .select(organizations::all_columns) - .load::(conn) - .expect("Error loading user orgs") + organizations::table.load::(conn).expect("Error loading organizations").from_db() }} } } -impl Membership { - pub async fn to_json(&self, conn: &DbConn) -> Value { +impl UserOrganization { + pub async fn to_json(&self, conn: &mut DbConn) -> Value { let org = Organization::find_by_uuid(&self.org_uuid, conn).await.unwrap(); - // HACK: Convert the manager type to a custom type - // It will be converted back on other locations - let membership_type = self.type_manager_as_custom(); - let permissions = json!({ - // TODO: Add full support for Custom User Roles + // TODO: Add support for Custom User Roles // See: https://bitwarden.com/help/article/user-types-access-control/#custom-role - // Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission "accessEventLogs": false, "accessImportExport": false, "accessReports": false, - // If the following 3 Collection roles are set to true a custom user has access all permission - "createNewCollections": membership_type == 4 && self.access_all, - "editAnyCollection": membership_type == 4 && self.access_all, - "deleteAnyCollection": membership_type == 4 && self.access_all, + "createNewCollections": false, + "editAnyCollection": false, + "deleteAnyCollection": false, + "editAssignedCollections": false, + "deleteAssignedCollections": false, "manageGroups": false, "managePolicies": false, "manageSso": false, // Not supported @@ -471,12 +375,12 @@ impl Membership { "manageScim": false // Not supported (Not AGPLv3 Licensed) }); - // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs + // https://github.com/bitwarden/server/blob/13d1e74d6960cf0d042620b72d85bf583a4236f7/src/Api/Models/Response/ProfileOrganizationResponseModel.cs json!({ "id": self.org_uuid, "identifier": null, // Not supported "name": org.name, - "seats": 20, // hardcoded maxEmailsCount in the web-vault + "seats": null, "maxCollections": null, "usersGetPremium": true, "use2fa": true, @@ -494,12 +398,10 @@ impl Membership { "ssoBound": false, // Not supported "useSso": false, // Not supported "useKeyConnector": false, - "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) + "useSecretsManager": false, "usePasswordManager": true, - "useCustomPermissions": true, + "useCustomPermissions": false, "useActivateAutofillPolicy": false, - "useAdminSponsoredFamilies": false, - "useRiskInsights": false, // Not supported (Not AGPLv3 Licensed) "organizationUserId": self.uuid, "providerId": null, @@ -507,6 +409,7 @@ impl Membership { "providerType": null, "familySponsorshipFriendlyName": null, "familySponsorshipAvailable": false, + "planProductType": 3, "productTierType": 3, // Enterprise tier "keyConnectorEnabled": false, "keyConnectorUrl": null, @@ -514,13 +417,9 @@ impl Membership { "familySponsorshipValidUntil": null, "familySponsorshipToDelete": null, "accessSecretsManager": false, - // limit collection creation to managers with access_all permission to prevent issues - "limitCollectionCreation": self.atype < MembershipType::Manager || !self.access_all, - "limitCollectionDeletion": true, - "limitItemDeletion": false, + "limitCollectionCreationDeletion": false, // This should be set to true only when we can handle roles like createNewCollections "allowAdminAccessToAllCollectionItems": true, - "userIsManagedByOrganization": false, // Means not managed via the Members UI, like SSO - "userIsClaimedByOrganization": false, // The new key instead of the obsolete userIsManagedByOrganization + "flexibleCollections": false, "permissions": permissions, @@ -530,43 +429,42 @@ impl Membership { "userId": self.user_uuid, "key": self.akey, "status": self.status, - "type": membership_type, + "type": self.atype, "enabled": true, "object": "profileOrganization", }) } - pub async fn to_json_user_details(&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: &mut 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. // We subtract/add a number so we can restore/activate the user to it's previous state again. - let status = if self.status < MembershipStatus::Revoked as i32 { - MembershipStatus::Revoked as i32 + let status = if self.status < UserOrgStatus::Revoked as i32 { + UserOrgStatus::Revoked as i32 } else { self.status }; let twofactor_enabled = !TwoFactor::find_by_user(&user.uuid, conn).await.is_empty(); - let groups: Vec = if include_groups && CONFIG.org_groups_enabled() { - GroupUser::find_by_member(&self.uuid, conn).await.iter().map(|gu| gu.groups_uuid.clone()).collect() + let groups: Vec = if include_groups && CONFIG.org_groups_enabled() { + GroupUser::find_by_user(&self.uuid, conn).await.iter().map(|gu| gu.groups_uuid.clone()).collect() } else { // The Bitwarden clients seem to call this API regardless of whether groups are enabled, // so just act as if there are no groups. Vec::with_capacity(0) }; - // 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! - let full_access_group = - 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 - let collections: Vec = if include_collections && !(full_access_group || self.access_all) { + let collections: Vec = if include_collections { // Get all collections for the user here already to prevent more queries - let cu: HashMap = + let cu: HashMap = CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) .await .into_iter() @@ -574,7 +472,7 @@ impl Membership { .collect(); // Get all collection groups for this user to prevent there inclusion - let cg: HashSet = CollectionGroup::find_by_user(&self.user_uuid, conn) + let cg: HashSet = CollectionGroup::find_by_user(&self.user_uuid, conn) .await .into_iter() .map(|cg| cg.collections_uuid) @@ -584,13 +482,13 @@ impl Membership { .await .into_iter() .filter_map(|c| { - let (read_only, hide_passwords, manage) = if self.has_full_access() { - (false, false, self.atype >= MembershipType::Manager) + let (read_only, hide_passwords, can_manage) = if self.has_full_access() { + (false, false, self.atype >= UserOrgType::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), + self.atype == UserOrgType::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 @@ -604,7 +502,7 @@ impl Membership { "id": c.uuid, "readOnly": read_only, "hidePasswords": hide_passwords, - "manage": manage, + "manage": can_manage, })) }) .collect() @@ -612,39 +510,29 @@ impl Membership { Vec::with_capacity(0) }; - // HACK: Convert the manager type to a custom type - // It will be converted back on other locations - let membership_type = self.type_manager_as_custom(); - - // HACK: Only return permissions if the user is of type custom and has access_all - // Else Bitwarden will assume the defaults of all false - let permissions = if membership_type == 4 && self.access_all { - json!({ - // TODO: Add full support for Custom User Roles - // See: https://bitwarden.com/help/article/user-types-access-control/#custom-role - // Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission - "accessEventLogs": false, - "accessImportExport": false, - "accessReports": false, - // If the following 3 Collection roles are set to true a custom user has access all permission - "createNewCollections": true, - "editAnyCollection": true, - "deleteAnyCollection": true, - "manageGroups": false, - "managePolicies": false, - "manageSso": false, // Not supported - "manageUsers": false, - "manageResetPassword": false, - "manageScim": false // Not supported (Not AGPLv3 Licensed) - }) - } else { - json!(null) - }; + let permissions = json!({ + // TODO: Add support for Custom User Roles + // See: https://bitwarden.com/help/article/user-types-access-control/#custom-role + "accessEventLogs": false, + "accessImportExport": false, + "accessReports": false, + "createNewCollections": false, + "editAnyCollection": false, + "deleteAnyCollection": false, + "editAssignedCollections": false, + "deleteAssignedCollections": false, + "manageGroups": false, + "managePolicies": false, + "manageSso": false, // Not supported + "manageUsers": false, + "manageResetPassword": false, + "manageScim": false // Not supported (Not AGPLv3 Licensed) + }); json!({ "id": self.uuid, "userId": self.user_uuid, - "name": if self.get_unrevoked_status() >= MembershipStatus::Accepted as i32 { Some(user.name) } else { None }, + "name": if self.get_unrevoked_status() >= UserOrgStatus::Accepted as i32 { Some(user.name) } else { None }, "email": user.email, "externalId": self.external_id, "avatarColor": user.avatar_color, @@ -652,7 +540,7 @@ impl Membership { "collections": collections, "status": status, - "type": membership_type, + "type": self.atype, "accessAll": self.access_all, "twoFactorEnabled": twofactor_enabled, "resetPasswordEnrolled": self.reset_password_key.is_some(), @@ -661,8 +549,6 @@ impl Membership { "permissions": permissions, "ssoBound": false, // Not supported - "managedByOrganization": false, // This key is obsolete replaced by claimedByOrganization - "claimedByOrganization": false, // Means not managed via the Members UI, like SSO "usesKeyConnector": false, // Not supported "accessSecretsManager": false, // Not supported (Not AGPLv3 Licensed) @@ -675,11 +561,10 @@ impl Membership { "id": self.uuid, "readOnly": col_user.read_only, "hidePasswords": col_user.hide_passwords, - "manage": col_user.manage, }) } - pub async fn to_json_details(&self, conn: &DbConn) -> Value { + pub async fn to_json_details(&self, conn: &mut DbConn) -> Value { let coll_uuids = if self.access_all { vec![] // If we have complete access, no need to fill the array } else { @@ -687,12 +572,11 @@ impl Membership { CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn).await; collections .iter() - .map(|cu| { + .map(|c| { json!({ - "id": cu.collection_uuid, - "readOnly": cu.read_only, - "hidePasswords": cu.hide_passwords, - "manage": cu.manage, + "id": c.collection_uuid, + "readOnly": c.read_only, + "hidePasswords": c.hide_passwords, }) }) .collect() @@ -700,8 +584,8 @@ impl Membership { // Because BitWarden want the status to be -1 for revoked users we need to catch that here. // We subtract/add a number so we can restore/activate the user to it's previous state again. - let status = if self.status < MembershipStatus::Revoked as i32 { - MembershipStatus::Revoked as i32 + let status = if self.status < UserOrgStatus::Revoked as i32 { + UserOrgStatus::Revoked as i32 } else { self.status }; @@ -718,36 +602,13 @@ impl Membership { "object": "organizationUserDetails", }) } - - pub async fn to_json_mini_details(&self, conn: &DbConn) -> Value { - let user = User::find_by_uuid(&self.user_uuid, conn).await.unwrap(); - - // Because Bitwarden wants the status to be -1 for revoked users we need to catch that here. - // We subtract/add a number so we can restore/activate the user to it's previous state again. - let status = if self.status < MembershipStatus::Revoked as i32 { - MembershipStatus::Revoked as i32 - } else { - self.status - }; - - json!({ - "id": self.uuid, - "userId": self.user_uuid, - "type": self.type_manager_as_custom(), // HACK: Convert the manager type to a custom type - "status": status, - "name": user.name, - "email": user.email, - "object": "organizationUserUserMiniDetails", - }) - } - - pub async fn save(&self, conn: &DbConn) -> EmptyResult { + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { User::update_uuid_revision(&self.user_uuid, conn).await; db_run! { conn: sqlite, mysql { match diesel::replace_into(users_organizations::table) - .values(self) + .values(UserOrganizationDb::to_db(self)) .execute(conn) { Ok(_) => Ok(()), @@ -755,7 +616,7 @@ impl Membership { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(users_organizations::table) .filter(users_organizations::uuid.eq(&self.uuid)) - .set(self) + .set(UserOrganizationDb::to_db(self)) .execute(conn) .map_res("Error adding user to organization") }, @@ -763,22 +624,23 @@ impl Membership { }.map_res("Error adding user to organization") } postgresql { + let value = UserOrganizationDb::to_db(self); diesel::insert_into(users_organizations::table) - .values(self) + .values(&value) .on_conflict(users_organizations::uuid) .do_update() - .set(self) + .set(&value) .execute(conn) .map_res("Error adding user to organization") } } } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { User::update_uuid_revision(&self.user_uuid, conn).await; CollectionUser::delete_all_by_user_and_org(&self.user_uuid, &self.org_uuid, conn).await?; - GroupUser::delete_all_by_member(&self.uuid, conn).await?; + GroupUser::delete_all_by_user(&self.uuid, conn).await?; db_run! { conn: { diesel::delete(users_organizations::table.filter(users_organizations::uuid.eq(self.uuid))) @@ -787,154 +649,121 @@ impl Membership { }} } - pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { - for member in Self::find_by_org(org_uuid, conn).await { - member.delete(conn).await?; + pub async fn delete_all_by_organization(org_uuid: &str, conn: &mut DbConn) -> EmptyResult { + for user_org in Self::find_by_org(org_uuid, conn).await { + user_org.delete(conn).await?; } Ok(()) } - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - for member in Self::find_any_state_by_user(user_uuid, conn).await { - member.delete(conn).await?; + pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult { + for user_org in Self::find_any_state_by_user(user_uuid, conn).await { + user_org.delete(conn).await?; } Ok(()) } - pub async fn find_by_email_and_org(email: &str, org_uuid: &OrganizationId, conn: &DbConn) -> Option { + pub async fn find_by_email_and_org(email: &str, org_id: &str, conn: &mut DbConn) -> Option { if let Some(user) = User::find_by_mail(email, conn).await { - if let Some(member) = Membership::find_by_user_and_org(&user.uuid, org_uuid, conn).await { - return Some(member); + if let Some(user_org) = UserOrganization::find_by_user_and_org(&user.uuid, org_id, conn).await { + return Some(user_org); } } None } - pub fn has_status(&self, status: MembershipStatus) -> bool { + pub fn has_status(&self, status: UserOrgStatus) -> bool { self.status == status as i32 } - pub fn has_type(&self, user_type: MembershipType) -> bool { + pub fn has_type(&self, user_type: UserOrgType) -> bool { self.atype == user_type as i32 } pub fn has_full_access(&self) -> bool { - (self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed) + (self.access_all || self.atype >= UserOrgType::Admin) && self.has_status(UserOrgStatus::Confirmed) } - pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option { + pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { users_organizations::table .filter(users_organizations::uuid.eq(uuid)) - .first::(conn) - .ok() + .first::(conn) + .ok().from_db() }} } - pub async fn find_by_uuid_and_org(uuid: &MembershipId, org_uuid: &OrganizationId, conn: &DbConn) -> Option { + pub async fn find_by_uuid_and_org(uuid: &str, org_uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { users_organizations::table .filter(users_organizations::uuid.eq(uuid)) .filter(users_organizations::org_uuid.eq(org_uuid)) - .first::(conn) - .ok() + .first::(conn) + .ok().from_db() }} } - pub async fn find_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn find_confirmed_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .load::(conn) - .unwrap_or_default() + .filter(users_organizations::status.eq(UserOrgStatus::Confirmed as i32)) + .load::(conn) + .unwrap_or_default().from_db() }} } - pub async fn find_invited_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn find_invited_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Invited as i32)) - .load::(conn) - .unwrap_or_default() + .filter(users_organizations::status.eq(UserOrgStatus::Invited as i32)) + .load::(conn) + .unwrap_or_default().from_db() }} } - // Should be used only when email are disabled. - // In Organizations::send_invite status is set to Accepted only if the user has a password. - pub async fn accept_user_invitations(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - db_run! { conn: { - diesel::update(users_organizations::table) - .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Invited as i32)) - .set(users_organizations::status.eq(MembershipStatus::Accepted as i32)) - .execute(conn) - .map_res("Error confirming invitations") - }} - } - - pub async fn find_any_state_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn find_any_state_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) - .load::(conn) - .unwrap_or_default() + .load::(conn) + .unwrap_or_default().from_db() }} } - pub async fn count_accepted_and_confirmed_by_user( - user_uuid: &UserId, - excluded_org: &OrganizationId, - conn: &DbConn, - ) -> i64 { + pub async fn count_accepted_and_confirmed_by_user(user_uuid: &str, conn: &mut DbConn) -> i64 { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::org_uuid.ne(excluded_org)) - .filter(users_organizations::status.eq(MembershipStatus::Accepted as i32).or(users_organizations::status.eq(MembershipStatus::Confirmed as i32))) + .filter(users_organizations::status.eq(UserOrgStatus::Accepted as i32).or(users_organizations::status.eq(UserOrgStatus::Confirmed as i32))) .count() .first::(conn) .unwrap_or(0) }} } - pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { + pub async fn find_by_org(org_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) - .load::(conn) - .expect("Error loading user organizations") + .load::(conn) + .expect("Error loading user organizations").from_db() }} } - pub async fn find_confirmed_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { + pub async fn find_confirmed_by_org(org_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .load::(conn) - .unwrap_or_default() + .filter(users_organizations::status.eq(UserOrgStatus::Confirmed as i32)) + .load::(conn) + .unwrap_or_default().from_db() }} } - // Get all users which are either owner or admin, or a manager which can manage/access all - pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::org_uuid.eq(org_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]) - .or(users_organizations::atype.eq(MembershipType::Manager as i32).and(users_organizations::access_all.eq(true))) - ) - .load::(conn) - .unwrap_or_default() - }} - } - - pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { + pub async fn count_by_org(org_uuid: &str, conn: &mut DbConn) -> i64 { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) @@ -945,79 +774,71 @@ impl Membership { }} } - pub async fn find_by_org_and_type(org_uuid: &OrganizationId, atype: MembershipType, conn: &DbConn) -> Vec { + pub async fn find_by_org_and_type(org_uuid: &str, atype: UserOrgType, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::atype.eq(atype as i32)) - .load::(conn) - .expect("Error loading user organizations") + .load::(conn) + .expect("Error loading user organizations").from_db() }} } - pub async fn count_confirmed_by_org_and_type( - org_uuid: &OrganizationId, - atype: MembershipType, - conn: &DbConn, - ) -> i64 { + pub async fn count_confirmed_by_org_and_type(org_uuid: &str, atype: UserOrgType, conn: &mut DbConn) -> i64 { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::atype.eq(atype as i32)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) + .filter(users_organizations::status.eq(UserOrgStatus::Confirmed as i32)) .count() .first::(conn) .unwrap_or(0) }} } - pub async fn find_by_user_and_org(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> Option { + pub async fn find_by_user_and_org(user_uuid: &str, org_uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::org_uuid.eq(org_uuid)) - .first::(conn) - .ok() + .first::(conn) + .ok().from_db() }} } - pub async fn find_confirmed_by_user_and_org( - user_uuid: &UserId, - org_uuid: &OrganizationId, - conn: &DbConn, - ) -> Option { + pub async fn find_confirmed_by_user_and_org(user_uuid: &str, org_uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::org_uuid.eq(org_uuid)) .filter( - users_organizations::status.eq(MembershipStatus::Confirmed as i32) + users_organizations::status.eq(UserOrgStatus::Confirmed as i32) ) - .first::(conn) - .ok() + .first::(conn) + .ok().from_db() }} } - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) - .load::(conn) - .expect("Error loading user organizations") + .load::(conn) + .expect("Error loading user organizations").from_db() }} } - pub async fn get_orgs_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn get_org_uuid_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .select(users_organizations::org_uuid) - .load::(conn) + .load::(conn) .unwrap_or_default() }} } - pub async fn find_by_user_and_policy(user_uuid: &UserId, policy_type: OrgPolicyType, conn: &DbConn) -> Vec { + pub async fn find_by_user_and_policy(user_uuid: &str, policy_type: OrgPolicyType, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table .inner_join( @@ -1028,15 +849,15 @@ impl Membership { .and(org_policies::enabled.eq(true))) ) .filter( - users_organizations::status.eq(MembershipStatus::Confirmed as i32) + users_organizations::status.eq(UserOrgStatus::Confirmed as i32) ) .select(users_organizations::all_columns) - .load::(conn) - .unwrap_or_default() + .load::(conn) + .unwrap_or_default().from_db() }} } - pub async fn find_by_cipher_and_org(cipher_uuid: &CipherId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec { + pub async fn find_by_cipher_and_org(cipher_uuid: &str, org_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) @@ -1055,16 +876,11 @@ impl Membership { ) .select(users_organizations::all_columns) .distinct() - .load::(conn) - .expect("Error loading user organizations") + .load::(conn).expect("Error loading user organizations").from_db() }} } - pub async fn find_by_cipher_and_org_with_group( - cipher_uuid: &CipherId, - org_uuid: &OrganizationId, - conn: &DbConn, - ) -> Vec { + pub async fn find_by_cipher_and_org_with_group(cipher_uuid: &str, org_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) @@ -1074,9 +890,7 @@ impl Membership { .left_join(collections_groups::table.on( collections_groups::groups_uuid.eq(groups_users::groups_uuid) )) - .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid)) - )) + .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid))) .left_join(ciphers_collections::table.on( ciphers_collections::collection_uuid.eq(collections_groups::collections_uuid).and(ciphers_collections::cipher_uuid.eq(&cipher_uuid)) @@ -1088,29 +902,23 @@ impl Membership { ) .select(users_organizations::all_columns) .distinct() - .load::(conn) - .expect("Error loading user organizations with groups") + .load::(conn).expect("Error loading user organizations with groups").from_db() }} } - pub async fn user_has_ge_admin_access_to_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> bool { + pub async fn user_has_ge_admin_access_to_cipher(user_uuid: &str, cipher_uuid: &str, conn: &mut DbConn) -> bool { db_run! { conn: { users_organizations::table .inner_join(ciphers::table.on(ciphers::uuid.eq(cipher_uuid).and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())))) .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32])) + .filter(users_organizations::atype.eq_any(vec![UserOrgType::Owner as i32, UserOrgType::Admin as i32])) .count() .first::(conn) - .ok() - .unwrap_or(0) != 0 + .ok().unwrap_or(0) != 0 }} } - pub async fn find_by_collection_and_org( - collection_uuid: &CollectionId, - org_uuid: &OrganizationId, - conn: &DbConn, - ) -> Vec { + pub async fn find_by_collection_and_org(collection_uuid: &str, org_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) @@ -1123,31 +931,18 @@ impl Membership { ) ) .select(users_organizations::all_columns) - .load::(conn) - .expect("Error loading user organizations") + .load::(conn).expect("Error loading user organizations").from_db() }} } - pub async fn find_by_external_id_and_org(ext_id: &str, org_uuid: &OrganizationId, conn: &DbConn) -> Option { - db_run! { conn: { + pub async fn find_by_external_id_and_org(ext_id: &str, org_uuid: &str, conn: &mut DbConn) -> Option { + db_run! {conn: { users_organizations::table .filter( users_organizations::external_id.eq(ext_id) .and(users_organizations::org_uuid.eq(org_uuid)) ) - .first::(conn) - .ok() - }} - } - - pub async fn find_main_user_org(user_uuid: &str, conn: &DbConn) -> Option { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) - .order(users_organizations::atype.asc()) - .first::(conn) - .ok() + .first::(conn).ok().from_db() }} } } @@ -1157,7 +952,7 @@ impl OrganizationApiKey { db_run! { conn: sqlite, mysql { match diesel::replace_into(organization_api_key::table) - .values(self) + .values(OrganizationApiKeyDb::to_db(self)) .execute(conn) { Ok(_) => Ok(()), @@ -1165,7 +960,7 @@ impl OrganizationApiKey { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(organization_api_key::table) .filter(organization_api_key::uuid.eq(&self.uuid)) - .set(self) + .set(OrganizationApiKeyDb::to_db(self)) .execute(conn) .map_res("Error saving organization") } @@ -1174,27 +969,28 @@ impl OrganizationApiKey { } postgresql { + let value = OrganizationApiKeyDb::to_db(self); diesel::insert_into(organization_api_key::table) - .values(self) + .values(&value) .on_conflict((organization_api_key::uuid, organization_api_key::org_uuid)) .do_update() - .set(self) + .set(&value) .execute(conn) .map_res("Error saving organization") } } } - pub async fn find_by_org_uuid(org_uuid: &OrganizationId, conn: &DbConn) -> Option { + pub async fn find_by_org_uuid(org_uuid: &str, conn: &DbConn) -> Option { db_run! { conn: { organization_api_key::table .filter(organization_api_key::org_uuid.eq(org_uuid)) - .first::(conn) - .ok() + .first::(conn) + .ok().from_db() }} } - pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_organization(org_uuid: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(organization_api_key::table.filter(organization_api_key::org_uuid.eq(org_uuid))) .execute(conn) @@ -1203,56 +999,15 @@ impl OrganizationApiKey { } } -#[derive( - Clone, - Debug, - AsRef, - Deref, - DieselNewType, - Display, - From, - FromForm, - Hash, - PartialEq, - Eq, - Serialize, - Deserialize, - UuidFromParam, -)] -#[deref(forward)] -#[from(forward)] -pub struct OrganizationId(String); - -#[derive( - Clone, - Debug, - Deref, - DieselNewType, - Display, - From, - FromForm, - Hash, - PartialEq, - Eq, - Serialize, - Deserialize, - UuidFromParam, -)] -pub struct MembershipId(String); - -#[derive(Clone, Debug, DieselNewType, Display, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub struct OrgApiKeyId(String); - #[cfg(test)] mod tests { use super::*; #[test] #[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()); + fn partial_cmp_UserOrgType() { + assert!(UserOrgType::Owner > UserOrgType::Admin); + assert!(UserOrgType::Admin > UserOrgType::Manager); + assert!(UserOrgType::Manager > UserOrgType::User); } } diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 84802c54..36944281 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -1,43 +1,43 @@ use chrono::{NaiveDateTime, Utc}; use serde_json::Value; -use crate::{config::PathType, util::LowerCase, CONFIG}; +use crate::util::LowerCase; -use super::{OrganizationId, User, UserId}; -use crate::db::schema::sends; -use diesel::prelude::*; -use id::SendId; +use super::User; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = sends)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid))] -pub struct Send { - pub uuid: SendId, +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = sends)] + #[diesel(treat_none_as_null = true)] + #[diesel(primary_key(uuid))] + pub struct Send { + pub uuid: String, - pub user_uuid: Option, - pub organization_uuid: Option, + pub user_uuid: Option, + pub organization_uuid: Option, - pub name: String, - pub notes: Option, - pub atype: i32, - pub data: String, - pub akey: String, - pub password_hash: Option>, - password_salt: Option>, - password_iter: Option, + pub name: String, + pub notes: Option, - pub max_access_count: Option, - pub access_count: i32, + pub atype: i32, + pub data: String, + pub akey: String, + pub password_hash: Option>, + password_salt: Option>, + password_iter: Option, - pub creation_date: NaiveDateTime, - pub revision_date: NaiveDateTime, - pub expiration_date: Option, - pub deletion_date: NaiveDateTime, + pub max_access_count: Option, + pub access_count: i32, - pub disabled: bool, - pub hide_email: Option, + pub creation_date: NaiveDateTime, + pub revision_date: NaiveDateTime, + pub expiration_date: Option, + pub deletion_date: NaiveDateTime, + + pub disabled: bool, + pub hide_email: Option, + } } #[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] @@ -46,22 +46,12 @@ pub enum SendType { File = 1, } -enum SendAuthType { - #[allow(dead_code)] - // Send requires email OTP verification - Email = 0, // Not yet supported by Vaultwarden - // Send requires a password - Password = 1, - // Send requires no auth - None = 2, -} - impl Send { pub fn new(atype: i32, name: String, data: String, akey: String, deletion_date: NaiveDateTime) -> Self { let now = Utc::now().naive_utc(); Self { - uuid: SendId::from(crate::util::get_uuid()), + uuid: crate::util::get_uuid(), user_uuid: None, organization_uuid: None, @@ -113,7 +103,7 @@ impl Send { } } - pub async fn creator_identifier(&self, conn: &DbConn) -> Option { + pub async fn creator_identifier(&self, conn: &mut DbConn) -> Option { if let Some(hide_email) = self.hide_email { if hide_email { return None; @@ -155,7 +145,6 @@ impl Send { "maxAccessCount": self.max_access_count, "accessCount": self.access_count, "password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), - "authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 }, "disabled": self.disabled, "hideEmail": self.hide_email, @@ -166,7 +155,7 @@ impl Send { }) } - pub async fn to_json_access(&self, conn: &DbConn) -> Value { + pub async fn to_json_access(&self, conn: &mut DbConn) -> Value { use crate::util::format_date; let mut data = serde_json::from_str::>(&self.data).map(|d| d.data).unwrap_or_default(); @@ -198,14 +187,14 @@ use crate::error::MapResult; use crate::util::NumberOrString; impl Send { - pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { + pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { self.update_users_revision(conn).await; self.revision_date = Utc::now().naive_utc(); db_run! { conn: sqlite, mysql { match diesel::replace_into(sends::table) - .values(&*self) + .values(SendDb::to_db(self)) .execute(conn) { Ok(_) => Ok(()), @@ -213,7 +202,7 @@ impl Send { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(sends::table) .filter(sends::uuid.eq(&self.uuid)) - .set(&*self) + .set(SendDb::to_db(self)) .execute(conn) .map_res("Error saving send") } @@ -221,23 +210,23 @@ impl Send { }.map_res("Error saving send") } postgresql { + let value = SendDb::to_db(self); diesel::insert_into(sends::table) - .values(&*self) + .values(&value) .on_conflict(sends::uuid) .do_update() - .set(&*self) + .set(&value) .execute(conn) .map_res("Error saving send") } } } - pub async fn delete(&self, conn: &DbConn) -> EmptyResult { + pub async fn delete(&self, conn: &mut DbConn) -> EmptyResult { self.update_users_revision(conn).await; if self.atype == SendType::File as i32 { - let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?; - operator.remove_all(&self.uuid).await.ok(); + std::fs::remove_dir_all(std::path::Path::new(&crate::CONFIG.sends_folder()).join(&self.uuid)).ok(); } db_run! { conn: { @@ -248,13 +237,13 @@ impl Send { } /// Purge all sends that are past their deletion date. - pub async fn purge(conn: &DbConn) { + pub async fn purge(conn: &mut DbConn) { for send in Self::find_by_past_deletion_date(conn).await { send.delete(conn).await.ok(); } } - pub async fn update_users_revision(&self, conn: &DbConn) -> Vec { + pub async fn update_users_revision(&self, conn: &mut DbConn) -> Vec { let mut user_uuids = Vec::new(); match &self.user_uuid { Some(user_uuid) => { @@ -268,58 +257,49 @@ impl Send { user_uuids } - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult { for send in Self::find_by_user(user_uuid, conn).await { send.delete(conn).await?; } Ok(()) } - pub async fn find_by_access_id(access_id: &str, conn: &DbConn) -> Option { + pub async fn find_by_access_id(access_id: &str, conn: &mut DbConn) -> Option { use data_encoding::BASE64URL_NOPAD; use uuid::Uuid; - let Ok(uuid_vec) = BASE64URL_NOPAD.decode(access_id.as_bytes()) else { - return None; + let uuid_vec = match BASE64URL_NOPAD.decode(access_id.as_bytes()) { + Ok(v) => v, + Err(_) => return None, }; let uuid = match Uuid::from_slice(&uuid_vec) { - Ok(u) => SendId::from(u.to_string()), + Ok(u) => u.to_string(), Err(_) => return None, }; Self::find_by_uuid(&uuid, conn).await } - pub async fn find_by_uuid(uuid: &SendId, conn: &DbConn) -> Option { - db_run! { conn: { + pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option { + db_run! {conn: { sends::table .filter(sends::uuid.eq(uuid)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_by_uuid_and_user(uuid: &SendId, user_uuid: &UserId, conn: &DbConn) -> Option { - db_run! { conn: { - sends::table - .filter(sends::uuid.eq(uuid)) - .filter(sends::user_uuid.eq(user_uuid)) - .first::(conn) - .ok() - }} - } - - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { - db_run! { conn: { + pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { + db_run! {conn: { sends::table .filter(sends::user_uuid.eq(user_uuid)) - .load::(conn) - .expect("Error loading sends") + .load::(conn).expect("Error loading sends").from_db() }} } - pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option { + pub async fn size_by_user(user_uuid: &str, conn: &mut DbConn) -> Option { let sends = Self::find_by_user(user_uuid, conn).await; #[derive(serde::Deserialize)] @@ -342,67 +322,20 @@ impl Send { Some(total) } - pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - db_run! { conn: { + pub async fn find_by_org(org_uuid: &str, conn: &mut DbConn) -> Vec { + db_run! {conn: { sends::table .filter(sends::organization_uuid.eq(org_uuid)) - .load::(conn) - .expect("Error loading sends") + .load::(conn).expect("Error loading sends").from_db() }} } - pub async fn find_by_past_deletion_date(conn: &DbConn) -> Vec { + pub async fn find_by_past_deletion_date(conn: &mut DbConn) -> Vec { let now = Utc::now().naive_utc(); - db_run! { conn: { + db_run! {conn: { sends::table .filter(sends::deletion_date.lt(now)) - .load::(conn) - .expect("Error loading sends") + .load::(conn).expect("Error loading sends").from_db() }} } } - -// separate namespace to avoid name collision with std::marker::Send -pub mod id { - use derive_more::{AsRef, Deref, Display, From}; - use macros::{IdFromParam, UuidFromParam}; - use std::marker::Send; - use std::path::Path; - - #[derive( - Clone, - Debug, - AsRef, - Deref, - DieselNewType, - Display, - From, - FromForm, - Hash, - PartialEq, - Eq, - Serialize, - Deserialize, - UuidFromParam, - )] - pub struct SendId(String); - - impl AsRef for SendId { - #[inline] - fn as_ref(&self) -> &Path { - Path::new(&self.0) - } - } - - #[derive( - Clone, Debug, AsRef, Deref, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam, - )] - pub struct SendFileId(String); - - impl AsRef for SendFileId { - #[inline] - fn as_ref(&self) -> &Path { - Path::new(&self.0) - } - } -} diff --git a/src/db/models/sso_auth.rs b/src/db/models/sso_auth.rs deleted file mode 100644 index 2c6eec6d..00000000 --- a/src/db/models/sso_auth.rs +++ /dev/null @@ -1,142 +0,0 @@ -use chrono::{NaiveDateTime, Utc}; -use std::time::Duration; - -use crate::api::EmptyResult; -use crate::db::schema::sso_auth; -use crate::db::{DbConn, DbPool}; -use crate::error::MapResult; -use crate::sso::{OIDCCode, OIDCCodeChallenge, OIDCIdentifier, OIDCState, SSO_AUTH_EXPIRATION}; - -use diesel::deserialize::FromSql; -use diesel::expression::AsExpression; -use diesel::prelude::*; -use diesel::serialize::{Output, ToSql}; -use diesel::sql_types::Text; - -#[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)] -#[diesel(sql_type = Text)] -pub enum OIDCCodeWrapper { - Ok { - code: OIDCCode, - }, - Error { - error: String, - error_description: Option, - }, -} - -impl_FromToSqlText!(OIDCCodeWrapper); - -#[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)] -#[diesel(sql_type = Text)] -pub struct OIDCAuthenticatedUser { - pub refresh_token: Option, - pub access_token: String, - pub expires_in: Option, - pub identifier: OIDCIdentifier, - pub email: String, - pub email_verified: Option, - pub user_name: Option, -} - -impl_FromToSqlText!(OIDCAuthenticatedUser); - -#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] -#[diesel(table_name = sso_auth)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(state))] -pub struct SsoAuth { - pub state: OIDCState, - pub client_challenge: OIDCCodeChallenge, - pub nonce: String, - pub redirect_uri: String, - pub code_response: Option, - pub auth_response: Option, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, - pub binding_hash: Option, -} - -/// Local methods -impl SsoAuth { - pub fn new( - state: OIDCState, - client_challenge: OIDCCodeChallenge, - nonce: String, - redirect_uri: String, - binding_hash: Option, - ) -> Self { - let now = Utc::now().naive_utc(); - - SsoAuth { - state, - client_challenge, - nonce, - redirect_uri, - created_at: now, - updated_at: now, - code_response: None, - auth_response: None, - binding_hash, - } - } -} - -/// Database methods -impl SsoAuth { - pub async fn save(&self, conn: &DbConn) -> EmptyResult { - db_run! { conn: - mysql { - diesel::insert_into(sso_auth::table) - .values(self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(self) - .execute(conn) - .map_res("Error saving SSO auth") - } - postgresql, sqlite { - diesel::insert_into(sso_auth::table) - .values(self) - .on_conflict(sso_auth::state) - .do_update() - .set(self) - .execute(conn) - .map_res("Error saving SSO auth") - } - } - } - - pub async fn find(state: &OIDCState, conn: &DbConn) -> Option { - let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION; - db_run! { conn: { - sso_auth::table - .filter(sso_auth::state.eq(state)) - .filter(sso_auth::created_at.ge(oldest)) - .first::(conn) - .ok() - }} - } - - pub async fn delete(self, conn: &DbConn) -> EmptyResult { - db_run! {conn: { - diesel::delete(sso_auth::table.filter(sso_auth::state.eq(self.state))) - .execute(conn) - .map_res("Error deleting sso_auth") - }} - } - - pub async fn delete_expired(pool: DbPool) -> EmptyResult { - debug!("Purging expired sso_auth"); - if let Ok(conn) = pool.get().await { - let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION; - db_run! { conn: { - diesel::delete(sso_auth::table.filter(sso_auth::created_at.lt(oldest))) - .execute(conn) - .map_res("Error deleting expired SSO nonce") - }} - } else { - err!("Failed to get DB connection while purging expired sso_auth") - } - } -} diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 0dc08e3e..9155c518 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -1,25 +1,22 @@ -use super::UserId; -use crate::api::core::two_factor::webauthn::WebauthnRegistration; -use crate::db::schema::twofactor; -use crate::{api::EmptyResult, db::DbConn, error::MapResult}; -use diesel::prelude::*; use serde_json::Value; -use webauthn_rs::prelude::{Credential, ParsedAttestation}; -use webauthn_rs_core::proto::CredentialV3; -use webauthn_rs_proto::{AttestationFormat, RegisteredExtensions}; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = twofactor)] -#[diesel(primary_key(uuid))] -pub struct TwoFactor { - pub uuid: TwoFactorId, - pub user_uuid: UserId, - pub atype: i32, - pub enabled: bool, - pub data: String, - pub last_used: i64, +use crate::{api::EmptyResult, db::DbConn, error::MapResult}; + +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = twofactor)] + #[diesel(primary_key(uuid))] + pub struct TwoFactor { + pub uuid: String, + pub user_uuid: String, + pub atype: i32, + pub enabled: bool, + pub data: String, + pub last_used: i64, + } } +#[allow(dead_code)] #[derive(num_derive::FromPrimitive)] pub enum TwoFactorType { Authenticator = 0, @@ -30,7 +27,6 @@ pub enum TwoFactorType { Remember = 5, OrganizationDuo = 6, Webauthn = 7, - RecoveryCode = 8, // These are implementation details U2fRegisterChallenge = 1000, @@ -45,9 +41,9 @@ pub enum TwoFactorType { /// Local methods impl TwoFactor { - pub fn new(user_uuid: UserId, atype: TwoFactorType, data: String) -> Self { + pub fn new(user_uuid: String, atype: TwoFactorType, data: String) -> Self { Self { - uuid: TwoFactorId(crate::util::get_uuid()), + uuid: crate::util::get_uuid(), user_uuid, atype: atype as i32, enabled: true, @@ -75,11 +71,11 @@ impl TwoFactor { /// Database methods impl TwoFactor { - pub async fn save(&self, conn: &DbConn) -> EmptyResult { + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { match diesel::replace_into(twofactor::table) - .values(self) + .values(TwoFactorDb::to_db(self)) .execute(conn) { Ok(_) => Ok(()), @@ -87,7 +83,7 @@ impl TwoFactor { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(twofactor::table) .filter(twofactor::uuid.eq(&self.uuid)) - .set(self) + .set(TwoFactorDb::to_db(self)) .execute(conn) .map_res("Error saving twofactor") } @@ -95,6 +91,7 @@ impl TwoFactor { }.map_res("Error saving twofactor") } postgresql { + let value = TwoFactorDb::to_db(self); // We need to make sure we're not going to violate the unique constraint on user_uuid and atype. // This happens automatically on other DBMS backends due to replace_into(). PostgreSQL does // not support multiple constraints on ON CONFLICT clauses. @@ -103,17 +100,17 @@ impl TwoFactor { .map_res("Error deleting twofactor for insert")?; diesel::insert_into(twofactor::table) - .values(self) + .values(&value) .on_conflict(twofactor::uuid) .do_update() - .set(self) + .set(&value) .execute(conn) .map_res("Error saving twofactor") } } } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(twofactor::table.filter(twofactor::uuid.eq(self.uuid))) .execute(conn) @@ -121,27 +118,29 @@ impl TwoFactor { }} } - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { db_run! { conn: { twofactor::table .filter(twofactor::user_uuid.eq(user_uuid)) .filter(twofactor::atype.lt(1000)) // Filter implementation types - .load::(conn) + .load::(conn) .expect("Error loading twofactor") + .from_db() }} } - pub async fn find_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Option { + pub async fn find_by_user_and_type(user_uuid: &str, atype: i32, conn: &mut DbConn) -> Option { db_run! { conn: { twofactor::table .filter(twofactor::user_uuid.eq(user_uuid)) .filter(twofactor::atype.eq(atype)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(twofactor::table.filter(twofactor::user_uuid.eq(user_uuid))) .execute(conn) @@ -149,18 +148,18 @@ impl TwoFactor { }} } - pub async fn migrate_u2f_to_webauthn(conn: &DbConn) -> EmptyResult { + pub async fn migrate_u2f_to_webauthn(conn: &mut DbConn) -> EmptyResult { let u2f_factors = db_run! { conn: { twofactor::table .filter(twofactor::atype.eq(TwoFactorType::U2f as i32)) - .load::(conn) + .load::(conn) .expect("Error loading twofactor") + .from_db() }}; use crate::api::core::two_factor::webauthn::U2FRegistration; use crate::api::core::two_factor::webauthn::{get_webauthn_registrations, WebauthnRegistration}; - use webauthn_rs::prelude::{COSEEC2Key, COSEKey, COSEKeyType, ECDSACurve}; - use webauthn_rs_proto::{COSEAlgorithm, UserVerificationPolicy}; + use webauthn_rs::proto::*; for mut u2f in u2f_factors { let mut regs: Vec = serde_json::from_str(&u2f.data)?; @@ -184,8 +183,8 @@ impl TwoFactor { type_: COSEAlgorithm::ES256, key: COSEKeyType::EC_EC2(COSEEC2Key { curve: ECDSACurve::SECP256R1, - x: x.into(), - y: y.into(), + x, + y, }), }; @@ -195,19 +194,11 @@ impl TwoFactor { name: reg.name.clone(), credential: Credential { counter: reg.counter, - user_verified: false, + verified: false, cred: key, - cred_id: reg.reg.key_handle.clone().into(), - registration_policy: UserVerificationPolicy::Discouraged_DO_NOT_USE, - - transports: None, - backup_eligible: false, - backup_state: false, - extensions: RegisteredExtensions::none(), - attestation: ParsedAttestation::default(), - attestation_format: AttestationFormat::None, - } - .into(), + cred_id: reg.reg.key_handle.clone(), + registration_policy: UserVerificationPolicy::Discouraged, + }, }; webauthn_regs.push(new_reg); @@ -225,51 +216,4 @@ impl TwoFactor { Ok(()) } - - pub async fn migrate_credential_to_passkey(conn: &DbConn) -> EmptyResult { - let webauthn_factors = db_run! { conn: { - twofactor::table - .filter(twofactor::atype.eq(TwoFactorType::Webauthn as i32)) - .load::(conn) - .expect("Error loading twofactor") - }}; - - for webauthn_factor in webauthn_factors { - // assume that a failure to parse into the old struct, means that it was already converted - // alternatively this could also be checked via an extra field in the db - let Ok(regs) = serde_json::from_str::>(&webauthn_factor.data) else { - continue; - }; - - let regs = regs.into_iter().map(|r| r.into()).collect::>(); - - TwoFactor::new(webauthn_factor.user_uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®s)?) - .save(conn) - .await?; - } - - Ok(()) - } -} - -#[derive(Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct TwoFactorId(String); - -#[derive(Deserialize)] -pub struct WebauthnRegistrationV3 { - pub id: i32, - pub name: String, - pub migrated: bool, - pub credential: CredentialV3, -} - -impl From for WebauthnRegistration { - fn from(value: WebauthnRegistrationV3) -> Self { - Self { - id: value.id, - name: value.name, - migrated: value.migrated, - credential: Credential::from(value.credential).into(), - } - } } diff --git a/src/db/models/two_factor_duo_context.rs b/src/db/models/two_factor_duo_context.rs index 205a57d8..3e742d35 100644 --- a/src/db/models/two_factor_duo_context.rs +++ b/src/db/models/two_factor_duo_context.rs @@ -1,30 +1,33 @@ use chrono::Utc; -use crate::db::schema::twofactor_duo_ctx; use crate::{api::EmptyResult, db::DbConn, error::MapResult}; -use diesel::prelude::*; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = twofactor_duo_ctx)] -#[diesel(primary_key(state))] -pub struct TwoFactorDuoContext { - pub state: String, - pub user_email: String, - pub nonce: String, - pub exp: i64, +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = twofactor_duo_ctx)] + #[diesel(primary_key(state))] + pub struct TwoFactorDuoContext { + pub state: String, + pub user_email: String, + pub nonce: String, + pub exp: i64, + } } impl TwoFactorDuoContext { - pub async fn find_by_state(state: &str, conn: &DbConn) -> Option { - db_run! { conn: { - twofactor_duo_ctx::table - .filter(twofactor_duo_ctx::state.eq(state)) - .first::(conn) - .ok() - }} + pub async fn find_by_state(state: &str, conn: &mut DbConn) -> Option { + db_run! { + conn: { + twofactor_duo_ctx::table + .filter(twofactor_duo_ctx::state.eq(state)) + .first::(conn) + .ok() + .from_db() + } + } } - pub async fn save(state: &str, user_email: &str, nonce: &str, ttl: i64, conn: &DbConn) -> EmptyResult { + pub async fn save(state: &str, user_email: &str, nonce: &str, ttl: i64, conn: &mut DbConn) -> EmptyResult { // A saved context should never be changed, only created or deleted. let exists = Self::find_by_state(state, conn).await; if exists.is_some() { @@ -33,40 +36,47 @@ impl TwoFactorDuoContext { let exp = Utc::now().timestamp() + ttl; - db_run! { conn: { - diesel::insert_into(twofactor_duo_ctx::table) - .values(( - twofactor_duo_ctx::state.eq(state), - twofactor_duo_ctx::user_email.eq(user_email), - twofactor_duo_ctx::nonce.eq(nonce), - twofactor_duo_ctx::exp.eq(exp) - )) - .execute(conn) - .map_res("Error saving context to twofactor_duo_ctx") - }} - } - - pub async fn find_expired(conn: &DbConn) -> Vec { - let now = Utc::now().timestamp(); - db_run! { conn: { - twofactor_duo_ctx::table - .filter(twofactor_duo_ctx::exp.lt(now)) - .load::(conn) - .expect("Error finding expired contexts in twofactor_duo_ctx") - }} - } - - pub async fn delete(&self, conn: &DbConn) -> EmptyResult { - db_run! { conn: { - diesel::delete( - twofactor_duo_ctx::table - .filter(twofactor_duo_ctx::state.eq(&self.state))) + db_run! { + conn: { + diesel::insert_into(twofactor_duo_ctx::table) + .values(( + twofactor_duo_ctx::state.eq(state), + twofactor_duo_ctx::user_email.eq(user_email), + twofactor_duo_ctx::nonce.eq(nonce), + twofactor_duo_ctx::exp.eq(exp) + )) .execute(conn) - .map_res("Error deleting from twofactor_duo_ctx") - }} + .map_res("Error saving context to twofactor_duo_ctx") + } + } } - pub async fn purge_expired_duo_contexts(conn: &DbConn) { + pub async fn find_expired(conn: &mut DbConn) -> Vec { + let now = Utc::now().timestamp(); + db_run! { + conn: { + twofactor_duo_ctx::table + .filter(twofactor_duo_ctx::exp.lt(now)) + .load::(conn) + .expect("Error finding expired contexts in twofactor_duo_ctx") + .from_db() + } + } + } + + pub async fn delete(&self, conn: &mut DbConn) -> EmptyResult { + db_run! { + conn: { + diesel::delete( + twofactor_duo_ctx::table + .filter(twofactor_duo_ctx::state.eq(&self.state))) + .execute(conn) + .map_res("Error deleting from twofactor_duo_ctx") + } + } + } + + pub async fn purge_expired_duo_contexts(conn: &mut DbConn) { for context in Self::find_expired(conn).await { context.delete(conn).await.ok(); } diff --git a/src/db/models/two_factor_incomplete.rs b/src/db/models/two_factor_incomplete.rs index 2f7e4779..12813eb5 100644 --- a/src/db/models/two_factor_incomplete.rs +++ b/src/db/models/two_factor_incomplete.rs @@ -1,41 +1,32 @@ use chrono::{NaiveDateTime, Utc}; -use crate::db::schema::twofactor_incomplete; -use crate::{ - api::EmptyResult, - auth::ClientIp, - db::{ - models::{DeviceId, UserId}, - DbConn, - }, - error::MapResult, - CONFIG, -}; -use diesel::prelude::*; +use crate::{api::EmptyResult, auth::ClientIp, db::DbConn, error::MapResult, CONFIG}; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = twofactor_incomplete)] -#[diesel(primary_key(user_uuid, device_uuid))] -pub struct TwoFactorIncomplete { - pub user_uuid: UserId, - // This device UUID is simply what's claimed by the device. It doesn't - // necessarily correspond to any UUID in the devices table, since a device - // must complete 2FA login before being added into the devices table. - pub device_uuid: DeviceId, - pub device_name: String, - pub device_type: i32, - pub login_time: NaiveDateTime, - pub ip_address: String, +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = twofactor_incomplete)] + #[diesel(primary_key(user_uuid, device_uuid))] + pub struct TwoFactorIncomplete { + pub user_uuid: String, + // This device UUID is simply what's claimed by the device. It doesn't + // necessarily correspond to any UUID in the devices table, since a device + // must complete 2FA login before being added into the devices table. + pub device_uuid: String, + pub device_name: String, + pub device_type: i32, + pub login_time: NaiveDateTime, + pub ip_address: String, + } } impl TwoFactorIncomplete { pub async fn mark_incomplete( - user_uuid: &UserId, - device_uuid: &DeviceId, + user_uuid: &str, + device_uuid: &str, device_name: &str, device_type: i32, ip: &ClientIp, - conn: &DbConn, + conn: &mut DbConn, ) -> EmptyResult { if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { return Ok(()); @@ -64,7 +55,7 @@ impl TwoFactorIncomplete { }} } - pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult { + pub async fn mark_complete(user_uuid: &str, device_uuid: &str, conn: &mut DbConn) -> EmptyResult { if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { return Ok(()); } @@ -72,30 +63,32 @@ impl TwoFactorIncomplete { Self::delete_by_user_and_device(user_uuid, device_uuid, conn).await } - pub async fn find_by_user_and_device(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> Option { + pub async fn find_by_user_and_device(user_uuid: &str, device_uuid: &str, conn: &mut DbConn) -> Option { db_run! { conn: { twofactor_incomplete::table .filter(twofactor_incomplete::user_uuid.eq(user_uuid)) .filter(twofactor_incomplete::device_uuid.eq(device_uuid)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_logins_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec { - db_run! { conn: { + pub async fn find_logins_before(dt: &NaiveDateTime, conn: &mut DbConn) -> Vec { + db_run! {conn: { twofactor_incomplete::table .filter(twofactor_incomplete::login_time.lt(dt)) - .load::(conn) + .load::(conn) .expect("Error loading twofactor_incomplete") + .from_db() }} } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { Self::delete_by_user_and_device(&self.user_uuid, &self.device_uuid, conn).await } - pub async fn delete_by_user_and_device(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult { + pub async fn delete_by_user_and_device(user_uuid: &str, device_uuid: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(twofactor_incomplete::table .filter(twofactor_incomplete::user_uuid.eq(user_uuid)) @@ -105,7 +98,7 @@ impl TwoFactorIncomplete { }} } - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(twofactor_incomplete::table.filter(twofactor_incomplete::user_uuid.eq(user_uuid))) .execute(conn) diff --git a/src/db/models/user.rs b/src/db/models/user.rs index ebc72101..94f42c84 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -1,85 +1,66 @@ -use crate::db::schema::{invitations, sso_users, twofactor_incomplete, users}; +use crate::util::{format_date, get_uuid, retry}; use chrono::{NaiveDateTime, TimeDelta, Utc}; -use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; use serde_json::Value; -use super::{ - Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete, -}; -use crate::{ - api::EmptyResult, - crypto, - db::{models::DeviceId, DbConn}, - error::MapResult, - sso::OIDCIdentifier, - util::{format_date, get_uuid, retry}, - CONFIG, -}; -use macros::UuidFromParam; +use crate::crypto; +use crate::CONFIG; -#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] -#[diesel(table_name = users)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid))] -pub struct User { - pub uuid: UserId, - pub enabled: bool, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, - pub verified_at: Option, - pub last_verifying_at: Option, - pub login_verify_count: i32, +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = users)] + #[diesel(treat_none_as_null = true)] + #[diesel(primary_key(uuid))] + pub struct User { + pub uuid: String, + pub enabled: bool, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub verified_at: Option, + pub last_verifying_at: Option, + pub login_verify_count: i32, - pub email: String, - pub email_new: Option, - pub email_new_token: Option, - pub name: String, + pub email: String, + pub email_new: Option, + pub email_new_token: Option, + pub name: String, - pub password_hash: Vec, - pub salt: Vec, - pub password_iterations: i32, - pub password_hint: Option, + pub password_hash: Vec, + pub salt: Vec, + pub password_iterations: i32, + pub password_hint: Option, - pub akey: String, - pub private_key: Option, - pub public_key: Option, + pub akey: String, + pub private_key: Option, + pub public_key: Option, - #[diesel(column_name = "totp_secret")] // Note, this is only added to the UserDb structs, not to User - _totp_secret: Option, - pub totp_recover: Option, + #[diesel(column_name = "totp_secret")] // Note, this is only added to the UserDb structs, not to User + _totp_secret: Option, + pub totp_recover: Option, - pub security_stamp: String, - pub stamp_exception: Option, + pub security_stamp: String, + pub stamp_exception: Option, - pub equivalent_domains: String, - pub excluded_globals: String, + pub equivalent_domains: String, + pub excluded_globals: String, - pub client_kdf_type: i32, - pub client_kdf_iter: i32, - pub client_kdf_memory: Option, - pub client_kdf_parallelism: Option, + pub client_kdf_type: i32, + pub client_kdf_iter: i32, + pub client_kdf_memory: Option, + pub client_kdf_parallelism: Option, - pub api_key: Option, + pub api_key: Option, - pub avatar_color: Option, + pub avatar_color: Option, - pub external_id: Option, // Todo: Needs to be removed in the future, this is not used anymore. -} + pub external_id: Option, // Todo: Needs to be removed in the future, this is not used anymore. + } -#[derive(Identifiable, Queryable, Insertable)] -#[diesel(table_name = invitations)] -#[diesel(primary_key(email))] -pub struct Invitation { - pub email: String, -} - -#[derive(Identifiable, Queryable, Insertable, Selectable)] -#[diesel(table_name = sso_users)] -#[diesel(primary_key(user_uuid))] -pub struct SsoUser { - pub user_uuid: UserId, - pub identifier: OIDCIdentifier, + #[derive(Identifiable, Queryable, Insertable)] + #[diesel(table_name = invitations)] + #[diesel(primary_key(email))] + pub struct Invitation { + pub email: String, + } } pub enum UserKdfType { @@ -105,19 +86,19 @@ impl User { pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32; pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000; - pub fn new(email: &str, name: Option) -> Self { + pub fn new(email: String) -> Self { let now = Utc::now().naive_utc(); let email = email.to_lowercase(); Self { - uuid: UserId(get_uuid()), + uuid: get_uuid(), enabled: true, created_at: now, updated_at: now, verified_at: None, last_verifying_at: None, login_verify_count: 0, - name: name.unwrap_or(email.clone()), + name: email.clone(), email, akey: String::new(), email_new: None, @@ -182,17 +163,16 @@ impl User { /// * `password` - A str which contains a hashed version of the users master password. /// * `new_key` - A String which contains the new aKey value of the users master password. /// * `allow_next_route` - A Option> with the function names of the next allowed (rocket) routes. - /// These routes are able to use the previous stamp id for the next 2 minutes. - /// After these 2 minutes this stamp will expire. + /// These routes are able to use the previous stamp id for the next 2 minutes. + /// After these 2 minutes this stamp will expire. /// - pub async fn set_password( + pub fn set_password( &mut self, password: &str, new_key: Option, reset_security_stamp: bool, allow_next_route: Option>, - conn: &DbConn, - ) -> EmptyResult { + ) { self.password_hash = crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations as u32); if let Some(route) = allow_next_route { @@ -204,23 +184,20 @@ impl User { } if reset_security_stamp { - self.reset_security_stamp(conn).await?; + self.reset_security_stamp() } - Ok(()) } - pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult { + pub fn reset_security_stamp(&mut self) { self.security_stamp = get_uuid(); - Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?; - Ok(()) } /// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp. /// /// # Arguments /// * `route_exception` - A Vec with the function names of the next allowed (rocket) routes. - /// These routes are able to use the previous stamp id for the next 2 minutes. - /// After these 2 minutes this stamp will expire. + /// These routes are able to use the previous stamp id for the next 2 minutes. + /// After these 2 minutes this stamp will expire. /// pub fn set_stamp_exception(&mut self, route_exception: Vec) { let stamp_exception = UserStampException { @@ -235,22 +212,22 @@ impl User { pub fn reset_stamp_exception(&mut self) { self.stamp_exception = None; } - - pub fn display_name(&self) -> &str { - // default to email if name is empty - if !&self.name.is_empty() { - &self.name - } else { - &self.email - } - } } +use super::{ + Cipher, Device, EmergencyAccess, Favorite, Folder, Send, TwoFactor, TwoFactorIncomplete, UserOrgType, + UserOrganization, +}; +use crate::db::DbConn; + +use crate::api::EmptyResult; +use crate::error::MapResult; + /// Database methods impl User { - pub async fn to_json(&self, conn: &DbConn) -> Value { + pub async fn to_json(&self, conn: &mut DbConn) -> Value { let mut orgs_json = Vec::new(); - for c in Membership::find_confirmed_by_user(&self.uuid, conn).await { + for c in UserOrganization::find_confirmed_by_user(&self.uuid, conn).await { orgs_json.push(c.to_json(conn).await); } @@ -271,6 +248,7 @@ impl User { "emailVerified": !CONFIG.mail_enabled() || self.verified_at.is_some(), "premium": true, "premiumFromOrganization": false, + "masterPasswordHint": self.password_hint, "culture": "en-US", "twoFactorEnabled": twofactor_enabled, "key": self.akey, @@ -287,48 +265,58 @@ 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)) + pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { + if self.email.trim().is_empty() { + err!("User email can't be empty") } self.updated_at = Utc::now().naive_utc(); - db_run! { conn: - mysql { - diesel::insert_into(users::table) - .values(&*self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(&*self) + db_run! {conn: + sqlite, mysql { + match diesel::replace_into(users::table) + .values(UserDb::to_db(self)) .execute(conn) - .map_res("Error saving user") + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(users::table) + .filter(users::uuid.eq(&self.uuid)) + .set(UserDb::to_db(self)) + .execute(conn) + .map_res("Error saving user") + } + Err(e) => Err(e.into()), + }.map_res("Error saving user") } - postgresql, sqlite { + postgresql { + let value = UserDb::to_db(self); diesel::insert_into(users::table) // Insert or update - .values(&*self) + .values(&value) .on_conflict(users::uuid) .do_update() - .set(&*self) + .set(&value) .execute(conn) .map_res("Error saving user") } } } - 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 + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { + for user_org in UserOrganization::find_confirmed_by_user(&self.uuid, conn).await { + if user_org.atype == UserOrgType::Owner + && UserOrganization::count_confirmed_by_org_and_type(&user_org.org_uuid, UserOrgType::Owner, conn).await + <= 1 { err!("Can't delete last owner") } } - super::Send::delete_all_by_user(&self.uuid, conn).await?; + Send::delete_all_by_user(&self.uuid, conn).await?; EmergencyAccess::delete_all_by_user(&self.uuid, conn).await?; EmergencyAccess::delete_all_by_grantee_email(&self.email, conn).await?; - Membership::delete_all_by_user(&self.uuid, conn).await?; + UserOrganization::delete_all_by_user(&self.uuid, conn).await?; 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?; @@ -337,23 +325,23 @@ impl User { TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?; Invitation::take(&self.email, conn).await; // Delete invitation if any - db_run! { conn: { + db_run! {conn: { diesel::delete(users::table.filter(users::uuid.eq(self.uuid))) .execute(conn) .map_res("Error deleting user") }} } - pub async fn update_uuid_revision(uuid: &UserId, conn: &DbConn) { + pub async fn update_uuid_revision(uuid: &str, conn: &mut DbConn) { if let Err(e) = Self::_update_revision(uuid, &Utc::now().naive_utc(), conn).await { - warn!("Failed to update revision for {uuid}: {e:#?}"); + warn!("Failed to update revision for {}: {:#?}", uuid, e); } } - pub async fn update_all_revisions(conn: &DbConn) -> EmptyResult { + pub async fn update_all_revisions(conn: &mut DbConn) -> EmptyResult { let updated_at = Utc::now().naive_utc(); - db_run! { conn: { + db_run! {conn: { retry(|| { diesel::update(users::table) .set(users::updated_at.eq(updated_at)) @@ -363,14 +351,14 @@ impl User { }} } - pub async fn update_revision(&mut self, conn: &DbConn) -> EmptyResult { + pub async fn update_revision(&mut self, conn: &mut DbConn) -> EmptyResult { self.updated_at = Utc::now().naive_utc(); Self::_update_revision(&self.uuid, &self.updated_at, conn).await } - async fn _update_revision(uuid: &UserId, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { - db_run! { conn: { + async fn _update_revision(uuid: &str, date: &NaiveDateTime, conn: &mut DbConn) -> EmptyResult { + db_run! {conn: { retry(|| { diesel::update(users::table.filter(users::uuid.eq(uuid))) .set(users::updated_at.eq(date)) @@ -380,52 +368,30 @@ impl User { }} } - pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option { + pub async fn find_by_mail(mail: &str, conn: &mut DbConn) -> Option { let lower_mail = mail.to_lowercase(); - db_run! { conn: { + db_run! {conn: { users::table .filter(users::email.eq(lower_mail)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn find_by_uuid(uuid: &UserId, conn: &DbConn) -> Option { - db_run! { conn: { - users::table - .filter(users::uuid.eq(uuid)) - .first::(conn) - .ok() + pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option { + db_run! {conn: { + users::table.filter(users::uuid.eq(uuid)).first::(conn).ok().from_db() }} } - pub async fn find_by_device_for_email2fa(device_uuid: &DeviceId, conn: &DbConn) -> Option { - if let Some(user_uuid) = db_run! ( conn: { - twofactor_incomplete::table - .filter(twofactor_incomplete::device_uuid.eq(device_uuid)) - .order_by(twofactor_incomplete::login_time.desc()) - .select(twofactor_incomplete::user_uuid) - .first::(conn) - .ok() - }) { - return Self::find_by_uuid(&user_uuid, conn).await; - } - None - } - - pub async fn get_all(conn: &DbConn) -> Vec<(Self, Option)> { - db_run! { conn: { - users::table - .left_join(sso_users::table) - .select(<(Self, Option)>::as_select()) - .load(conn) - .expect("Error loading groups for user") - .into_iter() - .collect() + pub async fn get_all(conn: &mut DbConn) -> Vec { + db_run! {conn: { + users::table.load::(conn).expect("Error loading users").from_db() }} } - pub async fn last_active(&self, conn: &DbConn) -> Option { + pub async fn last_active(&self, conn: &mut DbConn) -> Option { match Device::find_latest_active_by_user(&self.uuid, conn).await { Some(device) => Some(device.updated_at), None => None, @@ -441,23 +407,23 @@ 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)) + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { + if self.email.trim().is_empty() { + err!("Invitation email can't be empty") } - db_run! { conn: + db_run! {conn: sqlite, mysql { // Not checking for ForeignKey Constraints here // Table invitations does not have any ForeignKey Constraints. diesel::replace_into(invitations::table) - .values(self) + .values(InvitationDb::to_db(self)) .execute(conn) .map_res("Error saving invitation") } postgresql { diesel::insert_into(invitations::table) - .values(self) + .values(InvitationDb::to_db(self)) .on_conflict(invitations::email) .do_nothing() .execute(conn) @@ -466,99 +432,29 @@ impl Invitation { } } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { - db_run! { conn: { + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { + db_run! {conn: { diesel::delete(invitations::table.filter(invitations::email.eq(self.email))) .execute(conn) .map_res("Error deleting invitation") }} } - pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option { + pub async fn find_by_mail(mail: &str, conn: &mut DbConn) -> Option { let lower_mail = mail.to_lowercase(); - db_run! { conn: { + db_run! {conn: { invitations::table .filter(invitations::email.eq(lower_mail)) - .first::(conn) + .first::(conn) .ok() + .from_db() }} } - pub async fn take(mail: &str, conn: &DbConn) -> bool { + pub async fn take(mail: &str, conn: &mut DbConn) -> bool { match Self::find_by_mail(mail, conn).await { Some(invitation) => invitation.delete(conn).await.is_ok(), None => false, } } } - -#[derive( - Clone, - Debug, - DieselNewType, - FromForm, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - AsRef, - Deref, - Display, - From, - UuidFromParam, -)] -#[deref(forward)] -#[from(forward)] -pub struct UserId(String); - -impl SsoUser { - pub async fn save(&self, conn: &DbConn) -> EmptyResult { - db_run! { conn: - sqlite, mysql { - diesel::replace_into(sso_users::table) - .values(self) - .execute(conn) - .map_res("Error saving SSO user") - } - postgresql { - diesel::insert_into(sso_users::table) - .values(self) - .execute(conn) - .map_res("Error saving SSO user") - } - } - } - - pub async fn find_by_identifier(identifier: &str, conn: &DbConn) -> Option<(User, Self)> { - db_run! { conn: { - users::table - .inner_join(sso_users::table) - .select(<(User, Self)>::as_select()) - .filter(sso_users::identifier.eq(identifier)) - .first::<(User, Self)>(conn) - .ok() - }} - } - - pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option<(User, Option)> { - let lower_mail = mail.to_lowercase(); - - db_run! { conn: { - users::table - .left_join(sso_users::table) - .select(<(User, Option)>::as_select()) - .filter(users::email.eq(lower_mail)) - .first::<(User, Option)>(conn) - .ok() - }} - } - - pub async fn delete(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - db_run! { conn: { - diesel::delete(sso_users::table.filter(sso_users::user_uuid.eq(user_uuid))) - .execute(conn) - .map_res("Error deleting sso user") - }} - } -} diff --git a/src/db/query_logger.rs b/src/db/query_logger.rs deleted file mode 100644 index 0a207918..00000000 --- a/src/db/query_logger.rs +++ /dev/null @@ -1,57 +0,0 @@ -use diesel::connection::{Instrumentation, InstrumentationEvent}; -use std::{cell::RefCell, collections::HashMap, time::Instant}; - -thread_local! { - static QUERY_PERF_TRACKER: RefCell> = RefCell::new(HashMap::new()); -} - -pub fn simple_logger() -> Option> { - Some(Box::new(|event: InstrumentationEvent<'_>| match event { - InstrumentationEvent::StartEstablishConnection { - url, - .. - } => { - debug!("Establishing connection: {url}") - } - InstrumentationEvent::FinishEstablishConnection { - url, - error, - .. - } => { - if let Some(e) = error { - error!("Error during establishing a connection with {url}: {e:?}") - } else { - debug!("Connection established: {url}") - } - } - InstrumentationEvent::StartQuery { - query, - .. - } => { - let query_string = format!("{query:?}"); - let start = Instant::now(); - QUERY_PERF_TRACKER.with_borrow_mut(|map| { - map.insert(query_string, start); - }); - } - InstrumentationEvent::FinishQuery { - query, - .. - } => { - let query_string = format!("{query:?}"); - QUERY_PERF_TRACKER.with_borrow_mut(|map| { - if let Some(start) = map.remove(&query_string) { - let duration = start.elapsed(); - if duration.as_secs() >= 5 { - warn!("SLOW QUERY [{:.2}s]: {}", duration.as_secs_f32(), query_string); - } else if duration.as_secs() >= 1 { - info!("SLOW QUERY [{:.2}s]: {}", duration.as_secs_f32(), query_string); - } else { - debug!("QUERY [{:?}]: {}", duration, query_string); - } - } - }); - } - _ => {} - })) -} diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs new file mode 100644 index 00000000..fa84ed05 --- /dev/null +++ b/src/db/schemas/mysql/schema.rs @@ -0,0 +1,373 @@ +table! { + attachments (id) { + id -> Text, + cipher_uuid -> Text, + file_name -> Text, + file_size -> BigInt, + akey -> Nullable, + } +} + +table! { + ciphers (uuid) { + uuid -> Text, + created_at -> Datetime, + updated_at -> Datetime, + user_uuid -> Nullable, + organization_uuid -> Nullable, + key -> Nullable, + atype -> Integer, + name -> Text, + notes -> Nullable, + fields -> Nullable, + data -> Text, + password_history -> Nullable, + deleted_at -> Nullable, + reprompt -> Nullable, + } +} + +table! { + ciphers_collections (cipher_uuid, collection_uuid) { + cipher_uuid -> Text, + collection_uuid -> Text, + } +} + +table! { + collections (uuid) { + uuid -> Text, + org_uuid -> Text, + name -> Text, + external_id -> Nullable, + } +} + +table! { + devices (uuid, user_uuid) { + uuid -> Text, + created_at -> Datetime, + updated_at -> Datetime, + user_uuid -> Text, + name -> Text, + atype -> Integer, + push_uuid -> Nullable, + push_token -> Nullable, + refresh_token -> Text, + twofactor_remember -> Nullable, + } +} + +table! { + event (uuid) { + uuid -> Varchar, + event_type -> Integer, + user_uuid -> Nullable, + org_uuid -> Nullable, + cipher_uuid -> Nullable, + collection_uuid -> Nullable, + group_uuid -> Nullable, + org_user_uuid -> Nullable, + act_user_uuid -> Nullable, + device_type -> Nullable, + ip_address -> Nullable, + event_date -> Timestamp, + policy_uuid -> Nullable, + provider_uuid -> Nullable, + provider_user_uuid -> Nullable, + provider_org_uuid -> Nullable, + } +} + +table! { + favorites (user_uuid, cipher_uuid) { + user_uuid -> Text, + cipher_uuid -> Text, + } +} + +table! { + folders (uuid) { + uuid -> Text, + created_at -> Datetime, + updated_at -> Datetime, + user_uuid -> Text, + name -> Text, + } +} + +table! { + folders_ciphers (cipher_uuid, folder_uuid) { + cipher_uuid -> Text, + folder_uuid -> Text, + } +} + +table! { + invitations (email) { + email -> Text, + } +} + +table! { + org_policies (uuid) { + uuid -> Text, + org_uuid -> Text, + atype -> Integer, + enabled -> Bool, + data -> Text, + } +} + +table! { + organizations (uuid) { + uuid -> Text, + name -> Text, + billing_email -> Text, + private_key -> Nullable, + public_key -> Nullable, + } +} + +table! { + sends (uuid) { + uuid -> Text, + user_uuid -> Nullable, + organization_uuid -> Nullable, + name -> Text, + notes -> Nullable, + atype -> Integer, + data -> Text, + akey -> Text, + password_hash -> Nullable, + password_salt -> Nullable, + password_iter -> Nullable, + max_access_count -> Nullable, + access_count -> Integer, + creation_date -> Datetime, + revision_date -> Datetime, + expiration_date -> Nullable, + deletion_date -> Datetime, + disabled -> Bool, + hide_email -> Nullable, + } +} + +table! { + twofactor (uuid) { + uuid -> Text, + user_uuid -> Text, + atype -> Integer, + enabled -> Bool, + data -> Text, + last_used -> BigInt, + } +} + +table! { + twofactor_incomplete (user_uuid, device_uuid) { + user_uuid -> Text, + device_uuid -> Text, + device_name -> Text, + device_type -> Integer, + login_time -> Timestamp, + ip_address -> Text, + } +} + +table! { + twofactor_duo_ctx (state) { + state -> Text, + user_email -> Text, + nonce -> Text, + exp -> BigInt, + } +} + +table! { + users (uuid) { + uuid -> Text, + enabled -> Bool, + created_at -> Datetime, + updated_at -> Datetime, + verified_at -> Nullable, + last_verifying_at -> Nullable, + login_verify_count -> Integer, + email -> Text, + email_new -> Nullable, + email_new_token -> Nullable, + name -> Text, + password_hash -> Binary, + salt -> Binary, + password_iterations -> Integer, + password_hint -> Nullable, + akey -> Text, + private_key -> Nullable, + public_key -> Nullable, + totp_secret -> Nullable, + totp_recover -> Nullable, + security_stamp -> Text, + stamp_exception -> Nullable, + equivalent_domains -> Text, + excluded_globals -> Text, + client_kdf_type -> Integer, + client_kdf_iter -> Integer, + client_kdf_memory -> Nullable, + client_kdf_parallelism -> Nullable, + api_key -> Nullable, + avatar_color -> Nullable, + external_id -> Nullable, + } +} + +table! { + users_collections (user_uuid, collection_uuid) { + user_uuid -> Text, + collection_uuid -> Text, + read_only -> Bool, + hide_passwords -> Bool, + } +} + +table! { + users_organizations (uuid) { + uuid -> Text, + user_uuid -> Text, + org_uuid -> Text, + access_all -> Bool, + akey -> Text, + status -> Integer, + atype -> Integer, + reset_password_key -> Nullable, + external_id -> Nullable, + } +} + +table! { + organization_api_key (uuid, org_uuid) { + uuid -> Text, + org_uuid -> Text, + atype -> Integer, + api_key -> Text, + revision_date -> Timestamp, + } +} + +table! { + emergency_access (uuid) { + uuid -> Text, + grantor_uuid -> Text, + grantee_uuid -> Nullable, + email -> Nullable, + key_encrypted -> Nullable, + atype -> Integer, + status -> Integer, + wait_time_days -> Integer, + recovery_initiated_at -> Nullable, + last_notification_at -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +table! { + groups (uuid) { + uuid -> Text, + organizations_uuid -> Text, + name -> Text, + access_all -> Bool, + external_id -> Nullable, + creation_date -> Timestamp, + revision_date -> Timestamp, + } +} + +table! { + groups_users (groups_uuid, users_organizations_uuid) { + groups_uuid -> Text, + users_organizations_uuid -> Text, + } +} + +table! { + collections_groups (collections_uuid, groups_uuid) { + collections_uuid -> Text, + groups_uuid -> Text, + read_only -> Bool, + hide_passwords -> Bool, + } +} + +table! { + auth_requests (uuid) { + uuid -> Text, + user_uuid -> Text, + organization_uuid -> Nullable, + request_device_identifier -> Text, + device_type -> Integer, + request_ip -> Text, + response_device_id -> Nullable, + access_code -> Text, + public_key -> Text, + enc_key -> Nullable, + master_password_hash -> Nullable, + approved -> Nullable, + creation_date -> Timestamp, + response_date -> Nullable, + authentication_date -> Nullable, + } +} + +joinable!(attachments -> ciphers (cipher_uuid)); +joinable!(ciphers -> organizations (organization_uuid)); +joinable!(ciphers -> users (user_uuid)); +joinable!(ciphers_collections -> ciphers (cipher_uuid)); +joinable!(ciphers_collections -> collections (collection_uuid)); +joinable!(collections -> organizations (org_uuid)); +joinable!(devices -> users (user_uuid)); +joinable!(folders -> users (user_uuid)); +joinable!(folders_ciphers -> ciphers (cipher_uuid)); +joinable!(folders_ciphers -> folders (folder_uuid)); +joinable!(org_policies -> organizations (org_uuid)); +joinable!(sends -> organizations (organization_uuid)); +joinable!(sends -> users (user_uuid)); +joinable!(twofactor -> users (user_uuid)); +joinable!(users_collections -> collections (collection_uuid)); +joinable!(users_collections -> users (user_uuid)); +joinable!(users_organizations -> organizations (org_uuid)); +joinable!(users_organizations -> users (user_uuid)); +joinable!(users_organizations -> ciphers (org_uuid)); +joinable!(organization_api_key -> organizations (org_uuid)); +joinable!(emergency_access -> users (grantor_uuid)); +joinable!(groups -> organizations (organizations_uuid)); +joinable!(groups_users -> users_organizations (users_organizations_uuid)); +joinable!(groups_users -> groups (groups_uuid)); +joinable!(collections_groups -> collections (collections_uuid)); +joinable!(collections_groups -> groups (groups_uuid)); +joinable!(event -> users_organizations (uuid)); +joinable!(auth_requests -> users (user_uuid)); + +allow_tables_to_appear_in_same_query!( + attachments, + ciphers, + ciphers_collections, + collections, + devices, + folders, + folders_ciphers, + invitations, + org_policies, + organizations, + sends, + twofactor, + users, + users_collections, + users_organizations, + organization_api_key, + emergency_access, + groups, + groups_users, + collections_groups, + event, + auth_requests, +); diff --git a/src/db/schema.rs b/src/db/schemas/postgresql/schema.rs similarity index 91% rename from src/db/schema.rs rename to src/db/schemas/postgresql/schema.rs index bf79ceac..d1ea4b02 100644 --- a/src/db/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -226,7 +226,6 @@ table! { collection_uuid -> Text, read_only -> Bool, hide_passwords -> Bool, - manage -> Bool, } } @@ -235,7 +234,6 @@ table! { uuid -> Text, user_uuid -> Text, org_uuid -> Text, - invited_by_email -> Nullable, access_all -> Bool, akey -> Text, status -> Integer, @@ -255,27 +253,6 @@ table! { } } -table! { - sso_auth (state) { - state -> Text, - client_challenge -> Text, - nonce -> Text, - redirect_uri -> Text, - code_response -> Nullable, - auth_response -> Nullable, - created_at -> Timestamp, - updated_at -> Timestamp, - binding_hash -> Nullable, - } -} - -table! { - sso_users (user_uuid) { - user_uuid -> Text, - identifier -> Text, - } -} - table! { emergency_access (uuid) { uuid -> Text, @@ -318,7 +295,6 @@ table! { groups_uuid -> Text, read_only -> Bool, hide_passwords -> Bool, - manage -> Bool, } } @@ -342,16 +318,6 @@ table! { } } -table! { - archives (user_uuid, cipher_uuid) { - user_uuid -> Text, - cipher_uuid -> Text, - archived_at -> Timestamp, - } -} - -joinable!(archives -> users (user_uuid)); -joinable!(archives -> ciphers (cipher_uuid)); joinable!(attachments -> ciphers (cipher_uuid)); joinable!(ciphers -> organizations (organization_uuid)); joinable!(ciphers -> users (user_uuid)); @@ -380,10 +346,8 @@ joinable!(collections_groups -> collections (collections_uuid)); joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); -joinable!(sso_users -> users (user_uuid)); allow_tables_to_appear_in_same_query!( - archives, attachments, ciphers, ciphers_collections, @@ -395,7 +359,6 @@ allow_tables_to_appear_in_same_query!( org_policies, organizations, sends, - sso_users, twofactor, users, users_collections, diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs new file mode 100644 index 00000000..d1ea4b02 --- /dev/null +++ b/src/db/schemas/sqlite/schema.rs @@ -0,0 +1,373 @@ +table! { + attachments (id) { + id -> Text, + cipher_uuid -> Text, + file_name -> Text, + file_size -> BigInt, + akey -> Nullable, + } +} + +table! { + ciphers (uuid) { + uuid -> Text, + created_at -> Timestamp, + updated_at -> Timestamp, + user_uuid -> Nullable, + organization_uuid -> Nullable, + key -> Nullable, + atype -> Integer, + name -> Text, + notes -> Nullable, + fields -> Nullable, + data -> Text, + password_history -> Nullable, + deleted_at -> Nullable, + reprompt -> Nullable, + } +} + +table! { + ciphers_collections (cipher_uuid, collection_uuid) { + cipher_uuid -> Text, + collection_uuid -> Text, + } +} + +table! { + collections (uuid) { + uuid -> Text, + org_uuid -> Text, + name -> Text, + external_id -> Nullable, + } +} + +table! { + devices (uuid, user_uuid) { + uuid -> Text, + created_at -> Timestamp, + updated_at -> Timestamp, + user_uuid -> Text, + name -> Text, + atype -> Integer, + push_uuid -> Nullable, + push_token -> Nullable, + refresh_token -> Text, + twofactor_remember -> Nullable, + } +} + +table! { + event (uuid) { + uuid -> Text, + event_type -> Integer, + user_uuid -> Nullable, + org_uuid -> Nullable, + cipher_uuid -> Nullable, + collection_uuid -> Nullable, + group_uuid -> Nullable, + org_user_uuid -> Nullable, + act_user_uuid -> Nullable, + device_type -> Nullable, + ip_address -> Nullable, + event_date -> Timestamp, + policy_uuid -> Nullable, + provider_uuid -> Nullable, + provider_user_uuid -> Nullable, + provider_org_uuid -> Nullable, + } +} + +table! { + favorites (user_uuid, cipher_uuid) { + user_uuid -> Text, + cipher_uuid -> Text, + } +} + +table! { + folders (uuid) { + uuid -> Text, + created_at -> Timestamp, + updated_at -> Timestamp, + user_uuid -> Text, + name -> Text, + } +} + +table! { + folders_ciphers (cipher_uuid, folder_uuid) { + cipher_uuid -> Text, + folder_uuid -> Text, + } +} + +table! { + invitations (email) { + email -> Text, + } +} + +table! { + org_policies (uuid) { + uuid -> Text, + org_uuid -> Text, + atype -> Integer, + enabled -> Bool, + data -> Text, + } +} + +table! { + organizations (uuid) { + uuid -> Text, + name -> Text, + billing_email -> Text, + private_key -> Nullable, + public_key -> Nullable, + } +} + +table! { + sends (uuid) { + uuid -> Text, + user_uuid -> Nullable, + organization_uuid -> Nullable, + name -> Text, + notes -> Nullable, + atype -> Integer, + data -> Text, + akey -> Text, + password_hash -> Nullable, + password_salt -> Nullable, + password_iter -> Nullable, + max_access_count -> Nullable, + access_count -> Integer, + creation_date -> Timestamp, + revision_date -> Timestamp, + expiration_date -> Nullable, + deletion_date -> Timestamp, + disabled -> Bool, + hide_email -> Nullable, + } +} + +table! { + twofactor (uuid) { + uuid -> Text, + user_uuid -> Text, + atype -> Integer, + enabled -> Bool, + data -> Text, + last_used -> BigInt, + } +} + +table! { + twofactor_incomplete (user_uuid, device_uuid) { + user_uuid -> Text, + device_uuid -> Text, + device_name -> Text, + device_type -> Integer, + login_time -> Timestamp, + ip_address -> Text, + } +} + +table! { + twofactor_duo_ctx (state) { + state -> Text, + user_email -> Text, + nonce -> Text, + exp -> BigInt, + } +} + +table! { + users (uuid) { + uuid -> Text, + enabled -> Bool, + created_at -> Timestamp, + updated_at -> Timestamp, + verified_at -> Nullable, + last_verifying_at -> Nullable, + login_verify_count -> Integer, + email -> Text, + email_new -> Nullable, + email_new_token -> Nullable, + name -> Text, + password_hash -> Binary, + salt -> Binary, + password_iterations -> Integer, + password_hint -> Nullable, + akey -> Text, + private_key -> Nullable, + public_key -> Nullable, + totp_secret -> Nullable, + totp_recover -> Nullable, + security_stamp -> Text, + stamp_exception -> Nullable, + equivalent_domains -> Text, + excluded_globals -> Text, + client_kdf_type -> Integer, + client_kdf_iter -> Integer, + client_kdf_memory -> Nullable, + client_kdf_parallelism -> Nullable, + api_key -> Nullable, + avatar_color -> Nullable, + external_id -> Nullable, + } +} + +table! { + users_collections (user_uuid, collection_uuid) { + user_uuid -> Text, + collection_uuid -> Text, + read_only -> Bool, + hide_passwords -> Bool, + } +} + +table! { + users_organizations (uuid) { + uuid -> Text, + user_uuid -> Text, + org_uuid -> Text, + access_all -> Bool, + akey -> Text, + status -> Integer, + atype -> Integer, + reset_password_key -> Nullable, + external_id -> Nullable, + } +} + +table! { + organization_api_key (uuid, org_uuid) { + uuid -> Text, + org_uuid -> Text, + atype -> Integer, + api_key -> Text, + revision_date -> Timestamp, + } +} + +table! { + emergency_access (uuid) { + uuid -> Text, + grantor_uuid -> Text, + grantee_uuid -> Nullable, + email -> Nullable, + key_encrypted -> Nullable, + atype -> Integer, + status -> Integer, + wait_time_days -> Integer, + recovery_initiated_at -> Nullable, + last_notification_at -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +table! { + groups (uuid) { + uuid -> Text, + organizations_uuid -> Text, + name -> Text, + access_all -> Bool, + external_id -> Nullable, + creation_date -> Timestamp, + revision_date -> Timestamp, + } +} + +table! { + groups_users (groups_uuid, users_organizations_uuid) { + groups_uuid -> Text, + users_organizations_uuid -> Text, + } +} + +table! { + collections_groups (collections_uuid, groups_uuid) { + collections_uuid -> Text, + groups_uuid -> Text, + read_only -> Bool, + hide_passwords -> Bool, + } +} + +table! { + auth_requests (uuid) { + uuid -> Text, + user_uuid -> Text, + organization_uuid -> Nullable, + request_device_identifier -> Text, + device_type -> Integer, + request_ip -> Text, + response_device_id -> Nullable, + access_code -> Text, + public_key -> Text, + enc_key -> Nullable, + master_password_hash -> Nullable, + approved -> Nullable, + creation_date -> Timestamp, + response_date -> Nullable, + authentication_date -> Nullable, + } +} + +joinable!(attachments -> ciphers (cipher_uuid)); +joinable!(ciphers -> organizations (organization_uuid)); +joinable!(ciphers -> users (user_uuid)); +joinable!(ciphers_collections -> ciphers (cipher_uuid)); +joinable!(ciphers_collections -> collections (collection_uuid)); +joinable!(collections -> organizations (org_uuid)); +joinable!(devices -> users (user_uuid)); +joinable!(folders -> users (user_uuid)); +joinable!(folders_ciphers -> ciphers (cipher_uuid)); +joinable!(folders_ciphers -> folders (folder_uuid)); +joinable!(org_policies -> organizations (org_uuid)); +joinable!(sends -> organizations (organization_uuid)); +joinable!(sends -> users (user_uuid)); +joinable!(twofactor -> users (user_uuid)); +joinable!(users_collections -> collections (collection_uuid)); +joinable!(users_collections -> users (user_uuid)); +joinable!(users_organizations -> organizations (org_uuid)); +joinable!(users_organizations -> users (user_uuid)); +joinable!(users_organizations -> ciphers (org_uuid)); +joinable!(organization_api_key -> organizations (org_uuid)); +joinable!(emergency_access -> users (grantor_uuid)); +joinable!(groups -> organizations (organizations_uuid)); +joinable!(groups_users -> users_organizations (users_organizations_uuid)); +joinable!(groups_users -> groups (groups_uuid)); +joinable!(collections_groups -> collections (collections_uuid)); +joinable!(collections_groups -> groups (groups_uuid)); +joinable!(event -> users_organizations (uuid)); +joinable!(auth_requests -> users (user_uuid)); + +allow_tables_to_appear_in_same_query!( + attachments, + ciphers, + ciphers_collections, + collections, + devices, + folders, + folders_ciphers, + invitations, + org_policies, + organizations, + sends, + twofactor, + users, + users_collections, + users_organizations, + organization_api_key, + emergency_access, + groups, + groups_users, + collections_groups, + event, + auth_requests, +); diff --git a/src/error.rs b/src/error.rs index 1a258fd1..1061a08d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,7 +3,6 @@ // use crate::db::models::EventType; use crate::http_client::CustomHttpClientError; -use serde::ser::{Serialize, SerializeStruct, Serializer}; use std::error::Error as StdError; macro_rules! make_error { @@ -39,8 +38,7 @@ macro_rules! make_error { }; } -use diesel::r2d2::Error as R2d2Err; -use diesel::r2d2::PoolError as R2d2PoolErr; +use diesel::r2d2::PoolError as R2d2Err; use diesel::result::Error as DieselErr; use diesel::ConnectionError as DieselConErr; use handlebars::RenderError as HbErr; @@ -48,7 +46,6 @@ use jsonwebtoken::errors::Error as JwtErr; use lettre::address::AddressError as AddrErr; use lettre::error::Error as LettreErr; use lettre::transport::smtp::Error as SmtpErr; -use opendal::Error as OpenDALErr; use openssl::error::ErrorStack as SSLErr; use regex::Error as RegexErr; use reqwest::Error as ReqErr; @@ -56,14 +53,12 @@ use rocket::error::Error as RocketErr; use serde_json::{Error as SerdeErr, Value}; use std::io::Error as IoErr; use std::time::SystemTimeError as TimeErr; -use webauthn_rs::prelude::WebauthnError as WebauthnErr; +use webauthn_rs::error::WebauthnError as WebauthnErr; use yubico::yubicoerror::YubicoError as YubiErr; #[derive(Serialize)] pub struct Empty {} -pub struct Compact {} - // Error struct // Contains a String error message, meant for the user and an enum variant, with an error of different types. // @@ -74,19 +69,17 @@ make_error! { Empty(Empty): _no_source, _serialize, // Used to represent err! calls Simple(String): _no_source, _api_error, - Compact(Compact): _no_source, _compact_api_error, // Used in our custom http client to handle non-global IPs and blocked domains CustomHttpClient(CustomHttpClientError): _has_source, _api_error, // Used for special return values, like 2FA errors - Json(Value): _no_source, _serialize, - Db(DieselErr): _has_source, _api_error, - R2d2(R2d2Err): _has_source, _api_error, - R2d2Pool(R2d2PoolErr): _has_source, _api_error, - Serde(SerdeErr): _has_source, _api_error, - JWt(JwtErr): _has_source, _api_error, - Handlebars(HbErr): _has_source, _api_error, + Json(Value): _no_source, _serialize, + Db(DieselErr): _has_source, _api_error, + R2d2(R2d2Err): _has_source, _api_error, + Serde(SerdeErr): _has_source, _api_error, + JWt(JwtErr): _has_source, _api_error, + Handlebars(HbErr): _has_source, _api_error, Io(IoErr): _has_source, _api_error, Time(TimeErr): _has_source, _api_error, @@ -102,8 +95,6 @@ make_error! { DieselCon(DieselConErr): _has_source, _api_error, Webauthn(WebauthnErr): _has_source, _api_error, - - OpenDAL(OpenDALErr): _has_source, _api_error, } impl std::fmt::Debug for Error { @@ -131,10 +122,6 @@ impl Error { (usr_msg, log_msg.into()).into() } - pub fn new_msg + Clone>(usr_msg: M) -> Self { - (usr_msg.clone(), usr_msg.into()).into() - } - pub fn empty() -> Self { Empty {}.into() } @@ -145,12 +132,6 @@ impl Error { self } - #[must_use] - pub fn with_kind(mut self, kind: ErrorKind) -> Self { - self.error = kind; - self - } - #[must_use] pub const fn with_code(mut self, code: u16) -> Self { self.error_code = code; @@ -166,10 +147,6 @@ impl Error { pub fn get_event(&self) -> &Option { &self.event } - - pub fn message(&self) -> &str { - &self.message - } } pub trait MapResult { @@ -201,97 +178,26 @@ fn _no_source(_: T) -> Option { None } -fn _serialize(e: &impl Serialize, _msg: &str) -> String { +fn _serialize(e: &impl serde::Serialize, _msg: &str) -> String { serde_json::to_string(e).unwrap() } -/// This will serialize the default ApiErrorResponse -/// It will add the needed fields which are mostly empty or have multiple copies of the message -/// This is more efficient than having a larger struct and use the Serialize derive -/// It also prevents using `json!()` calls to create the final output -impl Serialize for ApiErrorResponse<'_> { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - #[derive(serde::Serialize)] - struct ErrorModel<'a> { - message: &'a str, - object: &'static str, - } - - let mut state = serializer.serialize_struct("ApiErrorResponse", 9)?; - - state.serialize_field("message", self.0.message)?; - - let mut validation_errors = std::collections::HashMap::with_capacity(1); - validation_errors.insert("", vec![self.0.message]); - state.serialize_field("validationErrors", &validation_errors)?; - - let error_model = ErrorModel { - message: self.0.message, - object: "error", - }; - state.serialize_field("errorModel", &error_model)?; - - state.serialize_field("error", "")?; - state.serialize_field("error_description", "")?; - state.serialize_field("exceptionMessage", &None::<()>)?; - state.serialize_field("exceptionStackTrace", &None::<()>)?; - state.serialize_field("innerExceptionMessage", &None::<()>)?; - state.serialize_field("object", "error")?; - - state.end() - } -} - -/// This will serialize the smaller CompactApiErrorResponse -/// It will add the needed fields which are mostly empty -/// This is more efficient than having a larger struct and use the Serialize derive -/// It also prevents using `json!()` calls to create the final output -impl Serialize for CompactApiErrorResponse<'_> { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let mut state = serializer.serialize_struct("CompactApiErrorResponse", 6)?; - - state.serialize_field("message", self.0.message)?; - state.serialize_field("validationErrors", &None::<()>)?; - state.serialize_field("exceptionMessage", &None::<()>)?; - state.serialize_field("exceptionStackTrace", &None::<()>)?; - state.serialize_field("innerExceptionMessage", &None::<()>)?; - state.serialize_field("object", "error")?; - - state.end() - } -} - -/// Main API Error struct template -/// This struct which we can be used by both ApiErrorResponse and CompactApiErrorResponse -/// is small and doesn't contain unneeded empty fields. This is more memory efficient, but also less code to compile -struct ApiErrorMsg<'a> { - message: &'a str, -} -/// Default API Error response struct -/// The custom serialization adds all other needed fields -struct ApiErrorResponse<'a>(ApiErrorMsg<'a>); -/// Compact API Error response struct used for some newer error responses -/// The custom serialization adds all other needed fields -struct CompactApiErrorResponse<'a>(ApiErrorMsg<'a>); - fn _api_error(_: &impl std::any::Any, msg: &str) -> String { - let response = ApiErrorMsg { - message: msg, - }; - serde_json::to_string(&ApiErrorResponse(response)).unwrap() -} - -fn _compact_api_error(_: &impl std::any::Any, msg: &str) -> String { - let response = ApiErrorMsg { - message: msg, - }; - serde_json::to_string(&CompactApiErrorResponse(response)).unwrap() + let json = json!({ + "message": msg, + "error": "", + "error_description": "", + "validationErrors": {"": [ msg ]}, + "errorModel": { + "message": msg, + "object": "error" + }, + "exceptionMessage": null, + "exceptionStackTrace": null, + "innerExceptionMessage": null, + "object": "error" + }); + _serialize(&json, "") } // @@ -306,8 +212,9 @@ use rocket::response::{self, Responder, Response}; impl Responder<'_, 'static> for Error { fn respond_to(self, _: &Request<'_>) -> response::Result<'static> { match self.error { - ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation - _ => error!(target: "error", "{self:#?}"), + ErrorKind::Empty(_) => {} // Don't print the error in this situation + ErrorKind::Simple(_) => {} // Don't print the error in this situation + _ => error!(target: "error", "{:#?}", self), }; let code = Status::from_code(self.error_code).unwrap_or(Status::BadRequest); @@ -321,63 +228,43 @@ impl Responder<'_, 'static> for Error { // #[macro_export] macro_rules! err { - ($kind:ident, $msg:expr) => {{ - let msg = $msg; - error!("{msg}"); - return Err($crate::error::Error::new_msg(msg).with_kind($crate::error::ErrorKind::$kind($crate::error::$kind {}))); - }}; ($msg:expr) => {{ - let msg = $msg; - error!("{msg}"); - return Err($crate::error::Error::new_msg(msg)); + error!("{}", $msg); + return Err($crate::error::Error::new($msg, $msg)); }}; ($msg:expr, ErrorEvent $err_event:tt) => {{ - let msg = $msg; - error!("{msg}"); - return Err($crate::error::Error::new_msg(msg).with_event($crate::error::ErrorEvent $err_event)); + error!("{}", $msg); + return Err($crate::error::Error::new($msg, $msg).with_event($crate::error::ErrorEvent $err_event)); }}; ($usr_msg:expr, $log_value:expr) => {{ - let usr_msg = $usr_msg; - let log_value = $log_value; - error!("{usr_msg}. {log_value}"); - return Err($crate::error::Error::new(usr_msg, log_value)); + error!("{}. {}", $usr_msg, $log_value); + return Err($crate::error::Error::new($usr_msg, $log_value)); }}; ($usr_msg:expr, $log_value:expr, ErrorEvent $err_event:tt) => {{ - let usr_msg = $usr_msg; - let log_value = $log_value; - error!("{usr_msg}. {log_value}"); - return Err($crate::error::Error::new(usr_msg, log_value).with_event($crate::error::ErrorEvent $err_event)); + error!("{}. {}", $usr_msg, $log_value); + return Err($crate::error::Error::new($usr_msg, $log_value).with_event($crate::error::ErrorEvent $err_event)); }}; } #[macro_export] macro_rules! err_silent { ($msg:expr) => {{ - return Err($crate::error::Error::new_msg($msg)); - }}; - ($msg:expr, ErrorEvent $err_event:tt) => {{ - return Err($crate::error::Error::new_msg($msg).with_event($crate::error::ErrorEvent $err_event)); + return Err($crate::error::Error::new($msg, $msg)); }}; ($usr_msg:expr, $log_value:expr) => {{ return Err($crate::error::Error::new($usr_msg, $log_value)); }}; - ($usr_msg:expr, $log_value:expr, ErrorEvent $err_event:tt) => {{ - return Err($crate::error::Error::new($usr_msg, $log_value).with_event($crate::error::ErrorEvent $err_event)); - }}; } #[macro_export] macro_rules! err_code { ($msg:expr, $err_code:expr) => {{ - let msg = $msg; - error!("{msg}"); - return Err($crate::error::Error::new_msg(msg).with_code($err_code)); + error!("{}", $msg); + return Err($crate::error::Error::new($msg, $msg).with_code($err_code)); }}; ($usr_msg:expr, $log_value:expr, $err_code:expr) => {{ - let usr_msg = $usr_msg; - let log_value = $log_value; - error!("{usr_msg}. {log_value}"); - return Err($crate::error::Error::new(usr_msg, log_value).with_code($err_code)); + error!("{}. {}", $usr_msg, $log_value); + return Err($crate::error::Error::new($usr_msg, $log_value).with_code($err_code)); }}; } @@ -385,7 +272,7 @@ macro_rules! err_code { macro_rules! err_discard { ($msg:expr, $data:expr) => {{ std::io::copy(&mut $data.open(), &mut std::io::sink()).ok(); - return Err($crate::error::Error::new_msg($msg)); + return Err($crate::error::Error::new($msg, $msg)); }}; ($usr_msg:expr, $log_value:expr, $data:expr) => {{ std::io::copy(&mut $data.open(), &mut std::io::sink()).ok(); @@ -410,9 +297,7 @@ macro_rules! err_handler { 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)); + error!(target: "auth", "Unauthorized Error: {}. {}", $usr_msg, $log_value); + return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $usr_msg)); }}; } diff --git a/src/http_client.rs b/src/http_client.rs index d39b884d..9feba366 100644 --- a/src/http_client.rs +++ b/src/http_client.rs @@ -1,11 +1,13 @@ use std::{ fmt, net::{IpAddr, SocketAddr}, - sync::{Arc, LazyLock, Mutex}, + str::FromStr, + sync::{Arc, Mutex}, time::Duration, }; -use hickory_resolver::{net::runtime::TokioRuntimeProvider, TokioResolver}; +use hickory_resolver::{system_conf::read_system_conf, TokioAsyncResolver}; +use once_cell::sync::Lazy; use regex::Regex; use reqwest::{ dns::{Name, Resolve, Resolving}, @@ -23,10 +25,9 @@ pub fn make_http_request(method: reqwest::Method, url: &str) -> Result = - LazyLock::new(|| get_reqwest_client_builder().build().expect("Failed to build client")); + static INSTANCE: Lazy = Lazy::new(|| get_reqwest_client_builder().build().expect("Failed to build client")); Ok(INSTANCE.request(method, url)) } @@ -44,7 +45,7 @@ pub fn get_reqwest_client_builder() -> ClientBuilder { return attempt.error("Invalid host"); }; - if let Err(e) = should_block_host(&host) { + if let Err(e) = should_block_host(host) { return attempt.error(e); } @@ -58,6 +59,16 @@ pub fn get_reqwest_client_builder() -> ClientBuilder { .timeout(Duration::from_secs(10)) } +pub fn should_block_address(domain_or_ip: &str) -> bool { + if let Ok(ip) = IpAddr::from_str(domain_or_ip) { + if should_block_ip(ip) { + return true; + } + } + + should_block_address_regex(domain_or_ip) +} + fn should_block_ip(ip: IpAddr) -> bool { if !CONFIG.http_request_block_non_global_ips() { return false; @@ -89,54 +100,11 @@ fn should_block_address_regex(domain_or_ip: &str) -> bool { is_match } -pub fn get_valid_host(host: &str) -> Result { - let Ok(host) = Host::parse(host) else { - return Err(CustomHttpClientError::Invalid { - domain: host.to_string(), - }); - }; - - // Some extra checks to validate hosts - match host { - Host::Domain(ref domain) => { - // Host::parse() does not verify length or all possible invalid characters - // We do some extra checks here to prevent issues - if domain.len() > 253 { - debug!("Domain validation error: '{domain}' exceeds 253 characters"); - return Err(CustomHttpClientError::Invalid { - domain: host.to_string(), - }); - } - if !domain.split('.').all(|label| { - !label.is_empty() - // Labels can't be longer than 63 chars - && label.len() <= 63 - // Labels are not allowed to start or end with a hyphen `-` - && !label.starts_with('-') - && !label.ends_with('-') - // Only ASCII Alphanumeric characters are allowed - // We already received a punycoded domain back, so no unicode should exists here - && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') - }) { - debug!( - "Domain validation error: '{domain}' labels contain invalid characters or exceed the maximum length" - ); - return Err(CustomHttpClientError::Invalid { - domain: host.to_string(), - }); - } - } - Host::Ipv4(_) | Host::Ipv6(_) => {} - } - - Ok(host) -} - -pub fn should_block_host>(host: &Host) -> Result<(), CustomHttpClientError> { +fn should_block_host(host: Host<&str>) -> Result<(), CustomHttpClientError> { let (ip, host_str): (Option, String) = match host { - Host::Ipv4(ip) => (Some(IpAddr::V4(*ip)), ip.to_string()), - Host::Ipv6(ip) => (Some(IpAddr::V6(*ip)), ip.to_string()), - Host::Domain(d) => (None, d.as_ref().to_string()), + Host::Ipv4(ip) => (Some(ip.into()), ip.to_string()), + Host::Ipv6(ip) => (Some(ip.into()), ip.to_string()), + Host::Domain(d) => (None, d.to_string()), }; if let Some(ip) = ip { @@ -166,9 +134,6 @@ pub enum CustomHttpClientError { domain: Option, ip: IpAddr, }, - Invalid { - domain: String, - }, } impl CustomHttpClientError { @@ -190,7 +155,7 @@ impl fmt::Display for CustomHttpClientError { match self { Self::Blocked { domain, - } => write!(f, "Blocked domain: '{domain}' matched HTTP_REQUEST_BLOCK_REGEX"), + } => write!(f, "Blocked domain: {domain} matched HTTP_REQUEST_BLOCK_REGEX"), Self::NonGlobalIp { domain: Some(domain), ip, @@ -198,10 +163,7 @@ impl fmt::Display for CustomHttpClientError { Self::NonGlobalIp { domain: None, ip, - } => write!(f, "IP '{ip}' is not a global IP!"), - Self::Invalid { - domain, - } => write!(f, "Invalid host: '{domain}' contains invalid characters or exceeds the maximum length"), + } => write!(f, "IP {ip} is not a global IP!"), } } } @@ -211,57 +173,48 @@ impl std::error::Error for CustomHttpClientError {} #[derive(Debug, Clone)] enum CustomDnsResolver { Default(), - Hickory(Arc), + Hickory(Arc), } type BoxError = Box; impl CustomDnsResolver { fn instance() -> Arc { - static INSTANCE: LazyLock> = LazyLock::new(CustomDnsResolver::new); + static INSTANCE: Lazy> = Lazy::new(CustomDnsResolver::new); Arc::clone(&*INSTANCE) } fn new() -> Arc { - TokioResolver::builder(TokioRuntimeProvider::default()) - .and_then(|mut builder| { - // Hickory's default since v0.26 is `Ipv6AndIpv4`, which sorts IPv6 first - // This might cause issues on IPv4 only systems or containers - // Unless someone enabled DNS_PREFER_IPV6, use Ipv4AndIpv6, which returns IPv4 first which was our previous default - if !CONFIG.dns_prefer_ipv6() { - builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6; - } - builder.build() - }) - .inspect_err(|e| warn!("Error creating Hickory resolver, falling back to default: {e:?}")) - .map(|resolver| Arc::new(Self::Hickory(Arc::new(resolver)))) - .unwrap_or_else(|_| Arc::new(Self::Default())) + match read_system_conf() { + Ok((config, opts)) => { + let resolver = TokioAsyncResolver::tokio(config.clone(), opts.clone()); + Arc::new(Self::Hickory(Arc::new(resolver))) + } + Err(e) => { + warn!("Error creating Hickory resolver, falling back to default: {e:?}"); + Arc::new(Self::Default()) + } + } } // Note that we get an iterator of addresses, but we only grab the first one for convenience - async fn resolve_domain(&self, name: &str) -> Result, BoxError> { + async fn resolve_domain(&self, name: &str) -> Result, BoxError> { pre_resolve(name)?; - let results: Vec = match self { - Self::Default() => tokio::net::lookup_host((name, 0)).await?.collect(), - Self::Hickory(r) => r.lookup_ip(name).await?.iter().map(|i| SocketAddr::new(i, 0)).collect(), + let result = match self { + Self::Default() => tokio::net::lookup_host(name).await?.next(), + Self::Hickory(r) => r.lookup_ip(name).await?.iter().next().map(|a| SocketAddr::new(a, 0)), }; - for addr in &results { + if let Some(addr) = &result { post_resolve(name, addr.ip())?; } - Ok(results) + Ok(result) } } fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> { - let Ok(host) = get_valid_host(name) else { - return Err(CustomHttpClientError::Invalid { - domain: name.to_string(), - }); - }; - - if should_block_host(&host).is_err() { + if should_block_address(name) { return Err(CustomHttpClientError::Blocked { domain: name.to_string(), }); @@ -286,275 +239,8 @@ impl Resolve for CustomDnsResolver { let this = self.clone(); Box::pin(async move { let name = name.as_str(); - let results = this.resolve_domain(name).await?; - if results.is_empty() { - warn!("Unable to resolve {name} to any valid IP address"); - } - Ok::(Box::new(results.into_iter())) + let result = this.resolve_domain(name).await?; + Ok::(Box::new(result.into_iter())) }) } } - -#[cfg(s3)] -pub(crate) mod aws { - use aws_smithy_runtime_api::client::{ - http::{HttpClient, HttpConnector, HttpConnectorFuture, HttpConnectorSettings, SharedHttpConnector}, - orchestrator::HttpResponse, - result::ConnectorError, - runtime_components::RuntimeComponents, - }; - use reqwest::Client; - - // Adapter that wraps reqwest to be compatible with the AWS SDK - #[derive(Debug)] - pub(crate) struct AwsReqwestConnector { - pub(crate) client: Client, - } - - impl HttpConnector for AwsReqwestConnector { - fn call(&self, request: aws_smithy_runtime_api::client::orchestrator::HttpRequest) -> HttpConnectorFuture { - // Convert the AWS-style request to a reqwest request - let client = self.client.clone(); - let future = async move { - let method = reqwest::Method::from_bytes(request.method().as_bytes()) - .map_err(|e| ConnectorError::user(Box::new(e)))?; - let mut req_builder = client.request(method, request.uri().to_string()); - - for (name, value) in request.headers() { - req_builder = req_builder.header(name, value); - } - - if let Some(body_bytes) = request.body().bytes() { - req_builder = req_builder.body(body_bytes.to_vec()); - } - - let response = req_builder.send().await.map_err(|e| ConnectorError::io(Box::new(e)))?; - - let status = response.status().into(); - let bytes = response.bytes().await.map_err(|e| ConnectorError::io(Box::new(e)))?; - - Ok(HttpResponse::new(status, bytes.into())) - }; - - HttpConnectorFuture::new(Box::pin(future)) - } - } - - impl HttpClient for AwsReqwestConnector { - fn http_connector( - &self, - _settings: &HttpConnectorSettings, - _components: &RuntimeComponents, - ) -> SharedHttpConnector { - SharedHttpConnector::new(AwsReqwestConnector { - client: self.client.clone(), - }) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::util::is_global_hardcoded; - use std::net::Ipv4Addr; - use url::Host; - - // === - // IPv4 numeric-format normalization - fn parse_to_ip(s: &str) -> Option { - match Host::parse(s).ok()? { - Host::Ipv4(v4) => Some(IpAddr::V4(v4)), - Host::Ipv6(v6) => Some(IpAddr::V6(v6)), - Host::Domain(_) => None, - } - } - - #[test] - fn dotted_decimal_loopback_normalizes() { - let ip = parse_to_ip("127.0.0.1").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn single_decimal_loopback_normalizes() { - // 127.0.0.1 == 2130706433 - let ip = parse_to_ip("2130706433").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn hex_loopback_normalizes() { - let ip = parse_to_ip("0x7f000001").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn dotted_hex_loopback_normalizes() { - let ip = parse_to_ip("0x7f.0.0.1").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn octal_loopback_normalizes() { - // 017700000001 == 127.0.0.1 - let ip = parse_to_ip("017700000001").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn dotted_octal_loopback_normalizes() { - let ip = parse_to_ip("0177.0.0.01").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn aws_metadata_decimal_blocked() { - // 169.254.169.254 == 2852039166 (link-local, AWS IMDS) - let ip = parse_to_ip("2852039166").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn rfc1918_hex_blocked() { - // 10.0.0.1 - let ip = parse_to_ip("0x0a000001").unwrap(); - assert!(!is_global_hardcoded(ip)); - } - - #[test] - fn public_ip_decimal_allowed() { - // 8.8.8.8 == 134744072 - let ip = parse_to_ip("134744072").unwrap(); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))); - assert!(is_global_hardcoded(ip)); - } - - // === - // get_valid_host integration: numeric forms become Host::Ipv4 - #[test] - fn get_valid_host_normalizes_decimal_int() { - let h = get_valid_host("2130706433").expect("valid"); - assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1))); - } - - #[test] - fn get_valid_host_normalizes_hex() { - let h = get_valid_host("0x7f000001").expect("valid"); - assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1))); - } - - #[test] - fn get_valid_host_normalizes_octal() { - let h = get_valid_host("017700000001").expect("valid"); - assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1))); - } - - // === - // IPv6 formats - #[test] - fn ipv6_loopback_blocked() { - let h = get_valid_host("[::1]").expect("valid"); - let Host::Ipv6(ip) = h else { - panic!("expected v6") - }; - assert!(!is_global_hardcoded(IpAddr::V6(ip))); - } - - #[test] - fn ipv4_mapped_in_ipv6_loopback_blocked() { - // ::ffff:127.0.0.1 — v4-mapped form; is_global_hardcoded blocks via ::ffff:0:0/96 - let h = get_valid_host("[::ffff:127.0.0.1]").expect("valid"); - let Host::Ipv6(ip) = h else { - panic!("expected v6") - }; - assert!(!is_global_hardcoded(IpAddr::V6(ip))); - } - - #[test] - fn ipv6_unique_local_blocked() { - let h = get_valid_host("[fc00::1]").expect("valid"); - let Host::Ipv6(ip) = h else { - panic!("expected v6") - }; - assert!(!is_global_hardcoded(IpAddr::V6(ip))); - } - - // === - // Punycode / IDN - #[test] - fn punycode_passthrough() { - let h = get_valid_host("xn--deadbeafcaf-lbb.test").expect("valid"); - match h { - Host::Domain(d) => assert_eq!(d, "xn--deadbeafcaf-lbb.test"), - _ => panic!("expected domain"), - } - } - - #[test] - fn idn_unicode_gets_punycoded() { - let h = get_valid_host("deadbeafcafé.test").expect("valid"); - match h { - Host::Domain(d) => assert_eq!(d, "xn--deadbeafcaf-lbb.test"), - _ => panic!("expected domain"), - } - } - - #[test] - fn idn_unicode_gets_punycoded_tld() { - let h = get_valid_host("deadbeaf.café").expect("valid"); - match h { - Host::Domain(d) => assert_eq!(d, "deadbeaf.xn--caf-dma"), - _ => panic!("expected domain"), - } - } - - #[test] - fn idn_emoji_gets_punycoded() { - let h = get_valid_host("xn--t88h.test").expect("valid"); // 🛡️.test - match h { - Host::Domain(d) => assert_eq!(d, "xn--t88h.test"), - _ => panic!("expected domain"), - } - } - - #[test] - fn idn_unicode_to_punycode_roundtrip() { - let from_unicode = get_valid_host("🛡️.test").expect("valid"); - let from_puny = get_valid_host("xn--t88h.test").expect("valid"); - match (from_unicode, from_puny) { - (Host::Domain(a), Host::Domain(b)) => assert_eq!(a, b), - _ => panic!("expected domains"), - } - } - - #[test] - fn invalid_punycode_rejected() { - // bare invalid punycode - assert!(get_valid_host("xn--").is_err()); - } - - #[test] - fn underscore_in_label_rejected() { - assert!(get_valid_host("dead_beaf.cafe").is_err()); - } - - #[test] - fn label_too_long_rejected() { - let label = "a".repeat(64); - assert!(get_valid_host(&format!("{label}.test")).is_err()); - } - - #[test] - fn domain_too_long_rejected() { - let big = "a.".repeat(130) + "test"; // > 253 - assert!(get_valid_host(&big).is_err()); - } -} diff --git a/src/mail.rs b/src/mail.rs index cdbd269a..b33efd95 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -1,6 +1,7 @@ +use std::str::FromStr; + use chrono::NaiveDateTime; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; -use std::{env::consts::EXE_SUFFIX, str::FromStr}; use lettre::{ message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart}, @@ -16,7 +17,7 @@ use crate::{ encode_jwt, generate_delete_claims, generate_emergency_access_invite_claims, generate_invite_claims, generate_verify_email_claims, }, - db::models::{Device, DeviceType, EmergencyAccessId, MembershipId, OrganizationId, User, UserId}, + db::models::{Device, DeviceType, User}, error::Error, CONFIG, }; @@ -25,7 +26,7 @@ fn sendmail_transport() -> AsyncSendmailTransport { if let Some(command) = CONFIG.sendmail_command() { AsyncSendmailTransport::new_with_command(command) } else { - AsyncSendmailTransport::new_with_command(format!("sendmail{EXE_SUFFIX}")) + AsyncSendmailTransport::new() } } @@ -85,7 +86,7 @@ fn smtp_transport() -> AsyncSmtpTransport { smtp_client.authentication(selected_mechanisms) } else { // Only show a warning, and return without setting an actual authentication mechanism - warn!("No valid SMTP Auth mechanism found for '{mechanism}', using default values"); + warn!("No valid SMTP Auth mechanism found for '{}', using default values", mechanism); smtp_client } } @@ -95,31 +96,7 @@ fn smtp_transport() -> AsyncSmtpTransport { smtp_client.build() } -// This will sanitize the string values by stripping all the html tags to prevent XSS and HTML Injections -fn sanitize_data(data: &mut serde_json::Value) { - use regex::Regex; - use std::sync::LazyLock; - static RE: LazyLock = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap()); - - match data { - serde_json::Value::String(s) => *s = RE.replace_all(s, "").to_string(), - serde_json::Value::Object(obj) => { - for d in obj.values_mut() { - sanitize_data(d); - } - } - serde_json::Value::Array(arr) => { - for d in arr.iter_mut() { - sanitize_data(d); - } - } - _ => {} - } -} - fn get_text(template_name: &'static str, data: serde_json::Value) -> Result<(String, String, String), Error> { - let mut data = data; - sanitize_data(&mut data); let (subject_html, body_html) = get_template(&format!("{template_name}.html"), &data)?; let (_subject_text, body_text) = get_template(template_name, &data)?; Ok((subject_html, body_html, body_text)) @@ -139,10 +116,6 @@ fn get_template(template_name: &str, data: &serde_json::Value) -> Result<(String None => err!("Template doesn't contain body"), }; - if text_split.next().is_some() { - err!("Template contains more than one body"); - } - Ok((subject, body)) } @@ -165,8 +138,8 @@ pub async fn send_password_hint(address: &str, hint: Option) -> EmptyRes send_email(address, &subject, body_html, body_text).await } -pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult { - let claims = generate_delete_claims(user_id.to_string()); +pub async fn send_delete_account(address: &str, uuid: &str) -> EmptyResult { + let claims = generate_delete_claims(uuid.to_string()); let delete_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( @@ -174,7 +147,7 @@ pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), - "user_id": user_id, + "user_id": uuid, "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "token": delete_token, }), @@ -183,8 +156,8 @@ pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult send_email(address, &subject, body_html, body_text).await } -pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { - let claims = generate_verify_email_claims(user_id); +pub async fn send_verify_email(address: &str, uuid: &str) -> EmptyResult { + let claims = generate_verify_email_claims(uuid.to_string()); let verify_email_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( @@ -192,7 +165,7 @@ pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), - "user_id": user_id, + "user_id": uuid, "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "token": verify_email_token, }), @@ -201,27 +174,6 @@ pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { send_email(address, &subject, body_html, body_text).await } -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); - let query_string = match query.query() { - None => err!("Failed to build verify URL query parameters"), - Some(query) => query, - }; - - let (subject, body_html, body_text) = get_text( - "email/register_verify_email", - json!({ - // `url.Url` would place the anchor `#` after the query parameters - "url": format!("{}/#/finish-signup/?{query_string}", CONFIG.domain()), - "img_src": CONFIG._smtp_img_src(), - "email": email, - }), - )?; - - send_email(email, &subject, body_html, body_text).await -} - pub async fn send_welcome(address: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/welcome", @@ -234,8 +186,8 @@ pub async fn send_welcome(address: &str) -> EmptyResult { send_email(address, &subject, body_html, body_text).await } -pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult { - let claims = generate_verify_email_claims(user_id); +pub async fn send_welcome_must_verify(address: &str, uuid: &str) -> EmptyResult { + let claims = generate_verify_email_claims(uuid.to_string()); let verify_email_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( @@ -243,7 +195,7 @@ pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyR json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), - "user_id": user_id, + "user_id": uuid, "token": verify_email_token, }), )?; @@ -279,8 +231,8 @@ pub async fn send_single_org_removed_from_org(address: &str, org_name: &str) -> pub async fn send_invite( user: &User, - org_id: OrganizationId, - member_id: MembershipId, + org_id: Option, + org_user_id: Option, org_name: &str, invited_by_email: Option, ) -> EmptyResult { @@ -288,7 +240,7 @@ pub async fn send_invite( user.uuid.clone(), user.email.clone(), org_id.clone(), - member_id.clone(), + org_user_id.clone(), invited_by_email, ); let invite_token = encode_jwt(&claims); @@ -298,27 +250,25 @@ pub async fn send_invite( query_params .append_pair("email", &user.email) .append_pair("organizationName", org_name) - .append_pair("organizationId", &org_id) - .append_pair("organizationUserId", &member_id) + .append_pair("organizationId", org_id.as_deref().unwrap_or("_")) + .append_pair("organizationUserId", org_user_id.as_deref().unwrap_or("_")) .append_pair("token", &invite_token); - - if CONFIG.sso_enabled() && CONFIG.sso_only() { - query_params.append_pair("orgSsoIdentifier", &org_id); - } if user.private_key.is_some() { query_params.append_pair("orgUserHasExistingUser", "true"); } } - let Some(query_string) = query.query() else { - err!("Failed to build invite URL query parameters") + let query_string = match query.query() { + None => err!(format!("Failed to build invite URL query parameters")), + Some(query) => query, }; + // `url.Url` would place the anchor `#` after the query parameters + let url = format!("{}/#/accept-organization/?{}", CONFIG.domain(), query_string); let (subject, body_html, body_text) = get_text( "email/send_org_invite", json!({ - // `url.Url` would place the anchor `#` after the query parameters - "url": format!("{}/#/accept-organization/?{query_string}", CONFIG.domain()), + "url": url, "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), @@ -329,41 +279,30 @@ pub async fn send_invite( pub async fn send_emergency_access_invite( address: &str, - user_id: UserId, - emer_id: EmergencyAccessId, + uuid: &str, + emer_id: &str, grantor_name: &str, grantor_email: &str, ) -> EmptyResult { let claims = generate_emergency_access_invite_claims( - user_id, + String::from(uuid), String::from(address), - emer_id.clone(), + String::from(emer_id), 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(); - { - let mut query_params = query.query_pairs_mut(); - query_params - .append_pair("id", &emer_id.to_string()) - .append_pair("name", grantor_name) - .append_pair("email", address) - .append_pair("token", &encode_jwt(&claims)); - } - - let Some(query_string) = query.query() else { - err!("Failed to build emergency invite URL query parameters") - }; + let invite_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( "email/send_emergency_access_invite", json!({ - // `url.Url` would place the anchor `#` after the query parameters - "url": format!("{}/#/accept-emergency/?{query_string}", CONFIG.domain()), + "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), + "emer_id": emer_id, + "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "grantor_name": grantor_name, + "token": invite_token, }), )?; @@ -574,46 +513,6 @@ pub async fn send_change_email(address: &str, token: &str) -> EmptyResult { send_email(address, &subject, body_html, body_text).await } -pub async fn send_change_email_existing(address: &str, acting_address: &str) -> EmptyResult { - let (subject, body_html, body_text) = get_text( - "email/change_email_existing", - json!({ - "url": CONFIG.domain(), - "img_src": CONFIG._smtp_img_src(), - "existing_address": address, - "acting_address": acting_address, - }), - )?; - - send_email(address, &subject, body_html, body_text).await -} - -pub async fn send_change_email_invited(address: &str, acting_address: &str) -> EmptyResult { - let (subject, body_html, body_text) = get_text( - "email/change_email_invited", - json!({ - "url": CONFIG.domain(), - "img_src": CONFIG._smtp_img_src(), - "existing_address": address, - "acting_address": acting_address, - }), - )?; - - send_email(address, &subject, body_html, body_text).await -} - -pub async fn send_sso_change_email(address: &str) -> EmptyResult { - let (subject, body_html, body_text) = get_text( - "email/sso_change_email", - json!({ - "url": format!("{}/#/settings/account", CONFIG.domain()), - "img_src": CONFIG._smtp_img_src(), - }), - )?; - - send_email(address, &subject, body_html, body_text).await -} - pub async fn send_test(address: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/smtp_test", @@ -659,13 +558,13 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult { // Match some common errors and make them more user friendly Err(e) => { if e.is_client() { - debug!("Sendmail client error: {e:?}"); + debug!("Sendmail client error: {:#?}", e); err!(format!("Sendmail client error: {e}")); } else if e.is_response() { - debug!("Sendmail response error: {e:?}"); + debug!("Sendmail response error: {:#?}", e); err!(format!("Sendmail response error: {e}")); } else { - debug!("Sendmail error: {e:?}"); + debug!("Sendmail error: {:#?}", e); err!(format!("Sendmail error: {e}")); } } @@ -676,13 +575,13 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult { // Match some common errors and make them more user friendly Err(e) => { if e.is_client() { - debug!("SMTP client error: {e:#?}"); + debug!("SMTP client error: {:#?}", e); err!(format!("SMTP client error: {e}")); } else if e.is_transient() { - debug!("SMTP 4xx error: {e:#?}"); + debug!("SMTP 4xx error: {:#?}", e); err!(format!("SMTP 4xx error: {e}")); } else if e.is_permanent() { - debug!("SMTP 5xx error: {e:#?}"); + debug!("SMTP 5xx error: {:#?}", e); let mut msg = e.to_string(); // Add a special check for 535 to add a more descriptive message if msg.contains("(535)") { @@ -690,13 +589,13 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult { } err!(format!("SMTP 5xx error: {msg}")); } else if e.is_timeout() { - debug!("SMTP timeout error: {e:#?}"); + debug!("SMTP timeout error: {:#?}", e); err!(format!("SMTP timeout error: {e}")); } else if e.is_tls() { - debug!("SMTP encryption error: {e:#?}"); + debug!("SMTP encryption error: {:#?}", e); err!(format!("SMTP encryption error: {e}")); } else { - debug!("SMTP error: {e:#?}"); + debug!("SMTP error: {:#?}", e); err!(format!("SMTP error: {e}")); } } @@ -705,7 +604,7 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult { } async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult { - let smtp_from = Address::from_str(&CONFIG.smtp_from())?; + let smtp_from = &CONFIG.smtp_from(); let body = if CONFIG.smtp_embed_images() { let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png").unwrap().1.to_vec()); @@ -727,9 +626,9 @@ async fn send_email(address: &str, subject: &str, body_html: String, body_text: }; let email = Message::builder() - .message_id(Some(format!("<{}@{}>", crate::util::get_uuid(), smtp_from.domain()))) + .message_id(Some(format!("<{}@{}>", crate::util::get_uuid(), smtp_from.split('@').collect::>()[1]))) .to(Mailbox::new(None, Address::from_str(address)?)) - .from(Mailbox::new(Some(CONFIG.smtp_from_name()), smtp_from)) + .from(Mailbox::new(Some(CONFIG.smtp_from_name()), Address::from_str(smtp_from)?)) .subject(subject) .multipart(body)?; diff --git a/src/main.rs b/src/main.rs index 60c5a593..7e180e2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,9 @@ #![cfg_attr(feature = "unstable", feature(ip))] // The recursion_limit is mainly triggered by the json!() macro. // The more key/value pairs there are the more recursion occurs. -// We want to keep this as low as possible! -#![recursion_limit = "165"] +// We want to keep this as low as possible, but not higher then 128. +// If you go above 128 it will cause rust-analyzer to fail, +#![recursion_limit = "200"] // When enabled use MiMalloc as malloc instead of the default malloc #[cfg(feature = "enable_mimalloc")] @@ -23,8 +24,6 @@ extern crate log; extern crate diesel; #[macro_use] extern crate diesel_migrations; -#[macro_use] -extern crate diesel_derive_newtype; use std::{ collections::HashMap, @@ -55,14 +54,12 @@ mod db; mod http_client; mod mail; mod ratelimit; -mod sso; -mod sso_client; mod util; use crate::api::core::two_factor::duo_oidc::purge_duo_contexts; use crate::api::purge_auth_requests; use crate::api::{WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}; -pub use config::{PathType, CONFIG}; +pub use config::CONFIG; pub use error::{Error, MapResult}; use rocket::data::{Limits, ToByteUnit}; use std::sync::{atomic::Ordering, Arc}; @@ -76,18 +73,20 @@ async fn main() -> Result<(), Error> { let level = init_logging()?; check_data_folder().await; - auth::initialize_keys().await.unwrap_or_else(|e| { + auth::initialize_keys().unwrap_or_else(|e| { error!("Error creating private key '{}'\n{e:?}\nExiting Vaultwarden!", CONFIG.private_rsa_key()); exit(1); }); check_web_vault(); + create_dir(&CONFIG.icon_cache_folder(), "icon cache"); create_dir(&CONFIG.tmp_folder(), "tmp folder"); + create_dir(&CONFIG.sends_folder(), "sends folder"); + create_dir(&CONFIG.attachments_folder(), "attachments folder"); let pool = create_db_pool().await; schedule_jobs(pool.clone()); - db::models::TwoFactor::migrate_u2f_to_webauthn(&pool.get().await.unwrap()).await.unwrap(); - db::models::TwoFactor::migrate_credential_to_passkey(&pool.get().await.unwrap()).await.unwrap(); + db::models::TwoFactor::migrate_u2f_to_webauthn(&mut pool.get().await.unwrap()).await.unwrap(); let extra_debug = matches!(level, log::LevelFilter::Trace | log::LevelFilter::Debug); launch_rocket(pool, extra_debug).await // Blocks until program termination. @@ -126,7 +125,7 @@ fn parse_args() { exit(0); } else if pargs.contains(["-v", "--version"]) { config::SKIP_CONFIG_VALIDATION.store(true, Ordering::Relaxed); - let web_vault_version = util::get_active_web_release(); + let web_vault_version = util::get_web_vault_version(); println!("Vaultwarden {version}"); println!("Web-Vault {web_vault_version}"); exit(0); @@ -187,7 +186,7 @@ fn parse_args() { exit(1); } } else if command == "backup" { - match db::backup_sqlite() { + match backup_sqlite() { Ok(f) => { println!("Backup to '{f}' was successful"); exit(0); @@ -202,6 +201,28 @@ fn parse_args() { } } +fn backup_sqlite() -> Result { + #[cfg(sqlite)] + { + use crate::db::{backup_sqlite_database, DbConnType}; + if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::sqlite).unwrap_or(false) { + use diesel::Connection; + let url = CONFIG.database_url(); + + // Establish a connection to the sqlite database + let mut conn = diesel::sqlite::SqliteConnection::establish(&url)?; + let backup_file = backup_sqlite_database(&mut conn)?; + Ok(backup_file) + } else { + err_silent!("The database type is not SQLite. Backups only works for SQLite databases") + } + } + #[cfg(not(sqlite))] + { + err_silent!("The 'sqlite' feature is not enabled. Backups only works for SQLite databases") + } +} + fn launch_info() { println!( "\ @@ -246,8 +267,8 @@ fn init_logging() -> Result { .split(',') .collect::>() .into_iter() - .flat_map(|s| match s.split_once('=') { - Some((log, lvl_str)) => log::LevelFilter::from_str(lvl_str).ok().map(|lvl| (log, lvl)), + .flat_map(|s| match s.split('=').collect::>()[..] { + [log, lvl_str] => log::LevelFilter::from_str(lvl_str).ok().map(|lvl| (log, lvl)), _ => None, }) .collect() @@ -267,6 +288,13 @@ fn init_logging() -> Result { log::LevelFilter::Off }; + let diesel_logger_level: log::LevelFilter = + if cfg!(feature = "query_logger") && std::env::var("QUERY_LOGGER").is_ok() { + log::LevelFilter::Debug + } else { + log::LevelFilter::Off + }; + // Only show Rocket underscore `_` logs when the level is Debug or higher // Else this will bloat the log output with useless messages. let rocket_underscore_level = if level >= log::LevelFilter::Debug { @@ -317,15 +345,9 @@ fn init_logging() -> Result { // Variable level for hickory used by reqwest ("hickory_resolver::name_server::name_server", hickory_level), ("hickory_proto::xfer", hickory_level), + ("diesel_logger", diesel_logger_level), // SMTP ("lettre::transport::smtp", smtp_log_level), - // Set query_logger default to Off, but can be overwritten manually - // You can set LOG_LEVEL=info,vaultwarden::db::query_logger= to overwrite it. - // This makes it possible to do the following: - // warn = Print slow queries only, 5 seconds or longer - // info = Print slow queries only, 1 second or longer - // debug = Print all queries - ("vaultwarden::db::query_logger", log::LevelFilter::Off), ]); for (path, level) in levels_override.into_iter() { @@ -411,7 +433,10 @@ fn init_logging() -> Result { } None => error!( target: "panic", - "thread '{thread}' panicked at '{msg}'\n{backtrace:}" + "thread '{}' panicked at '{}'\n{:}", + thread, + msg, + backtrace ), } })); @@ -431,7 +456,7 @@ fn chain_syslog(logger: fern::Dispatch) -> fern::Dispatch { match syslog::unix(syslog_fmt) { Ok(sl) => logger.chain(sl), Err(e) => { - error!("Unable to connect to syslog: {e:?}"); + error!("Unable to connect to syslog: {:?}", e); logger } } @@ -445,27 +470,9 @@ fn create_dir(path: &str, description: &str) { async fn check_data_folder() { let data_folder = &CONFIG.data_folder(); - - if data_folder.starts_with("s3://") { - if let Err(e) = CONFIG - .opendal_operator_for_path_type(&PathType::Data) - .unwrap_or_else(|e| { - error!("Failed to create S3 operator for data folder '{data_folder}': {e:?}"); - exit(1); - }) - .check() - .await - { - error!("Could not access S3 data folder '{data_folder}': {e:?}"); - exit(1); - } - - return; - } - let path = Path::new(data_folder); if !path.exists() { - error!("Data folder '{data_folder}' doesn't exist."); + error!("Data folder '{}' doesn't exist.", data_folder); if is_running_in_container() { error!("Verify that your data volume is mounted at the correct location."); } else { @@ -474,7 +481,7 @@ async fn check_data_folder() { exit(1); } if !path.is_dir() { - error!("Data folder '{data_folder}' is not a directory."); + error!("Data folder '{}' is not a directory.", data_folder); exit(1); } @@ -548,7 +555,7 @@ async fn create_db_pool() -> db::DbPool { match util::retry_db(db::DbPool::from_config, CONFIG.db_connection_retries()).await { Ok(p) => p, Err(e) => { - error!("Error creating database pool: {e:?}"); + error!("Error creating database pool: {:?}", e); exit(1); } } @@ -558,12 +565,6 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> let basepath = &CONFIG.domain_path(); let mut config = rocket::Config::from(rocket::Config::figment()); - - // We install our own signal handlers below; disable Rocket's built-in handlers - config.shutdown.ctrlc = false; - #[cfg(unix)] - config.shutdown.signals.clear(); - config.temp_dir = canonicalize(CONFIG.tmp_folder()).unwrap().into(); config.cli_colors = false; // Make sure Rocket does not color any values for logging. config.limits = Limits::new() @@ -595,26 +596,26 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> CONFIG.set_rocket_shutdown_handle(instance.shutdown()); - spawn_shutdown_signal_handler(); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.expect("Error setting Ctrl-C handler"); + info!("Exiting Vaultwarden!"); + CONFIG.shutdown(); + }); - #[cfg(all(unix, sqlite))] + #[cfg(unix)] { - if db::ACTIVE_DB_TYPE.get() != Some(&db::DbConnType::Sqlite) { - debug!("PostgreSQL and MySQL/MariaDB do not support this backup feature, skip adding USR1 signal."); - } else { - tokio::spawn(async move { - let mut signal_user1 = tokio::signal::unix::signal(SignalKind::user_defined1()).unwrap(); - loop { - // If we need more signals to act upon, we might want to use select! here. - // With only one item to listen for this is enough. - let _ = signal_user1.recv().await; - match db::backup_sqlite() { - Ok(f) => info!("Backup to '{f}' was successful"), - Err(e) => error!("Backup failed. {e:?}"), - } + tokio::spawn(async move { + let mut signal_user1 = tokio::signal::unix::signal(SignalKind::user_defined1()).unwrap(); + loop { + // If we need more signals to act upon, we might want to use select! here. + // With only one item to listen for this is enough. + let _ = signal_user1.recv().await; + match backup_sqlite() { + Ok(f) => info!("Backup to '{f}' was successful"), + Err(e) => error!("Backup failed. {e:?}"), } - }); - } + } + }); } instance.launch().await?; @@ -623,35 +624,6 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> Ok(()) } -#[cfg(unix)] -fn spawn_shutdown_signal_handler() { - tokio::spawn(async move { - use tokio::signal::unix::signal; - - let mut sigint = signal(SignalKind::interrupt()).expect("Error setting SIGINT handler"); - let mut sigterm = signal(SignalKind::terminate()).expect("Error setting SIGTERM handler"); - let mut sigquit = signal(SignalKind::quit()).expect("Error setting SIGQUIT handler"); - - let signal_name = tokio::select! { - _ = sigint.recv() => "SIGINT", - _ = sigterm.recv() => "SIGTERM", - _ = sigquit.recv() => "SIGQUIT", - }; - - info!("Received {signal_name}, initiating graceful shutdown"); - CONFIG.shutdown(); - }); -} - -#[cfg(not(unix))] -fn spawn_shutdown_signal_handler() { - tokio::spawn(async move { - tokio::signal::ctrl_c().await.expect("Error setting Ctrl-C handler"); - info!("Received Ctrl-C, initiating graceful shutdown"); - CONFIG.shutdown(); - }); -} - fn schedule_jobs(pool: db::DbPool) { if CONFIG.job_poll_interval_ms() == 0 { info!("Job scheduler disabled."); @@ -730,13 +702,6 @@ fn schedule_jobs(pool: db::DbPool) { })); } - // Purge sso auth from incomplete flow (default to daily at 00h20). - if !CONFIG.purge_incomplete_sso_auth().is_empty() { - sched.add(Job::new(CONFIG.purge_incomplete_sso_auth().parse().unwrap(), || { - runtime.spawn(db::models::SsoAuth::delete_expired(pool.clone())); - })); - } - // Periodically check for jobs to run. We probably won't need any // jobs that run more often than once a minute, so a default poll // interval of 30 seconds should be sufficient. Users who want to diff --git a/src/ratelimit.rs b/src/ratelimit.rs index 854bcc53..c85ce7ad 100644 --- a/src/ratelimit.rs +++ b/src/ratelimit.rs @@ -1,4 +1,5 @@ -use std::{net::IpAddr, num::NonZeroU32, sync::LazyLock, time::Duration}; +use once_cell::sync::Lazy; +use std::{net::IpAddr, num::NonZeroU32, time::Duration}; use governor::{clock::DefaultClock, state::keyed::DashMapStateStore, Quota, RateLimiter}; @@ -6,13 +7,13 @@ use crate::{Error, CONFIG}; type Limiter = RateLimiter, DefaultClock>; -static LIMITER_LOGIN: LazyLock = LazyLock::new(|| { +static LIMITER_LOGIN: Lazy = Lazy::new(|| { 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(|| { +static LIMITER_ADMIN: Lazy = Lazy::new(|| { 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)) diff --git a/src/sso.rs b/src/sso.rs deleted file mode 100644 index 7505f84f..00000000 --- a/src/sso.rs +++ /dev/null @@ -1,469 +0,0 @@ -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::{ - api::ApiResult, - auth, - auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, - db::{ - models::{Device, OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth, SsoUser, User}, - DbConn, - }, - sso_client::Client, - CONFIG, -}; - -pub static FAKE_SSO_IDENTIFIER: &str = "00000000-01DC-01DC-01DC-000000000000"; - -static SSO_JWT_ISSUER: LazyLock = LazyLock::new(|| format!("{}|sso", CONFIG.domain_origin())); - -pub static SSO_AUTH_EXPIRATION: LazyLock = - LazyLock::new(|| chrono::TimeDelta::try_minutes(10).unwrap()); - -#[derive( - Clone, - Debug, - Default, - DieselNewType, - FromForm, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - AsRef, - Deref, - Display, - From, -)] -#[deref(forward)] -#[from(forward)] -pub struct OIDCCode(String); - -#[derive( - Clone, - Debug, - Default, - DieselNewType, - FromForm, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - AsRef, - Deref, - Display, - From, - Into, -)] -#[deref(forward)] -#[into(owned)] -pub struct OIDCCodeChallenge(String); - -#[derive( - Clone, - Debug, - Default, - DieselNewType, - FromForm, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - AsRef, - Deref, - Display, - Into, -)] -#[deref(forward)] -#[into(owned)] -pub struct OIDCCodeVerifier(String); - -#[derive( - Clone, - Debug, - Default, - DieselNewType, - FromForm, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - AsRef, - Deref, - Display, - From, -)] -#[deref(forward)] -#[from(forward)] -pub struct OIDCState(String); - -#[derive(Debug, Serialize, Deserialize)] -struct SsoTokenJwtClaims { - // Not before - pub nbf: i64, - // Expiration time - pub exp: i64, - // Issuer - pub iss: String, - // Subject - pub sub: String, -} - -pub fn encode_ssotoken_claims() -> String { - let time_now = Utc::now(); - let claims = SsoTokenJwtClaims { - nbf: time_now.timestamp(), - exp: (time_now + chrono::TimeDelta::try_minutes(2).unwrap()).timestamp(), - iss: SSO_JWT_ISSUER.to_string(), - sub: "vaultwarden".to_string(), - }; - - auth::encode_jwt(&claims) -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct BasicTokenClaims { - iat: Option, - nbf: Option, - exp: i64, -} - -#[derive(Deserialize)] -struct BasicTokenClaimsValidation { - exp: u64, - iss: String, -} - -impl BasicTokenClaims { - fn nbf(&self) -> i64 { - self.nbf.or(self.iat).unwrap_or_else(|| Utc::now().timestamp()) - } -} - -fn decode_token_claims(token_name: &str, token: &str) -> ApiResult { - // We need to manually validate this token, since `insecure_decode` does not do this - match jsonwebtoken::dangerous::insecure_decode::(token) { - Ok(btcv) => { - let now = jsonwebtoken::get_current_timestamp(); - let validate_claim = btcv.claims; - // Validate the exp in the claim with a leeway of 60 seconds, same as jsonwebtoken does - if validate_claim.exp < now - 60 { - err_silent!(format!("Expired Signature for base token claim from {token_name}")) - } - if validate_claim.iss.ne(&CONFIG.sso_authority()) { - err_silent!(format!("Invalid Issuer for base token claim from {token_name}")) - } - - // All is validated and ok, lets decode again using the wanted struct - let btc = jsonwebtoken::dangerous::insecure_decode::(token).unwrap(); - Ok(btc.claims) - } - Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")), - } -} - -pub fn decode_state(base64_state: &str) -> ApiResult { - let state = match data_encoding::BASE64.decode(base64_state.as_bytes()) { - Ok(vec) => match String::from_utf8(vec) { - Ok(valid) => OIDCState(valid), - Err(_) => err!(format!("Invalid utf8 chars in {base64_state} after base64 decoding")), - }, - Err(_) => err!(format!("Failed to decode {base64_state} using base64")), - }; - - Ok(state) -} - -// 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, - client_id: &str, - raw_redirect_uri: &str, - binding_hash: Option, - conn: DbConn, -) -> ApiResult { - let redirect_uri = match client_id { - "web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()), - "desktop" | "mobile" => "bitwarden://sso-callback".to_string(), - "cli" => { - let port_regex = Regex::new(r"^http://localhost:([0-9]{4})$").unwrap(); - match port_regex.captures(raw_redirect_uri).and_then(|captures| captures.get(1).map(|c| c.as_str())) { - Some(port) => format!("http://localhost:{port}"), - None => err!("Failed to extract port number"), - } - } - _ => err!(format!("Unsupported client {client_id}")), - }; - - let (auth_url, sso_auth) = Client::authorize_url(state, client_challenge, redirect_uri, binding_hash).await?; - sso_auth.save(&conn).await?; - Ok(auth_url) -} - -#[derive( - Clone, - Debug, - Default, - DieselNewType, - FromForm, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - AsRef, - Deref, - Display, - From, -)] -#[deref(forward)] -#[from(forward)] -pub struct OIDCIdentifier(String); - -impl OIDCIdentifier { - fn new(issuer: &str, subject: &str) -> Self { - OIDCIdentifier(format!("{issuer}/{subject}")) - } -} - -// 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. -// The `SsoAuth` will ensure that the user is authorized only once. -pub async fn exchange_code( - state: &OIDCState, - client_verifier: OIDCCodeVerifier, - conn: &DbConn, -) -> ApiResult<(SsoAuth, OIDCAuthenticatedUser)> { - use openidconnect::OAuth2TokenResponse; - - let mut sso_auth = match SsoAuth::find(state, conn).await { - None => err!(format!("Invalid state cannot retrieve sso auth")), - Some(sso_auth) => sso_auth, - }; - - if let Some(authenticated_user) = sso_auth.auth_response.clone() { - return Ok((sso_auth, authenticated_user)); - } - - let code = match sso_auth.code_response.clone() { - Some(OIDCCodeWrapper::Ok { - code, - }) => code.clone(), - Some(OIDCCodeWrapper::Error { - error, - error_description, - }) => { - sso_auth.delete(conn).await?; - err!(format!("SSO authorization failed: {error}, {}", error_description.as_ref().unwrap_or(&String::new()))) - } - None => { - sso_auth.delete(conn).await?; - err!("Missing authorization provider return"); - } - }; - - let client = Client::cached().await?; - let (token_response, id_claims) = client.exchange_code(code, client_verifier, &sso_auth).await?; - - let user_info = client.user_info(token_response.access_token().to_owned()).await?; - - let email = match id_claims.email().or(user_info.email()) { - None => err!("Neither id token nor userinfo contained an email"), - Some(e) => e.to_string().to_lowercase(), - }; - - let email_verified = id_claims.email_verified().or(user_info.email_verified()); - - let user_name = id_claims.preferred_username().or(user_info.preferred_username()).map(|un| un.to_string()); - - let refresh_token = token_response.refresh_token().map(|t| t.secret()); - if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) { - error!("Scope offline_access is present but response contain no refresh_token"); - } - - let identifier = OIDCIdentifier::new(id_claims.issuer(), id_claims.subject()); - - let authenticated_user = OIDCAuthenticatedUser { - refresh_token: refresh_token.cloned(), - access_token: token_response.access_token().secret().clone(), - expires_in: token_response.expires_in(), - identifier: identifier.clone(), - email: email.clone(), - email_verified, - user_name: user_name.clone(), - }; - - 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?; - - Ok((sso_auth, authenticated_user)) -} - -// User has passed 2FA flow we can delete auth info from database -pub async fn redeem( - device: &Device, - user: &User, - client_id: Option, - sso_user: Option, - sso_auth: SsoAuth, - auth_user: OIDCAuthenticatedUser, - conn: &DbConn, -) -> ApiResult { - sso_auth.delete(conn).await?; - - if sso_user.is_none() { - let user_sso = SsoUser { - user_uuid: user.uuid.clone(), - identifier: auth_user.identifier.clone(), - }; - user_sso.save(conn).await?; - } - - if !CONFIG.sso_auth_only_not_session() { - let now = Utc::now(); - - let (ap_nbf, ap_exp) = - match (decode_token_claims("access_token", &auth_user.access_token), auth_user.expires_in) { - (Ok(ap), _) => (ap.nbf(), ap.exp), - (Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()), - _ => err!("Non jwt access_token and empty expires_in"), - }; - - let access_claims = - auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now); - - _create_auth_tokens(device, auth_user.refresh_token, access_claims, auth_user.access_token) - } else { - Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id)) - } -} - -// We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front). -// If there is no SSO refresh_token, we keep the access_token to be able to call user_info to check for validity -pub fn create_auth_tokens( - device: &Device, - user: &User, - client_id: Option, - refresh_token: Option, - access_token: String, - expires_in: Option, -) -> ApiResult { - if !CONFIG.sso_auth_only_not_session() { - let now = Utc::now(); - - let (ap_nbf, ap_exp) = match (decode_token_claims("access_token", &access_token), expires_in) { - (Ok(ap), _) => (ap.nbf(), ap.exp), - (Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()), - _ => err!("Non jwt access_token and empty expires_in"), - }; - - let access_claims = - auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now); - - _create_auth_tokens(device, refresh_token, access_claims, access_token) - } else { - Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id)) - } -} - -fn _create_auth_tokens( - device: &Device, - refresh_token: Option, - access_claims: auth::LoginJwtClaims, - access_token: String, -) -> ApiResult { - let (nbf, exp, token) = if let Some(rt) = refresh_token { - match decode_token_claims("refresh_token", &rt) { - Err(_) => { - let time_now = Utc::now(); - let exp = (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp(); - debug!("Non jwt refresh_token (expiration set to {exp})"); - (time_now.timestamp(), exp, TokenWrapper::Refresh(rt)) - } - Ok(refresh_payload) => { - debug!("Refresh_payload: {refresh_payload:?}"); - (refresh_payload.nbf(), refresh_payload.exp, TokenWrapper::Refresh(rt)) - } - } - } else { - debug!("No refresh_token present"); - (access_claims.nbf, access_claims.exp, TokenWrapper::Access(access_token)) - }; - - let refresh_claims = auth::RefreshJwtClaims { - nbf, - exp, - iss: auth::JWT_LOGIN_ISSUER.to_string(), - sub: AuthMethod::Sso, - device_token: device.refresh_token.clone(), - token: Some(token), - }; - - Ok(AuthTokens { - refresh_claims, - access_claims, - }) -} - -// This endpoint is called in two case -// - the session is close to expiration we will try to extend it -// - the user is going to make an action and we check that the session is still valid -pub async fn exchange_refresh_token( - device: &Device, - user: &User, - client_id: Option, - refresh_claims: auth::RefreshJwtClaims, -) -> ApiResult { - let exp = refresh_claims.exp; - match refresh_claims.token { - Some(TokenWrapper::Refresh(refresh_token)) => { - // Use new refresh_token if returned - let (new_refresh_token, access_token, expires_in) = - Client::exchange_refresh_token(refresh_token.clone()).await?; - - create_auth_tokens( - device, - user, - client_id, - new_refresh_token.or(Some(refresh_token)), - access_token, - expires_in, - ) - } - Some(TokenWrapper::Access(access_token)) => { - let now = Utc::now(); - let exp_limit = (now + *BW_EXPIRATION).timestamp(); - - if exp < exp_limit { - err_silent!("Access token is close to expiration but we have no refresh token") - } - - Client::check_validity(access_token.clone()).await?; - - let access_claims = auth::LoginJwtClaims::new( - device, - user, - now.timestamp(), - exp, - AuthMethod::Sso.scope_vec(), - client_id, - now, - ); - - _create_auth_tokens(device, None, access_claims, access_token) - } - None => err!("No token present while in SSO"), - } -} diff --git a/src/sso_client.rs b/src/sso_client.rs deleted file mode 100644 index abff6bcb..00000000 --- a/src/sso_client.rs +++ /dev/null @@ -1,280 +0,0 @@ -use std::{borrow::Cow, sync::LazyLock, time::Duration}; - -use openidconnect::{core::*, reqwest, *}; -use regex::Regex; -use url::Url; - -use crate::{ - api::{ApiResult, EmptyResult}, - db::models::SsoAuth, - sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, - CONFIG, -}; - -static CLIENT_CACHE_KEY: LazyLock = LazyLock::new(|| "sso-client".to_string()); -static CLIENT_CACHE: LazyLock> = LazyLock::new(|| { - moka::sync::Cache::builder() - .max_capacity(1) - .time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())) - .build() -}); -static REFRESH_CACHE: LazyLock>> = - LazyLock::new(|| moka::future::Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(30)).build()); - -/// OpenID Connect Core client. -pub type CustomClient = openidconnect::Client< - EmptyAdditionalClaims, - CoreAuthDisplay, - CoreGenderClaim, - CoreJweContentEncryptionAlgorithm, - CoreJsonWebKey, - CoreAuthPrompt, - StandardErrorResponse, - CoreTokenResponse, - CoreTokenIntrospectionResponse, - CoreRevocableToken, - CoreRevocationErrorResponse, - EndpointSet, - EndpointNotSet, - EndpointNotSet, - EndpointNotSet, - EndpointSet, - EndpointSet, ->; - -pub type RefreshTokenResponse = (Option, String, Option); - -#[derive(Clone)] -pub struct Client { - pub http_client: reqwest::Client, - pub core_client: CustomClient, -} - -impl Client { - // Call the OpenId discovery endpoint to retrieve configuration - async fn _get_client() -> ApiResult { - let client_id = ClientId::new(CONFIG.sso_client_id()); - let client_secret = ClientSecret::new(CONFIG.sso_client_secret()); - - let issuer_url = CONFIG.sso_issuer_url()?; - - let http_client = match reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none()).build() { - Err(err) => err!(format!("Failed to build http client: {err}")), - Ok(client) => client, - }; - - let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, &http_client).await { - Err(err) => err!(format!("Failed to discover OpenID provider: {err}")), - Ok(metadata) => metadata, - }; - - let base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); - - let token_uri = match base_client.token_uri() { - Some(uri) => uri.clone(), - None => err!("Failed to discover token_url, cannot proceed"), - }; - - let user_info_url = match base_client.user_info_url() { - Some(url) => url.clone(), - None => err!("Failed to discover user_info url, cannot proceed"), - }; - - let core_client = base_client - .set_redirect_uri(CONFIG.sso_redirect_url()?) - .set_token_uri(token_uri) - .set_user_info_url(user_info_url); - - Ok(Client { - http_client, - core_client, - }) - } - - // Simple cache to prevent recalling the discovery endpoint each time - pub async fn cached() -> ApiResult { - if CONFIG.sso_client_cache_expiration() > 0 { - match CLIENT_CACHE.get(&*CLIENT_CACHE_KEY) { - Some(client) => Ok(client), - None => Self::_get_client().await.inspect(|client| { - debug!("Inserting new client in cache"); - CLIENT_CACHE.insert(CLIENT_CACHE_KEY.clone(), client.clone()); - }), - } - } else { - Self::_get_client().await - } - } - - pub fn invalidate() { - if CONFIG.sso_client_cache_expiration() > 0 { - CLIENT_CACHE.invalidate(&*CLIENT_CACHE_KEY); - } - } - - // The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier). - pub async fn authorize_url( - state: OIDCState, - client_challenge: OIDCCodeChallenge, - redirect_uri: String, - binding_hash: Option, - ) -> ApiResult<(Url, SsoAuth)> { - let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new); - let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes()); - - let client = Self::cached().await?; - let mut auth_req = client - .core_client - .authorize_url( - AuthenticationFlow::::AuthorizationCode, - || CsrfToken::new(base64_state), - Nonce::new_random, - ) - .add_scopes(scopes) - .add_extra_params(CONFIG.sso_authorize_extra_params_vec()); - - if CONFIG.sso_pkce() { - auth_req = auth_req - .add_extra_param::<&str, String>("code_challenge", client_challenge.clone().into()) - .add_extra_param("code_challenge_method", "S256"); - } - - let (auth_url, _, nonce) = auth_req.url(); - Ok((auth_url, SsoAuth::new(state, client_challenge, nonce.secret().clone(), redirect_uri, binding_hash))) - } - - pub async fn exchange_code( - &self, - code: OIDCCode, - client_verifier: OIDCCodeVerifier, - sso_auth: &SsoAuth, - ) -> ApiResult<( - StandardTokenResponse< - IdTokenFields< - EmptyAdditionalClaims, - EmptyExtraTokenFields, - CoreGenderClaim, - CoreJweContentEncryptionAlgorithm, - CoreJwsSigningAlgorithm, - >, - CoreTokenType, - >, - IdTokenClaims, - )> { - let oidc_code = AuthorizationCode::new(code.to_string()); - - let mut exchange = self.core_client.exchange_code(oidc_code); - - let verifier = PkceCodeVerifier::new(client_verifier.into()); - if CONFIG.sso_pkce() { - exchange = exchange.set_pkce_verifier(verifier); - } else { - let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier); - if challenge.as_str() != String::from(sso_auth.client_challenge.clone()) { - err!(format!("PKCE client challenge failed")) - // Might need to notify admin ? how ? - } - } - - match exchange.request_async(&self.http_client).await { - Err(err) => err!(format!("Failed to contact token endpoint: {:?}", err)), - Ok(token_response) => { - let oidc_nonce = Nonce::new(sso_auth.nonce.clone()); - - let id_token = match token_response.extra_fields().id_token() { - None => err!("Token response did not contain an id_token"), - Some(token) => token, - }; - - if CONFIG.sso_debug_tokens() { - debug!("Id token: {}", id_token.to_string()); - debug!("Access token: {}", token_response.access_token().secret()); - debug!("Refresh token: {:?}", token_response.refresh_token().map(|t| t.secret())); - debug!("Expiration time: {:?}", token_response.expires_in()); - } - - let id_claims = match id_token.claims(&self.vw_id_token_verifier(), &oidc_nonce) { - Ok(claims) => claims.clone(), - Err(err) => { - Self::invalidate(); - err!(format!("Could not read id_token claims, {err}")); - } - }; - - Ok((token_response, id_claims)) - } - } - } - - pub async fn user_info(&self, access_token: AccessToken) -> ApiResult { - match self.core_client.user_info(access_token, None).request_async(&self.http_client).await { - Err(err) => err!(format!("Request to user_info endpoint failed: {err}")), - Ok(user_info) => Ok(user_info), - } - } - - pub async fn check_validity(access_token: String) -> EmptyResult { - let client = Client::cached().await?; - match client.user_info(AccessToken::new(access_token)).await { - Err(err) => { - err_silent!(format!("Failed to retrieve user info, token has probably been invalidated: {err}")) - } - Ok(_) => Ok(()), - } - } - - pub fn vw_id_token_verifier(&self) -> CoreIdTokenVerifier<'_> { - let mut verifier = self.core_client.id_token_verifier(); - if let Some(regex_str) = CONFIG.sso_audience_trusted() { - match Regex::new(®ex_str) { - Ok(regex) => { - verifier = verifier.set_other_audience_verifier_fn(move |aud| regex.is_match(aud)); - } - Err(err) => { - error!("Failed to parse SSO_AUDIENCE_TRUSTED={regex_str} regex: {err}"); - } - } - } - verifier - } - - pub async fn exchange_refresh_token(refresh_token: String) -> ApiResult { - let client = Client::cached().await?; - - REFRESH_CACHE - .get_with(refresh_token.clone(), async move { client._exchange_refresh_token(refresh_token).await }) - .await - .map_err(Into::into) - } - - async fn _exchange_refresh_token(&self, refresh_token: String) -> Result { - let rt = RefreshToken::new(refresh_token); - - match self.core_client.exchange_refresh_token(&rt).request_async(&self.http_client).await { - Err(err) => { - error!("Request to exchange_refresh_token endpoint failed: {err}"); - Err(format!("Request to exchange_refresh_token endpoint failed: {err}")) - } - Ok(token_response) => Ok(( - token_response.refresh_token().map(|token| token.secret().clone()), - token_response.access_token().secret().clone(), - token_response.expires_in(), - )), - } - } -} - -trait AuthorizationRequestExt<'a> { - fn add_extra_params>, V: Into>>(self, params: Vec<(N, V)>) -> Self; -} - -impl<'a, AD: AuthDisplay, P: AuthPrompt, RT: ResponseType> AuthorizationRequestExt<'a> - for AuthorizationRequest<'a, AD, P, RT> -{ - fn add_extra_params>, V: Into>>(mut self, params: Vec<(N, V)>) -> Self { - for (key, value) in params { - self = self.add_extra_param(key, value); - } - self - } -} diff --git a/src/static/global_domains.json b/src/static/global_domains.json index 3b13a3e9..e3f08813 100644 --- a/src/static/global_domains.json +++ b/src/static/global_domains.json @@ -111,8 +111,7 @@ "microsoftstore.com", "xbox.com", "azure.com", - "windowsazure.com", - "cloud.microsoft" + "windowsazure.com" ], "excluded": false }, @@ -972,13 +971,5 @@ "pinterest.se" ], "excluded": false - }, - { - "type": 91, - "domains": [ - "twitter.com", - "x.com" - ], - "excluded": false } -] +] \ No newline at end of file diff --git a/src/static/scripts/admin.css b/src/static/scripts/admin.css index c7c6f443..1db8d4c0 100644 --- a/src/static/scripts/admin.css +++ b/src/static/scripts/admin.css @@ -1,17 +1,6 @@ body { padding-top: 75px; } -/* Some extra width's for the main layout */ -@media (min-width: 1600px) { - .container-xxl { - max-width: 1520px; - } -} -@media (min-width: 1800px) { - .container-xxl { - max-width: 1720px; - } -} img { width: 48px; height: 48px; @@ -49,8 +38,8 @@ img { max-width: 130px; } #users-table .vw-actions, #orgs-table .vw-actions { - min-width: 170px; - max-width: 180px; + min-width: 130px; + max-width: 130px; } #users-table .vw-org-cell { max-height: 120px; @@ -65,24 +54,3 @@ img { .vw-copy-toast { width: 15rem; } - -.abbr-badge { - cursor: help; -} - -.theme-icon, -.theme-icon-active { - display: inline-flex; - flex: 0 0 1.75em; - justify-content: center; -} - -.theme-icon svg, -.theme-icon-active svg { - width: 1.25em; - height: 1.25em; - min-width: 1.25em; - min-height: 1.25em; - display: block; - overflow: visible; -} \ No newline at end of file diff --git a/src/static/scripts/admin.js b/src/static/scripts/admin.js index 3f6bb1df..b194a91d 100644 --- a/src/static/scripts/admin.js +++ b/src/static/scripts/admin.js @@ -1,6 +1,6 @@ "use strict"; /* eslint-env es2017, browser */ -/* exported BASE_URL, _post _delete */ +/* exported BASE_URL, _post */ function getBaseUrl() { // If the base URL is `https://vaultwarden.example.com/base/path/admin/`, @@ -28,11 +28,11 @@ function msg(text, reload_page = true) { reload_page && reload(); } -function _fetch(method, url, successMsg, errMsg, body, reload_page = true) { +function _post(url, successMsg, errMsg, body, reload_page = true) { let respStatus; let respStatusText; fetch(url, { - method: method, + method: "POST", body: body, mode: "same-origin", credentials: "same-origin", @@ -65,14 +65,6 @@ function _fetch(method, url, successMsg, errMsg, body, reload_page = true) { }); } -function _post(url, successMsg, errMsg, body, reload_page = true) { - return _fetch("POST", url, successMsg, errMsg, body, reload_page); -} - -function _delete(url, successMsg, errMsg, body, reload_page = true) { - return _fetch("DELETE", url, successMsg, errMsg, body, reload_page); -} - // Bootstrap Theme Selector const getStoredTheme = () => localStorage.getItem("theme"); const setStoredTheme = theme => localStorage.setItem("theme", theme); @@ -106,11 +98,7 @@ const showActiveTheme = (theme, focus = false) => { const themeSwitcherText = document.querySelector("#bd-theme-text"); const activeThemeIcon = document.querySelector(".theme-icon-active use"); const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`); - if (!btnToActive) { - return; - } - const btnIconUse = btnToActive ? btnToActive.querySelector("[data-theme-icon-use]") : null; - const iconHref = btnIconUse ? btnIconUse.getAttribute("href") || btnIconUse.getAttribute("xlink:href") : null; + const svgOfActiveBtn = btnToActive.querySelector("span use").textContent; document.querySelectorAll("[data-bs-theme-value]").forEach(element => { element.classList.remove("active"); @@ -119,12 +107,7 @@ const showActiveTheme = (theme, focus = false) => { btnToActive.classList.add("active"); btnToActive.setAttribute("aria-pressed", "true"); - - if (iconHref && activeThemeIcon) { - activeThemeIcon.setAttribute("href", iconHref); - activeThemeIcon.setAttribute("xlink:href", iconHref); - } - + activeThemeIcon.textContent = svgOfActiveBtn; const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`; themeSwitcher.setAttribute("aria-label", themeSwitcherLabel); @@ -163,4 +146,4 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => { navItem[0].className = navItem[0].className + " active"; navItem[0].setAttribute("aria-current", "page"); } -}); +}); \ No newline at end of file diff --git a/src/static/scripts/admin_diagnostics.js b/src/static/scripts/admin_diagnostics.js index 2cff4410..6a178e4b 100644 --- a/src/static/scripts/admin_diagnostics.js +++ b/src/static/scripts/admin_diagnostics.js @@ -7,8 +7,6 @@ var timeCheck = false; var ntpTimeCheck = false; var domainCheck = false; var httpsCheck = false; -var websocketCheck = false; -var httpResponseCheck = false; // ================================ // Date & Time Check @@ -29,7 +27,7 @@ function isValidIp(ip) { return ipv4Regex.test(ip) || ipv6Regex.test(ip); } -function checkVersions(platform, installed, latest, commit=null, compare_order=0) { +function checkVersions(platform, installed, latest, commit=null) { if (installed === "-" || latest === "-") { document.getElementById(`${platform}-failed`).classList.remove("d-none"); return; @@ -37,12 +35,10 @@ function checkVersions(platform, installed, latest, commit=null, compare_order=0 // Only check basic versions, no commit revisions if (commit === null || installed.indexOf("-") === -1) { - if (platform === "web" && compare_order === 1) { - document.getElementById(`${platform}-prerelease`).classList.remove("d-none"); - } else if (installed == latest) { - document.getElementById(`${platform}-success`).classList.remove("d-none"); - } else { + if (installed !== latest) { document.getElementById(`${platform}-warning`).classList.remove("d-none"); + } else { + document.getElementById(`${platform}-success`).classList.remove("d-none"); } } else { // Check if this is a branched version. @@ -80,15 +76,18 @@ async function generateSupportString(event, dj) { event.preventDefault(); event.stopPropagation(); - let supportString = "### Your environment (Generated via diagnostics page)\n\n"; + let supportString = "### Your environment (Generated via diagnostics page)\n"; supportString += `* Vaultwarden version: v${dj.current_release}\n`; - supportString += `* Web-vault version: v${dj.active_web_release}\n`; + supportString += `* Web-vault version: v${dj.web_vault_version}\n`; supportString += `* OS/Arch: ${dj.host_os}/${dj.host_arch}\n`; supportString += `* Running within a container: ${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: ${dj.overrides !== ""}\n`; + supportString += "* Environment settings overridden: "; + if (dj.overrides != "") { + supportString += "true\n"; + } else { + supportString += "false\n"; + } supportString += `* Uses a reverse proxy: ${dj.ip_header_exists}\n`; if (dj.ip_header_exists) { supportString += `* IP Header check: ${dj.ip_header_match} (${dj.ip_header_name})\n`; @@ -96,22 +95,15 @@ async function generateSupportString(event, dj) { supportString += `* Internet access: ${dj.has_http_access}\n`; supportString += `* Internet access via a proxy: ${dj.uses_proxy}\n`; supportString += `* DNS Check: ${dnsCheck}\n`; - if (dj.tz_env !== "") { - supportString += `* TZ environment: ${dj.tz_env}\n`; - } supportString += `* Browser/Server Time Check: ${timeCheck}\n`; supportString += `* Server/NTP Time Check: ${ntpTimeCheck}\n`; supportString += `* Domain Configuration Check: ${domainCheck}\n`; supportString += `* HTTPS Check: ${httpsCheck}\n`; - if (dj.enable_websocket) { - supportString += `* Websocket Check: ${websocketCheck}\n`; - } else { - supportString += "* Websocket Check: disabled\n"; - } - supportString += `* HTTP Response Checks: ${httpResponseCheck}\n`; - if (dj.invalid_feature_flags != "") { - supportString += `* Invalid feature flags: true\n`; - } + supportString += `* Database type: ${dj.db_type}\n`; + supportString += `* Database version: ${dj.db_version}\n`; + supportString += "* Clients used: \n"; + supportString += "* Reverse proxy and version: \n"; + supportString += "* Other relevant information: \n"; const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, { "headers": { "Accept": "application/json" } @@ -121,34 +113,10 @@ async function generateSupportString(event, dj) { throw new Error(jsonResponse); } const configJson = await jsonResponse.json(); + supportString += "\n### Config (Generated via diagnostics page)\n
Show Running Config\n"; + supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`; + supportString += "\n\n```json\n" + JSON.stringify(configJson, undefined, 2) + "\n```\n
\n"; - // Start Config and Details section within a details block which is collapsed by default - supportString += "\n### Config & Details (Generated via diagnostics page)\n\n"; - supportString += "
Show Config & Details\n"; - - // Add overrides if they exists - if (dj.overrides != "") { - supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`; - } - - if (dj.invalid_feature_flags != "") { - supportString += `\n**Invalid feature flags:** ${dj.invalid_feature_flags}\n`; - } - - // Add http response check messages if they exists - if (httpResponseCheck === false) { - supportString += "\n**Failed HTTP Checks:**\n"; - // We use `innerText` here since that will convert
into new-lines - supportString += "\n```yaml\n" + document.getElementById("http-response-errors").innerText.trim() + "\n```\n"; - } - - // Add the current config in json form - supportString += "\n**Config:**\n"; - supportString += "\n```json\n" + JSON.stringify(configJson, undefined, 2) + "\n```\n"; - - supportString += "\n
\n"; - - // Add the support string to the textbox so it can be viewed and copied document.getElementById("support-string").textContent = supportString; document.getElementById("support-string").classList.remove("d-none"); document.getElementById("copy-support").classList.remove("d-none"); @@ -215,9 +183,11 @@ function initVersionCheck(dj) { } checkVersions("server", serverInstalled, serverLatest, serverLatestCommit); - const webInstalled = dj.active_web_release; - const webLatest = dj.latest_web_release; - checkVersions("web", webInstalled, webLatest, null, dj.web_vault_compare); + if (!dj.running_within_container) { + const webInstalled = dj.web_vault_version; + const webLatest = dj.latest_web_build; + checkVersions("web", webInstalled, webLatest); + } } function checkDns(dns_resolved) { @@ -229,165 +199,6 @@ function checkDns(dns_resolved) { } } -async function fetchCheckUrl(url) { - try { - const response = await fetch(url); - return { headers: response.headers, status: response.status, text: await response.text() }; - } catch (error) { - console.error(`Error fetching ${url}: ${error}`); - return { error }; - } -} - -function checkSecurityHeaders(headers, omit) { - let securityHeaders = { - "x-frame-options": ["SAMEORIGIN"], - "x-content-type-options": ["nosniff"], - "referrer-policy": ["same-origin"], - "x-xss-protection": ["0"], - "x-robots-tag": ["noindex", "nofollow"], - "cross-origin-resource-policy": ["same-origin"], - "content-security-policy": [ - "default-src 'none'", - "font-src 'self'", - "manifest-src 'self'", - "base-uri 'self'", - "form-action 'self'", - "object-src 'self' blob:", - "script-src 'self' 'wasm-unsafe-eval'", - "style-src 'self' 'unsafe-inline'", - "child-src 'self' https://*.duosecurity.com https://*.duofederal.com", - "frame-src 'self' https://*.duosecurity.com https://*.duofederal.com", - "frame-ancestors 'self' chrome-extension://nngceckbapebfimnlniiiahkandclblb chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh moz-extension://*", - "img-src 'self' data: https://haveibeenpwned.com", - "connect-src 'self' https://api.pwnedpasswords.com https://api.2fa.directory https://app.simplelogin.io/api/ https://app.addy.io/api/ https://api.fastmail.com/ https://api.forwardemail.net", - ] - }; - - let messages = []; - for (let header in securityHeaders) { - // Skip some headers for specific endpoints if needed - if (typeof omit === "object" && omit.includes(header) === true) { - continue; - } - // If the header exists, check if the contents matches what we expect it to be - let headerValue = headers.get(header); - if (headerValue !== null) { - securityHeaders[header].forEach((expectedValue) => { - if (headerValue.indexOf(expectedValue) === -1) { - messages.push(`'${header}' does not contain '${expectedValue}'`); - } - }); - } else { - messages.push(`'${header}' is missing!`); - } - } - return messages; -} - -async function checkHttpResponse() { - const [apiConfig, webauthnConnector, notFound, notFoundApi, badRequest, unauthorized, forbidden] = await Promise.all([ - fetchCheckUrl(`${BASE_URL}/api/config`), - fetchCheckUrl(`${BASE_URL}/webauthn-connector.html`), - fetchCheckUrl(`${BASE_URL}/admin/does-not-exist`), - fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=404`), - fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=400`), - fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=401`), - fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=403`), - ]); - - const respErrorElm = document.getElementById("http-response-errors"); - - // Check and validate the default API header responses - let apiErrors = checkSecurityHeaders(apiConfig.headers); - if (apiErrors.length >= 1) { - respErrorElm.innerHTML += "API calls:
"; - apiErrors.forEach((errMsg) => { - respErrorElm.innerHTML += `Header: ${errMsg}
`; - }); - } - - // Check the special `-connector.html` headers, these should have some headers omitted. - const omitConnectorHeaders = ["x-frame-options", "content-security-policy"]; - let connectorErrors = checkSecurityHeaders(webauthnConnector.headers, omitConnectorHeaders); - omitConnectorHeaders.forEach((header) => { - if (webauthnConnector.headers.get(header) !== null) { - connectorErrors.push(`'${header}' is present while it should not`); - } - }); - if (connectorErrors.length >= 1) { - respErrorElm.innerHTML += "2FA Connector calls:
"; - connectorErrors.forEach((errMsg) => { - respErrorElm.innerHTML += `Header: ${errMsg}
`; - }); - } - - // Check specific error code responses if they are not re-written by a reverse proxy - let responseErrors = []; - if (notFound.status !== 404 || notFound.text.indexOf("return to the web-vault") === -1) { - responseErrors.push("404 (Not Found) HTML is invalid"); - } - - if (notFoundApi.status !== 404 || notFoundApi.text.indexOf("\"message\":\"Testing error 404 response\",") === -1) { - responseErrors.push("404 (Not Found) JSON is invalid"); - } - - if (badRequest.status !== 400 || badRequest.text.indexOf("\"message\":\"Testing error 400 response\",") === -1) { - responseErrors.push("400 (Bad Request) is invalid"); - } - - if (unauthorized.status !== 401 || unauthorized.text.indexOf("\"message\":\"Testing error 401 response\",") === -1) { - responseErrors.push("401 (Unauthorized) is invalid"); - } - - if (forbidden.status !== 403 || forbidden.text.indexOf("\"message\":\"Testing error 403 response\",") === -1) { - responseErrors.push("403 (Forbidden) is invalid"); - } - - if (responseErrors.length >= 1) { - respErrorElm.innerHTML += "HTTP error responses:
"; - responseErrors.forEach((errMsg) => { - respErrorElm.innerHTML += `Response to: ${errMsg}
`; - }); - } - - if (responseErrors.length >= 1 || connectorErrors.length >= 1 || apiErrors.length >= 1) { - document.getElementById("http-response-warning").classList.remove("d-none"); - } else { - httpResponseCheck = true; - document.getElementById("http-response-success").classList.remove("d-none"); - } -} - -async function fetchWsUrl(wsUrl) { - return new Promise((resolve, reject) => { - try { - const ws = new WebSocket(wsUrl); - ws.onopen = () => { - ws.close(); - resolve(true); - }; - - ws.onerror = () => { - reject(false); - }; - } catch (_) { - reject(false); - } - }); -} - -async function checkWebsocketConnection() { - // Test Websocket connections via the anonymous (login with device) connection - const isConnected = await fetchWsUrl(`${BASE_URL}/notifications/anonymous-hub?token=admin-diagnostics`).catch(() => false); - if (isConnected) { - websocketCheck = true; - document.getElementById("websocket-success").classList.remove("d-none"); - } else { - document.getElementById("websocket-error").classList.remove("d-none"); - } -} - function init(dj) { // Time check document.getElementById("time-browser-string").textContent = browserUTC; @@ -414,12 +225,6 @@ function init(dj) { // DNS Check checkDns(dj.dns_resolved); - - checkHttpResponse(); - - if (dj.enable_websocket) { - checkWebsocketConnection(); - } } // onLoad events diff --git a/src/static/scripts/admin_users.js b/src/static/scripts/admin_users.js index 99e39aab..c2462521 100644 --- a/src/static/scripts/admin_users.js +++ b/src/static/scripts/admin_users.js @@ -1,6 +1,6 @@ "use strict"; /* eslint-env es2017, browser, jquery */ -/* global _post:readable, _delete:readable BASE_URL:readable, reload:readable, jdenticon:readable */ +/* global _post:readable, BASE_URL:readable, reload:readable, jdenticon:readable */ function deleteUser(event) { event.preventDefault(); @@ -24,28 +24,6 @@ function deleteUser(event) { } } -function deleteSSOUser(event) { - event.preventDefault(); - event.stopPropagation(); - const id = event.target.parentNode.dataset.vwUserUuid; - const email = event.target.parentNode.dataset.vwUserEmail; - if (!id || !email) { - alert("Required parameters not found!"); - return false; - } - const input_email = prompt(`To delete user "${email}" SSO association, please type the email below`); - if (input_email != null) { - if (input_email == email) { - _delete(`${BASE_URL}/admin/users/${id}/sso`, - "User SSO association deleted correctly", - "Error deleting user SSO association" - ); - } else { - alert("Wrong email, please try again"); - } - } -} - function remove2fa(event) { event.preventDefault(); event.stopPropagation(); @@ -174,7 +152,7 @@ const ORG_TYPES = { "name": "User", "bg": "blue" }, - "4": { + "3": { "name": "Manager", "bg": "green" }, @@ -268,9 +246,6 @@ function initUserTable() { document.querySelectorAll("button[vw-delete-user]").forEach(btn => { btn.addEventListener("click", deleteUser); }); - document.querySelectorAll("button[vw-delete-sso-user]").forEach(btn => { - btn.addEventListener("click", deleteSSOUser); - }); document.querySelectorAll("button[vw-disable-user]").forEach(btn => { btn.addEventListener("click", disableUser); }); @@ -288,8 +263,6 @@ function initUserTable() { // onLoad events document.addEventListener("DOMContentLoaded", (/*event*/) => { - const size = jQuery("#users-table > thead th").length; - const ssoOffset = size-7; jQuery("#users-table").DataTable({ "drawCallback": function() { initUserTable(); @@ -302,10 +275,10 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => { ], "pageLength": -1, // Default show all "columnDefs": [{ - "targets": [1 + ssoOffset, 2 + ssoOffset], + "targets": [1, 2], "type": "date-iso" }, { - "targets": size-1, + "targets": 6, "searchable": false, "orderable": false }] @@ -330,4 +303,4 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => { if (btnInviteUserForm) { btnInviteUserForm.addEventListener("submit", inviteUser); } -}); +}); \ No newline at end of file diff --git a/src/static/scripts/bootstrap.bundle.js b/src/static/scripts/bootstrap.bundle.js index 93cbd3fe..6294dff3 100644 --- a/src/static/scripts/bootstrap.bundle.js +++ b/src/static/scripts/bootstrap.bundle.js @@ -1,6 +1,6 @@ /*! - * Bootstrap v5.3.8 (https://getbootstrap.com/) - * Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) */ (function (global, factory) { @@ -205,7 +205,7 @@ * @param {HTMLElement} element * @return void * - * @see https://www.harrytheo.com/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation + * @see https://www.charistheo.io/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation */ const reflow = element => { element.offsetHeight; // eslint-disable-line no-unused-expressions @@ -250,7 +250,7 @@ }); }; const execute = (possibleCallback, args = [], defaultValue = possibleCallback) => { - return typeof possibleCallback === 'function' ? possibleCallback.call(...args) : defaultValue; + return typeof possibleCallback === 'function' ? possibleCallback(...args) : defaultValue; }; const executeAfterTransition = (callback, transitionElement, waitForTransition = true) => { if (!waitForTransition) { @@ -572,7 +572,7 @@ const bsKeys = Object.keys(element.dataset).filter(key => key.startsWith('bs') && !key.startsWith('bsConfig')); for (const key of bsKeys) { let pureKey = key.replace(/^bs/, ''); - pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1); + pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1, pureKey.length); attributes[pureKey] = normalizeData(element.dataset[key]); } return attributes; @@ -647,7 +647,7 @@ * Constants */ - const VERSION = '5.3.8'; + const VERSION = '5.3.3'; /** * Class definition @@ -673,8 +673,6 @@ this[propertyName] = null; } } - - // Private _queueCallback(callback, element, isAnimated = true) { executeAfterTransition(callback, element, isAnimated); } @@ -1606,11 +1604,11 @@ this._element.style[dimension] = ''; this._queueCallback(complete, this._element, true); } - - // Private _isShown(element = this._element) { return element.classList.contains(CLASS_NAME_SHOW$7); } + + // Private _configAfterMerge(config) { config.toggle = Boolean(config.toggle); // Coerce string values config.parent = getElement(config.parent); @@ -2668,6 +2666,7 @@ var popperOffsets = computeOffsets({ reference: referenceClientRect, element: popperRect, + strategy: 'absolute', placement: placement }); var popperClientRect = rectToClientRect(Object.assign({}, popperRect, popperOffsets)); @@ -2995,6 +2994,7 @@ state.modifiersData[name] = computeOffsets({ reference: state.rects.reference, element: state.rects.popper, + strategy: 'absolute', placement: state.placement }); } // eslint-disable-next-line import/no-unused-modules @@ -3701,7 +3701,7 @@ } _createPopper() { if (typeof Popper === 'undefined') { - throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org/docs/v2/)'); + throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org)'); } let referenceElement = this._element; if (this._config.reference === 'parent') { @@ -3780,7 +3780,7 @@ } return { ...defaultBsPopperConfig, - ...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig]) + ...execute(this._config.popperConfig, [defaultBsPopperConfig]) }; } _selectMenuItem({ @@ -4802,6 +4802,7 @@ * * Shout-out to Angular https://github.com/angular/angular/blob/15.2.8/packages/core/src/sanitization/url_sanitizer.ts#L38 */ + // eslint-disable-next-line unicorn/better-regex const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i; const allowedAttribute = (attribute, allowedAttributeList) => { const attributeName = attribute.nodeName.toLowerCase(); @@ -4966,7 +4967,7 @@ return this._config.sanitize ? sanitizeHtml(arg, this._config.allowList, this._config.sanitizeFn) : arg; } _resolvePossibleFunction(arg) { - return execute(arg, [undefined, this]); + return execute(arg, [this]); } _putElementInTemplate(element, templateElement) { if (this._config.html) { @@ -5065,7 +5066,7 @@ class Tooltip extends BaseComponent { constructor(element, config) { if (typeof Popper === 'undefined') { - throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org/docs/v2/)'); + throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org)'); } super(element, config); @@ -5111,6 +5112,7 @@ if (!this._isEnabled) { return; } + this._activeTrigger.click = !this._activeTrigger.click; if (this._isShown()) { this._leave(); return; @@ -5298,7 +5300,7 @@ return offset; } _resolvePossibleFunction(arg) { - return execute(arg, [this._element, this._element]); + return execute(arg, [this._element]); } _getPopperConfig(attachment) { const defaultBsPopperConfig = { @@ -5336,7 +5338,7 @@ }; return { ...defaultBsPopperConfig, - ...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig]) + ...execute(this._config.popperConfig, [defaultBsPopperConfig]) }; } _setListeners() { @@ -5345,7 +5347,6 @@ if (trigger === 'click') { EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK$1), this._config.selector, event => { const context = this._initializeOnDelegatedTarget(event); - context._activeTrigger[TRIGGER_CLICK] = !(context._isShown() && context._activeTrigger[TRIGGER_CLICK]); context.toggle(); }); } else if (trigger !== TRIGGER_MANUAL) { @@ -6211,6 +6212,7 @@ } // Private + _maybeScheduleHide() { if (!this._config.autohide) { return; diff --git a/src/static/scripts/bootstrap.css b/src/static/scripts/bootstrap.css index b83f5079..b7ab57f2 100644 --- a/src/static/scripts/bootstrap.css +++ b/src/static/scripts/bootstrap.css @@ -1,7 +1,7 @@ @charset "UTF-8"; /*! - * Bootstrap v5.3.8 (https://getbootstrap.com/) - * Copyright 2011-2025 The Bootstrap Authors + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) */ :root, @@ -517,8 +517,8 @@ legend { width: 100%; padding: 0; margin-bottom: 0.5rem; - line-height: inherit; font-size: calc(1.275rem + 0.3vw); + line-height: inherit; } @media (min-width: 1200px) { legend { @@ -547,10 +547,6 @@ legend + * { -webkit-appearance: textfield; outline-offset: -2px; } -[type=search]::-webkit-search-cancel-button { - cursor: pointer; - filter: grayscale(1); -} /* rtl:raw: [type="tel"], @@ -605,9 +601,9 @@ progress { } .display-1 { + font-size: calc(1.625rem + 4.5vw); font-weight: 300; line-height: 1.2; - font-size: calc(1.625rem + 4.5vw); } @media (min-width: 1200px) { .display-1 { @@ -616,9 +612,9 @@ progress { } .display-2 { + font-size: calc(1.575rem + 3.9vw); font-weight: 300; line-height: 1.2; - font-size: calc(1.575rem + 3.9vw); } @media (min-width: 1200px) { .display-2 { @@ -627,9 +623,9 @@ progress { } .display-3 { + font-size: calc(1.525rem + 3.3vw); font-weight: 300; line-height: 1.2; - font-size: calc(1.525rem + 3.3vw); } @media (min-width: 1200px) { .display-3 { @@ -638,9 +634,9 @@ progress { } .display-4 { + font-size: calc(1.475rem + 2.7vw); font-weight: 300; line-height: 1.2; - font-size: calc(1.475rem + 2.7vw); } @media (min-width: 1200px) { .display-4 { @@ -649,9 +645,9 @@ progress { } .display-5 { + font-size: calc(1.425rem + 2.1vw); font-weight: 300; line-height: 1.2; - font-size: calc(1.425rem + 2.1vw); } @media (min-width: 1200px) { .display-5 { @@ -660,9 +656,9 @@ progress { } .display-6 { + font-size: calc(1.375rem + 1.5vw); font-weight: 300; line-height: 1.2; - font-size: calc(1.375rem + 1.5vw); } @media (min-width: 1200px) { .display-6 { @@ -807,7 +803,7 @@ progress { } .col { - flex: 1 0 0; + flex: 1 0 0%; } .row-cols-auto > * { @@ -1016,7 +1012,7 @@ progress { @media (min-width: 576px) { .col-sm { - flex: 1 0 0; + flex: 1 0 0%; } .row-cols-sm-auto > * { flex: 0 0 auto; @@ -1185,7 +1181,7 @@ progress { } @media (min-width: 768px) { .col-md { - flex: 1 0 0; + flex: 1 0 0%; } .row-cols-md-auto > * { flex: 0 0 auto; @@ -1354,7 +1350,7 @@ progress { } @media (min-width: 992px) { .col-lg { - flex: 1 0 0; + flex: 1 0 0%; } .row-cols-lg-auto > * { flex: 0 0 auto; @@ -1523,7 +1519,7 @@ progress { } @media (min-width: 1200px) { .col-xl { - flex: 1 0 0; + flex: 1 0 0%; } .row-cols-xl-auto > * { flex: 0 0 auto; @@ -1692,7 +1688,7 @@ progress { } @media (min-width: 1400px) { .col-xxl { - flex: 1 0 0; + flex: 1 0 0%; } .row-cols-xxl-auto > * { flex: 0 0 auto; @@ -2160,6 +2156,10 @@ progress { display: block; padding: 0; } +.form-control::-moz-placeholder { + color: var(--bs-secondary-color); + opacity: 1; +} .form-control::placeholder { color: var(--bs-secondary-color); opacity: 1; @@ -2607,11 +2607,9 @@ textarea.form-control-lg { top: 0; left: 0; z-index: 2; - max-width: 100%; height: 100%; padding: 1rem 0.75rem; overflow: hidden; - color: rgba(var(--bs-body-color-rgb), 0.65); text-align: start; text-overflow: ellipsis; white-space: nowrap; @@ -2629,10 +2627,17 @@ textarea.form-control-lg { .form-floating > .form-control-plaintext { padding: 1rem 0.75rem; } +.form-floating > .form-control::-moz-placeholder, .form-floating > .form-control-plaintext::-moz-placeholder { + color: transparent; +} .form-floating > .form-control::placeholder, .form-floating > .form-control-plaintext::placeholder { color: transparent; } +.form-floating > .form-control:not(:-moz-placeholder-shown), .form-floating > .form-control-plaintext:not(:-moz-placeholder-shown) { + padding-top: 1.625rem; + padding-bottom: 0.625rem; +} .form-floating > .form-control:focus, .form-floating > .form-control:not(:placeholder-shown), .form-floating > .form-control-plaintext:focus, .form-floating > .form-control-plaintext:not(:placeholder-shown) { @@ -2647,19 +2652,19 @@ textarea.form-control-lg { .form-floating > .form-select { padding-top: 1.625rem; padding-bottom: 0.625rem; - padding-left: 0.75rem; +} +.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label { + color: rgba(var(--bs-body-color-rgb), 0.65); + transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); } .form-floating > .form-control:focus ~ label, .form-floating > .form-control:not(:placeholder-shown) ~ label, .form-floating > .form-control-plaintext ~ label, .form-floating > .form-select ~ label { + color: rgba(var(--bs-body-color-rgb), 0.65); transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); } -.form-floating > .form-control:-webkit-autofill ~ label { - transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); -} -.form-floating > textarea:focus ~ label::after, -.form-floating > textarea:not(:placeholder-shown) ~ label::after { +.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label::after { position: absolute; inset: 1rem 0.375rem; z-index: -1; @@ -2668,8 +2673,21 @@ textarea.form-control-lg { background-color: var(--bs-body-bg); border-radius: var(--bs-border-radius); } -.form-floating > textarea:disabled ~ label::after { - background-color: var(--bs-secondary-bg); +.form-floating > .form-control:focus ~ label::after, +.form-floating > .form-control:not(:placeholder-shown) ~ label::after, +.form-floating > .form-control-plaintext ~ label::after, +.form-floating > .form-select ~ label::after { + position: absolute; + inset: 1rem 0.375rem; + z-index: -1; + height: 1.5em; + content: ""; + background-color: var(--bs-body-bg); + border-radius: var(--bs-border-radius); +} +.form-floating > .form-control:-webkit-autofill ~ label { + color: rgba(var(--bs-body-color-rgb), 0.65); + transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); } .form-floating > .form-control-plaintext ~ label { border-width: var(--bs-border-width) 0; @@ -2678,6 +2696,10 @@ textarea.form-control-lg { .form-floating > .form-control:disabled ~ label { color: #6c757d; } +.form-floating > :disabled ~ label::after, +.form-floating > .form-control:disabled ~ label::after { + background-color: var(--bs-secondary-bg); +} .input-group { position: relative; @@ -2760,7 +2782,7 @@ textarea.form-control-lg { border-bottom-right-radius: 0; } .input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) { - margin-left: calc(-1 * var(--bs-border-width)); + margin-left: calc(var(--bs-border-width) * -1); border-top-left-radius: 0; border-bottom-left-radius: 0; } @@ -2802,7 +2824,7 @@ textarea.form-control-lg { .was-validated .form-control:valid, .form-control.is-valid { border-color: var(--bs-form-valid-border-color); padding-right: calc(1.5em + 0.75rem); - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e"); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right calc(0.375em + 0.1875rem) center; background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); @@ -2821,7 +2843,7 @@ textarea.form-control-lg { border-color: var(--bs-form-valid-border-color); } .was-validated .form-select:valid:not([multiple]):not([size]), .was-validated .form-select:valid:not([multiple])[size="1"], .form-select.is-valid:not([multiple]):not([size]), .form-select.is-valid:not([multiple])[size="1"] { - --bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e"); + --bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); padding-right: 4.125rem; background-position: right 0.75rem center, center right 2.25rem; background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); @@ -3733,7 +3755,7 @@ textarea.form-control-lg { } .btn-group > :not(.btn-check:first-child) + .btn, .btn-group > .btn-group:not(:first-child) { - margin-left: calc(-1 * var(--bs-border-width)); + margin-left: calc(var(--bs-border-width) * -1); } .btn-group > .btn:not(:last-child):not(.dropdown-toggle), .btn-group > .btn.dropdown-toggle-split:first-child, @@ -3780,15 +3802,14 @@ textarea.form-control-lg { } .btn-group-vertical > .btn:not(:first-child), .btn-group-vertical > .btn-group:not(:first-child) { - margin-top: calc(-1 * var(--bs-border-width)); + margin-top: calc(var(--bs-border-width) * -1); } .btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle), .btn-group-vertical > .btn-group:not(:last-child) > .btn { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } -.btn-group-vertical > .btn:nth-child(n+3), -.btn-group-vertical > :not(.btn-check) + .btn, +.btn-group-vertical > .btn ~ .btn, .btn-group-vertical > .btn-group:not(:first-child) > .btn { border-top-left-radius: 0; border-top-right-radius: 0; @@ -3912,8 +3933,8 @@ textarea.form-control-lg { .nav-justified > .nav-link, .nav-justified .nav-item { - flex-grow: 1; flex-basis: 0; + flex-grow: 1; text-align: center; } @@ -4014,8 +4035,8 @@ textarea.form-control-lg { } .navbar-collapse { - flex-grow: 1; flex-basis: 100%; + flex-grow: 1; align-items: center; } @@ -4510,7 +4531,7 @@ textarea.form-control-lg { flex-flow: row wrap; } .card-group > .card { - flex: 1 0 0; + flex: 1 0 0%; margin-bottom: 0; } .card-group > .card + .card { @@ -4521,24 +4542,24 @@ textarea.form-control-lg { border-top-right-radius: 0; border-bottom-right-radius: 0; } - .card-group > .card:not(:last-child) > .card-img-top, - .card-group > .card:not(:last-child) > .card-header { + .card-group > .card:not(:last-child) .card-img-top, + .card-group > .card:not(:last-child) .card-header { border-top-right-radius: 0; } - .card-group > .card:not(:last-child) > .card-img-bottom, - .card-group > .card:not(:last-child) > .card-footer { + .card-group > .card:not(:last-child) .card-img-bottom, + .card-group > .card:not(:last-child) .card-footer { border-bottom-right-radius: 0; } .card-group > .card:not(:first-child) { border-top-left-radius: 0; border-bottom-left-radius: 0; } - .card-group > .card:not(:first-child) > .card-img-top, - .card-group > .card:not(:first-child) > .card-header { + .card-group > .card:not(:first-child) .card-img-top, + .card-group > .card:not(:first-child) .card-header { border-top-left-radius: 0; } - .card-group > .card:not(:first-child) > .card-img-bottom, - .card-group > .card:not(:first-child) > .card-footer { + .card-group > .card:not(:first-child) .card-img-bottom, + .card-group > .card:not(:first-child) .card-footer { border-bottom-left-radius: 0; } } @@ -4555,11 +4576,11 @@ textarea.form-control-lg { --bs-accordion-btn-padding-y: 1rem; --bs-accordion-btn-color: var(--bs-body-color); --bs-accordion-btn-bg: var(--bs-accordion-bg); - --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); + --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e"); --bs-accordion-btn-icon-width: 1.25rem; --bs-accordion-btn-icon-transform: rotate(-180deg); --bs-accordion-btn-icon-transition: transform 0.2s ease-in-out; - --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); + --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e"); --bs-accordion-btn-focus-box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); --bs-accordion-body-padding-x: 1.25rem; --bs-accordion-body-padding-y: 1rem; @@ -4669,15 +4690,16 @@ textarea.form-control-lg { .accordion-flush > .accordion-item:last-child { border-bottom: 0; } -.accordion-flush > .accordion-item > .accordion-collapse, -.accordion-flush > .accordion-item > .accordion-header .accordion-button, -.accordion-flush > .accordion-item > .accordion-header .accordion-button.collapsed { +.accordion-flush > .accordion-item > .accordion-header .accordion-button, .accordion-flush > .accordion-item > .accordion-header .accordion-button.collapsed { + border-radius: 0; +} +.accordion-flush > .accordion-item > .accordion-collapse { border-radius: 0; } [data-bs-theme=dark] .accordion-button::after { - --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e"); - --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e"); + --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); + --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); } .breadcrumb { @@ -4781,7 +4803,7 @@ textarea.form-control-lg { } .page-item:not(:first-child) .page-link { - margin-left: calc(-1 * var(--bs-border-width)); + margin-left: calc(var(--bs-border-width) * -1); } .page-item:first-child .page-link { border-top-left-radius: var(--bs-pagination-border-radius); @@ -4930,7 +4952,7 @@ textarea.form-control-lg { @keyframes progress-bar-stripes { 0% { - background-position-x: var(--bs-progress-height); + background-position-x: 1rem; } } .progress, @@ -5024,6 +5046,22 @@ textarea.form-control-lg { counter-increment: section; } +.list-group-item-action { + width: 100%; + color: var(--bs-list-group-action-color); + text-align: inherit; +} +.list-group-item-action:hover, .list-group-item-action:focus { + z-index: 1; + color: var(--bs-list-group-action-hover-color); + text-decoration: none; + background-color: var(--bs-list-group-action-hover-bg); +} +.list-group-item-action:active { + color: var(--bs-list-group-action-active-color); + background-color: var(--bs-list-group-action-active-bg); +} + .list-group-item { position: relative; display: block; @@ -5060,22 +5098,6 @@ textarea.form-control-lg { border-top-width: var(--bs-list-group-border-width); } -.list-group-item-action { - width: 100%; - color: var(--bs-list-group-action-color); - text-align: inherit; -} -.list-group-item-action:not(.active):hover, .list-group-item-action:not(.active):focus { - z-index: 1; - color: var(--bs-list-group-action-hover-color); - text-decoration: none; - background-color: var(--bs-list-group-action-hover-bg); -} -.list-group-item-action:not(.active):active { - color: var(--bs-list-group-action-active-color); - background-color: var(--bs-list-group-action-active-bg); -} - .list-group-horizontal { flex-direction: row; } @@ -5335,19 +5357,19 @@ textarea.form-control-lg { .btn-close { --bs-btn-close-color: #000; - --bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e"); + --bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e"); --bs-btn-close-opacity: 0.5; --bs-btn-close-hover-opacity: 0.75; --bs-btn-close-focus-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); --bs-btn-close-focus-opacity: 1; --bs-btn-close-disabled-opacity: 0.25; + --bs-btn-close-white-filter: invert(1) grayscale(100%) brightness(200%); box-sizing: content-box; width: 1em; height: 1em; padding: 0.25em 0.25em; color: var(--bs-btn-close-color); background: transparent var(--bs-btn-close-bg) center/1em auto no-repeat; - filter: var(--bs-btn-close-filter); border: 0; border-radius: 0.375rem; opacity: var(--bs-btn-close-opacity); @@ -5371,16 +5393,11 @@ textarea.form-control-lg { } .btn-close-white { - --bs-btn-close-filter: invert(1) grayscale(100%) brightness(200%); + filter: var(--bs-btn-close-white-filter); } -:root, -[data-bs-theme=light] { - --bs-btn-close-filter: ; -} - -[data-bs-theme=dark] { - --bs-btn-close-filter: invert(1) grayscale(100%) brightness(200%); +[data-bs-theme=dark] .btn-close { + filter: var(--bs-btn-close-white-filter); } .toast { @@ -5457,7 +5474,7 @@ textarea.form-control-lg { --bs-modal-width: 500px; --bs-modal-padding: 1rem; --bs-modal-margin: 0.5rem; - --bs-modal-color: var(--bs-body-color); + --bs-modal-color: ; --bs-modal-bg: var(--bs-body-bg); --bs-modal-border-color: var(--bs-border-color-translucent); --bs-modal-border-width: var(--bs-border-width); @@ -5493,8 +5510,8 @@ textarea.form-control-lg { pointer-events: none; } .modal.fade .modal-dialog { - transform: translate(0, -50px); transition: transform 0.3s ease-out; + transform: translate(0, -50px); } @media (prefers-reduced-motion: reduce) { .modal.fade .modal-dialog { @@ -5569,10 +5586,7 @@ textarea.form-control-lg { } .modal-header .btn-close { padding: calc(var(--bs-modal-header-padding-y) * 0.5) calc(var(--bs-modal-header-padding-x) * 0.5); - margin-top: calc(-0.5 * var(--bs-modal-header-padding-y)); - margin-right: calc(-0.5 * var(--bs-modal-header-padding-x)); - margin-bottom: calc(-0.5 * var(--bs-modal-header-padding-y)); - margin-left: auto; + margin: calc(-0.5 * var(--bs-modal-header-padding-y)) calc(-0.5 * var(--bs-modal-header-padding-x)) calc(-0.5 * var(--bs-modal-header-padding-y)) auto; } .modal-title { @@ -6093,7 +6107,6 @@ textarea.form-control-lg { color: #fff; text-align: center; background: none; - filter: var(--bs-carousel-control-icon-filter); border: 0; opacity: 0.5; transition: opacity 0.15s ease; @@ -6132,11 +6145,11 @@ textarea.form-control-lg { } .carousel-control-prev-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/%3e%3c/svg%3e") /*rtl:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e")*/; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e") /*rtl:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")*/; } .carousel-control-next-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e") /*rtl:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/%3e%3c/svg%3e")*/; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e") /*rtl:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")*/; } .carousel-indicators { @@ -6162,7 +6175,7 @@ textarea.form-control-lg { margin-left: 3px; text-indent: -999px; cursor: pointer; - background-color: var(--bs-carousel-indicator-active-bg); + background-color: #fff; background-clip: padding-box; border: 0; border-top: 10px solid transparent; @@ -6186,33 +6199,36 @@ textarea.form-control-lg { left: 15%; padding-top: 1.25rem; padding-bottom: 1.25rem; - color: var(--bs-carousel-caption-color); + color: #fff; text-align: center; } -.carousel-dark { - --bs-carousel-indicator-active-bg: #000; - --bs-carousel-caption-color: #000; - --bs-carousel-control-icon-filter: invert(1) grayscale(100); +.carousel-dark .carousel-control-prev-icon, +.carousel-dark .carousel-control-next-icon { + filter: invert(1) grayscale(100); +} +.carousel-dark .carousel-indicators [data-bs-target] { + background-color: #000; +} +.carousel-dark .carousel-caption { + color: #000; } -:root, -[data-bs-theme=light] { - --bs-carousel-indicator-active-bg: #fff; - --bs-carousel-caption-color: #fff; - --bs-carousel-control-icon-filter: ; +[data-bs-theme=dark] .carousel .carousel-control-prev-icon, +[data-bs-theme=dark] .carousel .carousel-control-next-icon, [data-bs-theme=dark].carousel .carousel-control-prev-icon, +[data-bs-theme=dark].carousel .carousel-control-next-icon { + filter: invert(1) grayscale(100); } - -[data-bs-theme=dark] { - --bs-carousel-indicator-active-bg: #000; - --bs-carousel-caption-color: #000; - --bs-carousel-control-icon-filter: invert(1) grayscale(100); +[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target], [data-bs-theme=dark].carousel .carousel-indicators [data-bs-target] { + background-color: #000; +} +[data-bs-theme=dark] .carousel .carousel-caption, [data-bs-theme=dark].carousel .carousel-caption { + color: #000; } .spinner-grow, .spinner-border { display: inline-block; - flex-shrink: 0; width: var(--bs-spinner-width); height: var(--bs-spinner-height); vertical-align: var(--bs-spinner-vertical-align); @@ -6757,10 +6773,7 @@ textarea.form-control-lg { } .offcanvas-header .btn-close { padding: calc(var(--bs-offcanvas-padding-y) * 0.5) calc(var(--bs-offcanvas-padding-x) * 0.5); - margin-top: calc(-0.5 * var(--bs-offcanvas-padding-y)); - margin-right: calc(-0.5 * var(--bs-offcanvas-padding-x)); - margin-bottom: calc(-0.5 * var(--bs-offcanvas-padding-y)); - margin-left: auto; + margin: calc(-0.5 * var(--bs-offcanvas-padding-y)) calc(-0.5 * var(--bs-offcanvas-padding-x)) calc(-0.5 * var(--bs-offcanvas-padding-y)) auto; } .offcanvas-title { @@ -7161,10 +7174,6 @@ textarea.form-control-lg { .visually-hidden-focusable:not(:focus):not(:focus-within):not(caption) { position: absolute !important; } -.visually-hidden *, -.visually-hidden-focusable:not(:focus):not(:focus-within) * { - overflow: hidden !important; -} .stretched-link::after { position: absolute; diff --git a/src/static/scripts/datatables.css b/src/static/scripts/datatables.css index d91ea601..878e2347 100644 --- a/src/static/scripts/datatables.css +++ b/src/static/scripts/datatables.css @@ -4,32 +4,31 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-2.3.7 + * https://datatables.net/download/#bs5/dt-2.0.8 * * Included libraries: - * DataTables 2.3.7 + * DataTables 2.0.8 */ +@charset "UTF-8"; :root { --dt-row-selected: 13, 110, 253; --dt-row-selected-text: 255, 255, 255; - --dt-row-selected-link: 228, 228, 228; + --dt-row-selected-link: 9, 10, 11; --dt-row-stripe: 0, 0, 0; --dt-row-hover: 0, 0, 0; --dt-column-ordering: 0, 0, 0; - --dt-header-align-items: center; - --dt-header-vertical-align: middle; --dt-html-background: white; } :root.dark { --dt-html-background: rgb(33, 37, 41); } -table.dataTable tbody td.dt-control { +table.dataTable td.dt-control { text-align: center; cursor: pointer; } -table.dataTable tbody td.dt-control:before { +table.dataTable td.dt-control:before { display: inline-block; box-sizing: border-box; content: ""; @@ -38,32 +37,23 @@ table.dataTable tbody td.dt-control:before { border-bottom: 5px solid transparent; border-right: 0px solid transparent; } -table.dataTable tbody tr.dt-hasChild td.dt-control:before { +table.dataTable tr.dt-hasChild td.dt-control:before { border-top: 10px solid rgba(0, 0, 0, 0.5); border-left: 5px solid transparent; border-bottom: 0px solid transparent; border-right: 5px solid transparent; } -table.dataTable tfoot:empty { - display: none; -} html.dark table.dataTable td.dt-control:before, -:root[data-bs-theme=dark] table.dataTable td.dt-control:before, -:root[data-theme=dark] table.dataTable td.dt-control:before { +:root[data-bs-theme=dark] table.dataTable td.dt-control:before { border-left-color: rgba(255, 255, 255, 0.5); } html.dark table.dataTable tr.dt-hasChild td.dt-control:before, -:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before, -:root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before { +:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before { border-top-color: rgba(255, 255, 255, 0.5); border-left-color: transparent; } -div.dt-scroll { - width: 100%; -} - div.dt-scroll-body thead tr, div.dt-scroll-body tfoot tr { height: 0; @@ -88,42 +78,52 @@ table.dataTable thead > tr > th:active, table.dataTable thead > tr > td:active { outline: none; } -table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before, -table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order:before, -table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before { +table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before, +table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:before, +table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:before { position: absolute; display: block; bottom: 50%; - content: "\25B2"; - content: "\25B2"/""; + content: "▲"; + content: "▲"/""; } -table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after, -table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order:after, -table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after { +table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after, +table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order:after, +table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after { position: absolute; display: block; top: 50%; - content: "\25BC"; - content: "\25BC"/""; + content: "▼"; + content: "▼"/""; } -table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order, table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order, -table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order, -table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order, -table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order, -table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order { +table.dataTable thead > tr > th.dt-orderable-asc, table.dataTable thead > tr > th.dt-orderable-desc, table.dataTable thead > tr > th.dt-ordering-asc, table.dataTable thead > tr > th.dt-ordering-desc, +table.dataTable thead > tr > td.dt-orderable-asc, +table.dataTable thead > tr > td.dt-orderable-desc, +table.dataTable thead > tr > td.dt-ordering-asc, +table.dataTable thead > tr > td.dt-ordering-desc { position: relative; - width: 12px; - height: 20px; + padding-right: 30px; } -table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-asc .dt-column-order:after, table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-desc .dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after, -table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order:before, -table.dataTable thead > tr > td.dt-orderable-asc .dt-column-order:after, -table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order:before, -table.dataTable thead > tr > td.dt-orderable-desc .dt-column-order:after, -table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before, -table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:after, -table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:before, -table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after { +table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order, +table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order, +table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order, +table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order, +table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order { + position: absolute; + right: 12px; + top: 0; + bottom: 0; + width: 12px; +} +table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:after, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after, +table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:before, +table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:after, +table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order:before, +table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order:after, +table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:before, +table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:after, +table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:before, +table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after { left: 0; opacity: 0.125; line-height: 9px; @@ -140,15 +140,14 @@ table.dataTable thead > tr > td.dt-orderable-desc:hover { outline: 2px solid rgba(0, 0, 0, 0.05); outline-offset: -2px; } -table.dataTable thead > tr > th.dt-ordering-asc .dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc .dt-column-order:after, -table.dataTable thead > tr > td.dt-ordering-asc .dt-column-order:before, -table.dataTable thead > tr > td.dt-ordering-desc .dt-column-order:after { +table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after, +table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:before, +table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after { opacity: 0.6; } -table.dataTable thead > tr > th.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) .dt-column-order:empty, table.dataTable thead > tr > th.sorting_desc_disabled .dt-column-order:after, table.dataTable thead > tr > th.sorting_asc_disabled .dt-column-order:before, -table.dataTable thead > tr > td.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) .dt-column-order:empty, -table.dataTable thead > tr > td.sorting_desc_disabled .dt-column-order:after, -table.dataTable thead > tr > td.sorting_asc_disabled .dt-column-order:before { +table.dataTable thead > tr > th.sorting_desc_disabled span.dt-column-order:after, table.dataTable thead > tr > th.sorting_asc_disabled span.dt-column-order:before, +table.dataTable thead > tr > td.sorting_desc_disabled span.dt-column-order:after, +table.dataTable thead > tr > td.sorting_asc_disabled span.dt-column-order:before { display: none; } table.dataTable thead > tr > th:active, @@ -156,40 +155,6 @@ table.dataTable thead > tr > td:active { outline: none; } -table.dataTable thead > tr > th div.dt-column-header, -table.dataTable thead > tr > th div.dt-column-footer, -table.dataTable thead > tr > td div.dt-column-header, -table.dataTable thead > tr > td div.dt-column-footer, -table.dataTable tfoot > tr > th div.dt-column-header, -table.dataTable tfoot > tr > th div.dt-column-footer, -table.dataTable tfoot > tr > td div.dt-column-header, -table.dataTable tfoot > tr > td div.dt-column-footer { - display: flex; - justify-content: space-between; - align-items: var(--dt-header-align-items); - gap: 4px; -} -table.dataTable thead > tr > th div.dt-column-header .dt-column-title, -table.dataTable thead > tr > th div.dt-column-footer .dt-column-title, -table.dataTable thead > tr > td div.dt-column-header .dt-column-title, -table.dataTable thead > tr > td div.dt-column-footer .dt-column-title, -table.dataTable tfoot > tr > th div.dt-column-header .dt-column-title, -table.dataTable tfoot > tr > th div.dt-column-footer .dt-column-title, -table.dataTable tfoot > tr > td div.dt-column-header .dt-column-title, -table.dataTable tfoot > tr > td div.dt-column-footer .dt-column-title { - flex-grow: 1; -} -table.dataTable thead > tr > th div.dt-column-header .dt-column-title:empty, -table.dataTable thead > tr > th div.dt-column-footer .dt-column-title:empty, -table.dataTable thead > tr > td div.dt-column-header .dt-column-title:empty, -table.dataTable thead > tr > td div.dt-column-footer .dt-column-title:empty, -table.dataTable tfoot > tr > th div.dt-column-header .dt-column-title:empty, -table.dataTable tfoot > tr > th div.dt-column-footer .dt-column-title:empty, -table.dataTable tfoot > tr > td div.dt-column-header .dt-column-title:empty, -table.dataTable tfoot > tr > td div.dt-column-footer .dt-column-title:empty { - display: none; -} - div.dt-scroll-body > table.dataTable > thead > tr > th, div.dt-scroll-body > table.dataTable > thead > tr > td { overflow: hidden; @@ -280,30 +245,10 @@ table.dataTable th, table.dataTable td { box-sizing: border-box; } -table.dataTable th.dt-type-numeric, table.dataTable th.dt-type-date, -table.dataTable td.dt-type-numeric, -table.dataTable td.dt-type-date { - text-align: right; -} -table.dataTable th.dt-type-numeric div.dt-column-header, -table.dataTable th.dt-type-numeric div.dt-column-footer, table.dataTable th.dt-type-date div.dt-column-header, -table.dataTable th.dt-type-date div.dt-column-footer, -table.dataTable td.dt-type-numeric div.dt-column-header, -table.dataTable td.dt-type-numeric div.dt-column-footer, -table.dataTable td.dt-type-date div.dt-column-header, -table.dataTable td.dt-type-date div.dt-column-footer { - flex-direction: row-reverse; -} table.dataTable th.dt-left, table.dataTable td.dt-left { text-align: left; } -table.dataTable th.dt-left div.dt-column-header, -table.dataTable th.dt-left div.dt-column-footer, -table.dataTable td.dt-left div.dt-column-header, -table.dataTable td.dt-left div.dt-column-footer { - flex-direction: row; -} table.dataTable th.dt-center, table.dataTable td.dt-center { text-align: center; @@ -312,22 +257,10 @@ table.dataTable th.dt-right, table.dataTable td.dt-right { text-align: right; } -table.dataTable th.dt-right div.dt-column-header, -table.dataTable th.dt-right div.dt-column-footer, -table.dataTable td.dt-right div.dt-column-header, -table.dataTable td.dt-right div.dt-column-footer { - flex-direction: row-reverse; -} table.dataTable th.dt-justify, table.dataTable td.dt-justify { text-align: justify; } -table.dataTable th.dt-justify div.dt-column-header, -table.dataTable th.dt-justify div.dt-column-footer, -table.dataTable td.dt-justify div.dt-column-header, -table.dataTable td.dt-justify div.dt-column-footer { - flex-direction: row; -} table.dataTable th.dt-nowrap, table.dataTable td.dt-nowrap { white-space: nowrap; @@ -337,12 +270,16 @@ table.dataTable td.dt-empty { text-align: center; vertical-align: top; } +table.dataTable th.dt-type-numeric, table.dataTable th.dt-type-date, +table.dataTable td.dt-type-numeric, +table.dataTable td.dt-type-date { + text-align: right; +} table.dataTable thead th, table.dataTable thead td, table.dataTable tfoot th, table.dataTable tfoot td { text-align: left; - vertical-align: var(--dt-header-vertical-align); } table.dataTable thead th.dt-head-left, table.dataTable thead td.dt-head-left, @@ -350,16 +287,6 @@ table.dataTable tfoot th.dt-head-left, table.dataTable tfoot td.dt-head-left { text-align: left; } -table.dataTable thead th.dt-head-left div.dt-column-header, -table.dataTable thead th.dt-head-left div.dt-column-footer, -table.dataTable thead td.dt-head-left div.dt-column-header, -table.dataTable thead td.dt-head-left div.dt-column-footer, -table.dataTable tfoot th.dt-head-left div.dt-column-header, -table.dataTable tfoot th.dt-head-left div.dt-column-footer, -table.dataTable tfoot td.dt-head-left div.dt-column-header, -table.dataTable tfoot td.dt-head-left div.dt-column-footer { - flex-direction: row; -} table.dataTable thead th.dt-head-center, table.dataTable thead td.dt-head-center, table.dataTable tfoot th.dt-head-center, @@ -372,32 +299,12 @@ table.dataTable tfoot th.dt-head-right, table.dataTable tfoot td.dt-head-right { text-align: right; } -table.dataTable thead th.dt-head-right div.dt-column-header, -table.dataTable thead th.dt-head-right div.dt-column-footer, -table.dataTable thead td.dt-head-right div.dt-column-header, -table.dataTable thead td.dt-head-right div.dt-column-footer, -table.dataTable tfoot th.dt-head-right div.dt-column-header, -table.dataTable tfoot th.dt-head-right div.dt-column-footer, -table.dataTable tfoot td.dt-head-right div.dt-column-header, -table.dataTable tfoot td.dt-head-right div.dt-column-footer { - flex-direction: row-reverse; -} table.dataTable thead th.dt-head-justify, table.dataTable thead td.dt-head-justify, table.dataTable tfoot th.dt-head-justify, table.dataTable tfoot td.dt-head-justify { text-align: justify; } -table.dataTable thead th.dt-head-justify div.dt-column-header, -table.dataTable thead th.dt-head-justify div.dt-column-footer, -table.dataTable thead td.dt-head-justify div.dt-column-header, -table.dataTable thead td.dt-head-justify div.dt-column-footer, -table.dataTable tfoot th.dt-head-justify div.dt-column-header, -table.dataTable tfoot th.dt-head-justify div.dt-column-footer, -table.dataTable tfoot td.dt-head-justify div.dt-column-header, -table.dataTable tfoot td.dt-head-justify div.dt-column-footer { - flex-direction: row; -} table.dataTable thead th.dt-head-nowrap, table.dataTable thead td.dt-head-nowrap, table.dataTable tfoot th.dt-head-nowrap, @@ -452,7 +359,7 @@ table.table.dataTable > tbody > tr.selected > * { color: rgb(var(--dt-row-selected-text)); } table.table.dataTable > tbody > tr.selected a { - color: rgb(228, 228, 228); + color: rgb(9, 10, 11); color: rgb(var(--dt-row-selected-link)); } table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1) > * { @@ -470,34 +377,6 @@ table.table.dataTable.table-hover > tbody > tr.selected:hover > * { box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.975); } -div.dt-container div.dt-layout-start > *:not(:last-child) { - margin-right: 1em; -} -div.dt-container div.dt-layout-end > *:not(:first-child) { - margin-left: 1em; -} -div.dt-container div.dt-layout-full { - width: 100%; -} -div.dt-container div.dt-layout-full > *:only-child { - margin-left: auto; - margin-right: auto; -} -div.dt-container div.dt-layout-table > div { - display: block !important; -} - -@media screen and (max-width: 767px) { - div.dt-container div.dt-layout-start > *:not(:last-child) { - margin-right: 0; - } - div.dt-container div.dt-layout-end > *:not(:first-child) { - margin-left: 0; - } -} -div.dt-container { - position: relative; -} div.dt-container div.dt-length label { font-weight: normal; text-align: left; @@ -521,6 +400,9 @@ div.dt-container div.dt-search input { display: inline-block; width: auto; } +div.dt-container div.dt-info { + padding-top: 0.85em; +} div.dt-container div.dt-paging { margin: 0; } @@ -586,19 +468,14 @@ table.dataTable.table-sm > thead > tr td.dt-orderable-asc, table.dataTable.table-sm > thead > tr td.dt-orderable-desc, table.dataTable.table-sm > thead > tr td.dt-ordering-asc, table.dataTable.table-sm > thead > tr td.dt-ordering-desc { - padding-right: 0.25rem; + padding-right: 20px; } -table.dataTable.table-sm > thead > tr th.dt-orderable-asc .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-orderable-desc .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-ordering-asc .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-ordering-desc .dt-column-order, -table.dataTable.table-sm > thead > tr td.dt-orderable-asc .dt-column-order, -table.dataTable.table-sm > thead > tr td.dt-orderable-desc .dt-column-order, -table.dataTable.table-sm > thead > tr td.dt-ordering-asc .dt-column-order, -table.dataTable.table-sm > thead > tr td.dt-ordering-desc .dt-column-order { - right: 0.25rem; -} -table.dataTable.table-sm > thead > tr th.dt-type-date .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-type-numeric .dt-column-order, -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-orderable-asc span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-orderable-desc span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-ordering-asc span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-ordering-desc span.dt-column-order, +table.dataTable.table-sm > thead > tr td.dt-orderable-asc span.dt-column-order, +table.dataTable.table-sm > thead > tr td.dt-orderable-desc span.dt-column-order, +table.dataTable.table-sm > thead > tr td.dt-ordering-asc span.dt-column-order, +table.dataTable.table-sm > thead > tr td.dt-ordering-desc span.dt-column-order { + right: 5px; } div.dt-scroll-head table.table-bordered { @@ -606,8 +483,7 @@ div.dt-scroll-head table.table-bordered { } div.table-responsive > div.dt-container > div.row { - margin-left: 0; - margin-right: 0; + margin: 0; } div.table-responsive > div.dt-container > div.row > div[class^=col-]:first-child { padding-left: 0; diff --git a/src/static/scripts/datatables.js b/src/static/scripts/datatables.js index 9c7fa042..3d22cbde 100644 --- a/src/static/scripts/datatables.js +++ b/src/static/scripts/datatables.js @@ -4,16 +4,34 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-2.3.7 + * https://datatables.net/download/#bs5/dt-2.0.8 * * Included libraries: - * DataTables 2.3.7 + * DataTables 2.0.8 */ -/*! DataTables 2.3.7 +/*! DataTables 2.0.8 * © SpryMedia Ltd - datatables.net/license */ +/** + * @summary DataTables + * @description Paginate, search and order HTML tables + * @version 2.0.8 + * @author SpryMedia Ltd + * @contact www.datatables.net + * @copyright SpryMedia Ltd. + * + * This source file is free software, available under the following license: + * MIT license - https://datatables.net/license + * + * This source file is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the license files for details. + * + * For details please refer to: https://www.datatables.net + */ + (function( factory ) { "use strict"; @@ -98,22 +116,19 @@ var i=0, iLen; var sId = this.getAttribute( 'id' ); + var bInitHandedOff = false; var defaults = DataTable.defaults; var $this = $(this); - // Sanity check + + /* Sanity check */ if ( this.nodeName.toLowerCase() != 'table' ) { _fnLog( null, 0, 'Non-table node initialisation ('+this.nodeName+')', 2 ); return; } - // Special case for options - if (oInit.on && oInit.on.options) { - _fnListener($this, 'options', oInit.on.options); - } - - $this.trigger( 'options.dt', oInit ); + $(this).trigger( 'options.dt', oInit ); /* Backwards compatibility for the defaults */ _fnCompatOpts( defaults ); @@ -124,7 +139,7 @@ _fnCamelToHungarian( defaults.column, defaults.column, true ); /* Setting up the initialisation object */ - _fnCamelToHungarian( defaults, $.extend( oInit, _fnEscapeObject($this.data()) ), true ); + _fnCamelToHungarian( defaults, $.extend( oInit, $this.data() ), true ); @@ -178,15 +193,12 @@ this.id = sId; } - // Replacing an existing colgroup with our own. Not ideal, but a merge could take a lot of code - $this.children('colgroup').remove(); - /* Create the settings object for this table and set some of the default parameters */ var oSettings = $.extend( true, {}, DataTable.models.oSettings, { "sDestroyWidth": $this[0].style.width, "sInstance": sId, "sTableId": sId, - colgroup: $(''), + colgroup: $('').prependTo(this), fastData: function (row, column, type) { return _fnGetCellData(oSettings, row, column, type); } @@ -254,12 +266,6 @@ "rowId", "caption", "layout", - "orderDescReverse", - "orderIndicators", - "orderHandler", - "titleRow", - "typeDetect", - "columnTitleTag", [ "iCookieDuration", "iStateDuration" ], // backwards compat [ "oSearch", "oPreviousSearch" ], [ "aoSearchCols", "aoPreSearchCols" ], @@ -287,13 +293,6 @@ oSettings.rowIdFn = _fnGetObjectDataFn( oInit.rowId ); - // Add event listeners - if (oInit.on) { - Object.keys(oInit.on).forEach(function (key) { - _fnListener($this, key, oInit.on[key]); - }); - } - /* Browser support detection */ _fnBrowserDetect( oSettings ); @@ -313,14 +312,38 @@ oSettings._iDisplayStart = oInit.iDisplayStart; } - var defer = oInit.iDeferLoading; - if ( defer !== null ) - { - oSettings.deferLoading = true; + /* Language definitions */ + var oLanguage = oSettings.oLanguage; + $.extend( true, oLanguage, oInit.oLanguage ); - var tmp = Array.isArray(defer); - oSettings._iRecordsDisplay = tmp ? defer[0] : defer; - oSettings._iRecordsTotal = tmp ? defer[1] : defer; + if ( oLanguage.sUrl ) + { + /* Get the language definitions from a file - because this Ajax call makes the language + * get async to the remainder of this function we use bInitHandedOff to indicate that + * _fnInitialise will be fired by the returned Ajax handler, rather than the constructor + */ + $.ajax( { + dataType: 'json', + url: oLanguage.sUrl, + success: function ( json ) { + _fnCamelToHungarian( defaults.oLanguage, json ); + $.extend( true, oLanguage, json, oSettings.oInit.oLanguage ); + + _fnCallbackFire( oSettings, null, 'i18n', [oSettings], true); + _fnInitialise( oSettings ); + }, + error: function () { + // Error occurred loading language file + _fnLog( oSettings, 0, 'i18n file loading error', 21 ); + + // continue on as best we can + _fnInitialise( oSettings ); + } + } ); + bInitHandedOff = true; + } + else { + _fnCallbackFire( oSettings, null, 'i18n', [oSettings]); } /* @@ -354,7 +377,7 @@ /* HTML5 attribute detection - build an mData object automatically if the * attributes are found */ - var rowOne = $this.children('tbody').find('tr:first-child').eq(0); + var rowOne = $this.children('tbody').find('tr').eq(0); if ( rowOne.length ) { var a = function ( cell, name ) { @@ -387,118 +410,113 @@ } ); } - // Must be done after everything which can be overridden by the state saving! + var features = oSettings.oFeatures; + var loadedInit = function () { + /* + * Sorting + * @todo For modularisation (1.11) this needs to do into a sort start up handler + */ + + // If aaSorting is not defined, then we use the first indicator in asSorting + // in case that has been altered, so the default sort reflects that option + if ( oInit.aaSorting === undefined ) { + var sorting = oSettings.aaSorting; + for ( i=0, iLen=sorting.length ; i').appendTo( $this ); + } + + caption.html( oSettings.caption ); + } + + // Store the caption side, so we can remove the element from the document + // when creating the element + if (caption.length) { + caption[0]._captionSide = caption.css('caption-side'); + oSettings.captionNode = caption[0]; + } + + if ( thead.length === 0 ) { + thead = $('').appendTo($this); + } + oSettings.nTHead = thead[0]; + $('tr', thead).addClass(oClasses.thead.row); + + var tbody = $this.children('tbody'); + if ( tbody.length === 0 ) { + tbody = $('').insertAfter(thead); + } + oSettings.nTBody = tbody[0]; + + var tfoot = $this.children('tfoot'); + if ( tfoot.length === 0 ) { + // If we are a scrolling table, and no footer has been given, then we need to create + // a tfoot element for the caption element to be appended to + tfoot = $('').appendTo($this); + } + oSettings.nTFoot = tfoot[0]; + $('tr', tfoot).addClass(oClasses.tfoot.row); + + // Check if there is data passing into the constructor + if ( oInit.aaData ) { + for ( i=0 ; i').prependTo( $this ); - } - - caption.html( oSettings.caption ); - } - - // Store the caption side, so we can remove the element from the document - // when creating the element - if (caption.length) { - caption[0]._captionSide = caption.css('caption-side'); - oSettings.captionNode = caption[0]; - } - - // Place the colgroup element in the correct location for the HTML structure - if (caption.length) { - oSettings.colgroup.insertAfter(caption); + _fnLoadState( oSettings, oInit, loadedInit ); } else { - oSettings.colgroup.prependTo(oSettings.nTable); + loadedInit(); } - if ( thead.length === 0 ) { - thead = $('').appendTo($this); - } - oSettings.nTHead = thead[0]; - - var tbody = $this.children('tbody'); - if ( tbody.length === 0 ) { - tbody = $('').insertAfter(thead); - } - oSettings.nTBody = tbody[0]; - - var tfoot = $this.children('tfoot'); - if ( tfoot.length === 0 ) { - // If we are a scrolling table, and no footer has been given, then we need to create - // a tfoot element for the caption element to be appended to - tfoot = $('').appendTo($this); - } - oSettings.nTFoot = tfoot[0]; - - // Copy the data index array - oSettings.aiDisplay = oSettings.aiDisplayMaster.slice(); - - // Initialisation complete - table can be drawn - oSettings.bInitialised = true; - - // Language definitions - var oLanguage = oSettings.oLanguage; - $.extend( true, oLanguage, oInit.oLanguage ); - - if ( oLanguage.sUrl ) { - // Get the language definitions from a file - $.ajax( { - dataType: 'json', - url: oLanguage.sUrl, - success: function ( json ) { - _fnCamelToHungarian( defaults.oLanguage, json ); - $.extend( true, oLanguage, json, oSettings.oInit.oLanguage ); - - _fnCallbackFire( oSettings, null, 'i18n', [oSettings], true); - _fnInitialise( oSettings ); - }, - error: function () { - // Error occurred loading language file - _fnLog( oSettings, 0, 'i18n file loading error', 21 ); - - // Continue on as best we can - _fnInitialise( oSettings ); - } - } ); - } - else { - _fnCallbackFire( oSettings, null, 'i18n', [oSettings], true); - _fnInitialise( oSettings ); - } } ); _that = null; return this; @@ -520,13 +538,6 @@ * @namespace */ DataTable.ext = _ext = { - /** - * DataTables build type (expanded by the download builder) - * - * @type string - */ - builder: "bs5/dt-2.3.7", - /** * Buttons. For use with the Buttons extension for DataTables. This is * defined here so other extensions can define buttons regardless of load @@ -538,14 +549,6 @@ buttons: {}, - /** - * ColumnControl buttons and content - * - * @type object - */ - ccContent: {}, - - /** * Element class names * @@ -555,6 +558,14 @@ classes: {}, + /** + * DataTables build type (expanded by the download builder) + * + * @type string + */ + builder: "bs5/dt-2.0.8", + + /** * Error reporting. * @@ -566,11 +577,6 @@ */ 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 @@ -755,7 +761,7 @@ * * 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 + * allows much greater control over the the data that is being used to * order a column, but is necessarily therefore more complex. * * This type of ordering is useful if you want to do ordering based on data @@ -914,7 +920,7 @@ * `{type}-asc` and `{type}-desc` together. It is generally recommended * that only `{type}-pre` is used, as this provides the optimal * implementation in terms of speed, although the others are provided - * for compatibility with existing JavaScript sort functions. + * for compatibility with existing Javascript sort functions. * * `{type}-pre`: Functions defined take a single parameter: * @@ -924,7 +930,7 @@ * * * `{*}` Data to be sorted upon * - * `{type}-asc` and `{type}-desc`: Functions are typical JavaScript sort + * `{type}-asc` and `{type}-desc`: Functions are typical Javascript sort * functions, taking two parameters: * * 1. `{*}` Data to compare to the second parameter @@ -1027,15 +1033,6 @@ info: { container: 'dt-info' }, - layout: { - row: 'dt-layout-row', - cell: 'dt-layout-cell', - tableRow: 'dt-layout-table', - tableCell: '', - start: 'dt-layout-start', - end: 'dt-layout-end', - full: 'dt-layout-full' - }, length: { container: 'dt-length', select: 'dt-input' @@ -1084,8 +1081,7 @@ active: 'current', button: 'dt-paging-button', container: 'dt-paging', - disabled: 'disabled', - nav: '' + disabled: 'disabled' } } ); @@ -1148,7 +1144,7 @@ }; // Convert from a formatted number with characters other than `.` as the - // decimal place, to a JavaScript number + // decimal place, to a Javascript number var _numToDecimal = function ( num, decimalPoint ) { // Cache created regular expressions for speed as this function is called often if ( ! _re_dic[ decimalPoint ] ) { @@ -1160,7 +1156,7 @@ }; - var _isNumber = function ( d, decimalPoint, formatted, allowEmpty ) { + var _isNumber = function ( d, decimalPoint, formatted ) { var type = typeof d; var strType = type === 'string'; @@ -1171,7 +1167,7 @@ // If empty return immediately so there must be a number if it is a // formatted string (this stops the string "k", or "kr", etc being detected // as a formatted number for currency - if ( allowEmpty && _empty( d ) ) { + if ( _empty( d ) ) { return true; } @@ -1193,8 +1189,8 @@ }; // Is a string a number surrounded by HTML? - var _htmlNumeric = function ( d, decimalPoint, formatted, allowEmpty ) { - if ( allowEmpty && _empty( d ) ) { + var _htmlNumeric = function ( d, decimalPoint, formatted ) { + if ( _empty( d ) ) { return true; } @@ -1206,7 +1202,7 @@ var html = _isHtml( d ); return ! html ? null : - _isNumber( _stripHtml( d ), decimalPoint, formatted, allowEmpty ) ? + _isNumber( _stripHtml( d ), decimalPoint, formatted ) ? true : null; }; @@ -1214,19 +1210,19 @@ var _pluck = function ( a, prop, prop2 ) { var out = []; - var i=0, iLen=a.length; + var i=0, ien=a.length; // Could have the test in the loop for slightly smaller code, but speed // is essential here if ( prop2 !== undefined ) { - for ( ; i _max_str_len) { throw new Error('Exceeded max str len'); @@ -1313,7 +1305,7 @@ var previous; - input = input.replace(_re_html, replacement || ''); // Complete tags + input = input.replace(_re_html, ''); // Complete tags // Safety for incomplete script tag - use do / while to ensure that // we get all instances @@ -1348,11 +1340,8 @@ } // It is faster to just run `normalize` than it is to check if - // we need to with a regex! (Check as it isn't available in old - // Safari) - var res = str.normalize - ? str.normalize("NFD") - : str; + // we need to with a regex! + var res = str.normalize("NFD"); // Equally, here we check if a regex is needed or not return res.length !== str.length @@ -1377,7 +1366,7 @@ var sorted = src.slice().sort(); var last = sorted[0]; - for ( var i=1, iLen=sorted.length ; i') .html( columns[i][titleProp] || '' ) .appendTo( row ); @@ -3300,13 +3212,14 @@ if (side === 'header') { settings.aoHeader = detected; - $('tr', target).addClass(classes.thead.row); } else { settings.aoFooter = detected; - $('tr', target).addClass(classes.tfoot.row); } + // ARIA role for the rows + $(target).children('tr').attr('role', 'row'); + // Every cell needs to be passed through the renderer $(target).children('tr').children('th, td') .each( function () { @@ -3322,7 +3235,7 @@ * @param {*} settings DataTables settings * @param {*} source Source layout array * @param {*} incColumns What columns should be included - * @returns Layout array in column index order + * @returns Layout array */ function _fnHeaderLayout( settings, source, incColumns ) { @@ -3388,7 +3301,7 @@ colspan++; } - var titleSpan = $('.dt-column-title', cell); + var titleSpan = $('span.dt-column-title', cell); structure[row][column] = { cell: cell, @@ -3474,13 +3387,7 @@ oSettings.bDrawing = true; /* Server-side processing draw intercept */ - if ( oSettings.deferLoading ) - { - oSettings.deferLoading = false; - oSettings.iDraw++; - _fnProcessingDisplay( oSettings, false ); - } - else if ( !bServerSide ) + if ( !bServerSide ) { oSettings.iDraw++; } @@ -3504,14 +3411,6 @@ { var iDataIndex = aiDisplay[j]; var aoData = oSettings.aoData[ iDataIndex ]; - - // Row has been deleted - can't be displayed - if (aoData === null) - { - continue; - } - - // Row node hasn't been created yet if ( aoData.nTr === null ) { _fnCreateTr( oSettings, iDataIndex ); @@ -3525,6 +3424,7 @@ var td = aoData.anCells[i]; _addClass(td, _ext.type.className[col.sType]); // auto class + _addClass(td, col.sClass); // column class _addClass(td, oSettings.oClasses.tbody.cell); // all cells } @@ -3588,9 +3488,6 @@ filter = features.bFilter; if (recompute === undefined || recompute === true) { - // Resolve any column types that are unknown due to addition or invalidation - _fnColumnTypes( settings ); - if ( sort ) { _fnSort( settings ); } @@ -3614,9 +3511,7 @@ _fnDraw( settings ); - settings.api.one('draw', function () { - settings._drawHold = false; - }); + settings._drawHold = false; } @@ -3628,9 +3523,10 @@ var zero = oLang.sZeroRecords; var dataSrc = _fnDataSource( 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) { + if ( + (settings.iDraw < 1 && dataSrc === 'ssp') || + (settings.iDraw <= 1 && dataSrc === 'ajax') + ) { zero = oLang.sLoadingRecords; } else if ( oLang.sEmptyTable && settings.fnRecordsTotal() === 0 ) @@ -3640,97 +3536,12 @@ return $( '' ) .append( $('', { - 'colSpan': _fnVisibleColumns( settings ), + 'colSpan': _fnVisbleColumns( settings ), 'class': settings.oClasses.empty.row } ).html( zero ) )[0]; } - /** - * 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') .addClass('dt-column-title') .append(cell.childNodes) .appendTo(cell); } - if ( - settings.orderIndicators && - isHeader && - jqCell.filter(':not([data-dt-order=disable])').length !== 0 && - jqCell.parent(':not([data-dt-order=disable])').length !== 0 && - $('.dt-column-order', cell).length === 0 - ) { - $(document.createElement(settings.columnTitleTag)) + if ( isHeader && $('span.dt-column-order', cell).length === 0) { + $('') .addClass('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 ( $('div.dt-column-' + headerFooter, cell).length === 0) { - $('
') - .addClass('dt-column-' + headerFooter) - .append(cell.childNodes) - .appendTo(cell); - } } // If there is col / rowspan, copy the information into the layout grid @@ -4190,49 +4015,44 @@ * DataTables - may be augmented by developer callbacks * @param {function} fn Callback function to run when data is obtained */ - function _fnBuildAjax(oSettings, data, fn) { + function _fnBuildAjax( oSettings, data, fn ) + { var ajaxData; var ajax = oSettings.ajax; var instance = oSettings.oInstance; - var callback = function (json) { - var status = oSettings.jqXHR ? oSettings.jqXHR.status : null; + var callback = function ( json ) { + var status = oSettings.jqXHR + ? oSettings.jqXHR.status + : null; - if (json === null || (typeof status === 'number' && status == 204)) { + if ( json === null || (typeof status === 'number' && status == 204 ) ) { json = {}; - _fnAjaxDataSrc(oSettings, json, []); + _fnAjaxDataSrc( oSettings, json, [] ); } var error = json.error || json.sError; - if (error) { - _fnLog(oSettings, 0, error); - } - - // Microsoft often wrap JSON as a string in another JSON object - // Let's handle that automatically - if (json.d && typeof json.d === 'string') { - try { - json = JSON.parse(json.d); - } catch (e) { - // noop - } + if ( error ) { + _fnLog( oSettings, 0, error ); } oSettings.json = json; - _fnCallbackFire(oSettings, null, 'xhr', [oSettings, json, oSettings.jqXHR], true); - fn(json); + _fnCallbackFire( oSettings, null, 'xhr', [oSettings, json, oSettings.jqXHR], true ); + fn( json ); }; - if ($.isPlainObject(ajax) && ajax.data) { + if ( $.isPlainObject( ajax ) && ajax.data ) + { ajaxData = ajax.data; - var newData = - typeof ajaxData === 'function' - ? ajaxData(data, oSettings) // fn can manipulate data or return - : ajaxData; // an object or array to merge + var newData = typeof ajaxData === 'function' ? + ajaxData( data, oSettings ) : // fn can manipulate data or return + ajaxData; // an object object or array to merge // If the function returned something, use that alone - data = typeof ajaxData === 'function' && newData ? newData : $.extend(true, data, newData); + data = typeof ajaxData === 'function' && newData ? + newData : + $.extend( true, 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) @@ -4240,92 +4060,87 @@ } var baseAjax = { - url: typeof ajax === 'string' ? ajax : '', - data: data, - success: callback, - dataType: 'json', - cache: false, - type: oSettings.sServerMethod, - error: function (xhr, error) { - var ret = _fnCallbackFire( - oSettings, - null, - 'xhr', - [oSettings, null, oSettings.jqXHR], - true - ); + "url": typeof ajax === 'string' ? + ajax : + '', + "data": data, + "success": callback, + "dataType": "json", + "cache": false, + "type": oSettings.sServerMethod, + "error": function (xhr, error) { + var ret = _fnCallbackFire( oSettings, null, 'xhr', [oSettings, null, oSettings.jqXHR], true ); - if (ret.indexOf(true) === -1) { - if (error == 'parsererror') { - _fnLog(oSettings, 0, 'Invalid JSON response', 1); + if ( ret.indexOf(true) === -1 ) { + if ( error == "parsererror" ) { + _fnLog( oSettings, 0, 'Invalid JSON response', 1 ); } - else if (xhr.readyState === 4) { - _fnLog(oSettings, 0, 'Ajax error', 7); + else if ( xhr.readyState === 4 ) { + _fnLog( oSettings, 0, 'Ajax error', 7 ); } } - _fnProcessingDisplay(oSettings, false); + _fnProcessingDisplay( oSettings, false ); } }; // If `ajax` option is an object, extend and override our default base - if ($.isPlainObject(ajax)) { - $.extend(baseAjax, ajax); + if ( $.isPlainObject( ajax ) ) { + $.extend( baseAjax, ajax ) } // Store the data submitted for the API oSettings.oAjaxData = data; // Allow plug-ins and external processes to modify the data - _fnCallbackFire(oSettings, null, 'preXhr', [oSettings, data, baseAjax], true); + _fnCallbackFire( oSettings, null, 'preXhr', [oSettings, data, baseAjax], true ); - // Custom Ajax option to submit the parameters as a JSON string - if (baseAjax.submitAs === 'json' && typeof data === 'object') { - baseAjax.data = JSON.stringify(data); - - if (!baseAjax.contentType) { - baseAjax.contentType = 'application/json; charset=utf-8'; - } - } - - if (typeof ajax === 'function') { + if ( typeof ajax === 'function' ) + { // Is a function - let the caller define what needs to be done - oSettings.jqXHR = ajax.call(instance, data, callback, oSettings); + oSettings.jqXHR = ajax.call( instance, data, callback, oSettings ); } else if (ajax.url === '') { // No url, so don't load any data. Just apply an empty data array // to the object for the callback. var empty = {}; - _fnAjaxDataSrc(oSettings, empty, []); + DataTable.util.set(ajax.dataSrc)(empty, []); callback(empty); } else { // Object to extend the base settings - oSettings.jqXHR = $.ajax(baseAjax); - } + oSettings.jqXHR = $.ajax( baseAjax ); - // Restore for next time around - if (ajaxData) { - ajax.data = ajaxData; + // Restore for next time around + if ( ajaxData ) { + ajax.data = ajaxData; + } } } + /** * Update the table using an Ajax call * @param {object} settings dataTables settings object * @returns {boolean} Block the table drawing or not * @memberof DataTable#oApi */ - function _fnAjaxUpdate(settings) { + function _fnAjaxUpdate( settings ) + { settings.iDraw++; - _fnProcessingDisplay(settings, true); + _fnProcessingDisplay( settings, true ); - _fnBuildAjax(settings, _fnAjaxParameters(settings), function (json) { - _fnAjaxUpdateDraw(settings, json); - }); + _fnBuildAjax( + settings, + _fnAjaxParameters( settings ), + function(json) { + _fnAjaxUpdateDraw( settings, json ); + } + ); } + /** * Build up the parameters in an object needed for a server-side processing * request. @@ -4333,18 +4148,22 @@ * @returns {bool} block the table drawing or not * @memberof DataTable#oApi */ - function _fnAjaxParameters(settings) { - var columns = settings.aoColumns, + function _fnAjaxParameters( settings ) + { + var + columns = settings.aoColumns, features = settings.oFeatures, preSearch = settings.oPreviousSearch, preColSearch = settings.aoPreSearchCols, - colData = function (idx, prop) { - return typeof columns[idx][prop] === 'function' ? 'function' : columns[idx][prop]; + colData = function ( idx, prop ) { + return typeof columns[idx][prop] === 'function' ? + 'function' : + columns[idx][prop]; }; return { draw: settings.iDraw, - columns: columns.map(function (column, i) { + columns: columns.map( function ( column, i ) { return { data: colData(i, 'mData'), name: column.sName, @@ -4353,43 +4172,40 @@ search: { value: preColSearch[i].search, regex: preColSearch[i].regex, - fixed: Object.keys(column.searchFixed) - .map(function (name) { - return { - name: name, - term: typeof column.searchFixed[name] !== 'function' - ? column.searchFixed[name].toString() - : 'function' - }; - }) + fixed: Object.keys(column.searchFixed).map( function(name) { + return { + name: name, + term: column.searchFixed[name].toString() + } + }) } }; - }), - order: _fnSortFlatten(settings).map(function (val) { + } ), + order: _fnSortFlatten( settings ).map( function ( val ) { return { column: val.col, dir: val.dir, name: colData(val.col, 'sName') }; - }), + } ), start: settings._iDisplayStart, - length: features.bPaginate ? settings._iDisplayLength : -1, + length: features.bPaginate ? + settings._iDisplayLength : + -1, search: { value: preSearch.search, regex: preSearch.regex, - fixed: Object.keys(settings.searchFixed) - .map(function (name) { - return { - name: name, - term: typeof settings.searchFixed[name] !== 'function' - ? settings.searchFixed[name].toString() - : 'function' - }; - }) + fixed: Object.keys(settings.searchFixed).map( function(name) { + return { + name: name, + term: settings.searchFixed[name].toString() + } + }) } }; } + /** * Data the data from the server (nuking the old) and redraw the table * @param {object} oSettings dataTables settings object @@ -4401,40 +4217,41 @@ * @param {string} [json.sColumns] Column ordering (sName, comma separated) * @memberof DataTable#oApi */ - function _fnAjaxUpdateDraw(settings, json) { + function _fnAjaxUpdateDraw ( settings, json ) + { var data = _fnAjaxDataSrc(settings, json); var draw = _fnAjaxDataSrcParam(settings, 'draw', json); var recordsTotal = _fnAjaxDataSrcParam(settings, 'recordsTotal', json); var recordsFiltered = _fnAjaxDataSrcParam(settings, 'recordsFiltered', json); - if (draw !== undefined) { + if ( draw !== undefined ) { // Protect against out of sequence returns - if (draw * 1 < settings.iDraw) { + if ( draw*1 < settings.iDraw ) { return; } settings.iDraw = draw * 1; } // No data in returned object, so rather than an array, we show an empty table - if (!data) { + if ( ! data ) { data = []; } - _fnClearTable(settings); - settings._iRecordsTotal = parseInt(recordsTotal, 10); + _fnClearTable( settings ); + settings._iRecordsTotal = parseInt(recordsTotal, 10); settings._iRecordsDisplay = parseInt(recordsFiltered, 10); - for (var i = 0, iLen = data.length; i < iLen; i++) { - _fnAddData(settings, data[i]); + for ( var i=0, ien=data.length ; i col is set to and correct if needed + for (var i=0 ; i col is set to and correct if needed - for (var i=0 ; i divBodyEl.clientHeight || divBody.css('overflow-y') == "scroll"; var paddingSide = 'padding' + (browser.bScrollbarLeft ? 'Left' : 'Right' ); @@ -5441,26 +5193,9 @@ visibleColumns = _fnGetColumns( settings, 'bVisible' ), tableWidthAttr = table.getAttribute('width'), // from DOM element tableContainer = table.parentNode, - i, j, column, columnIdx; - + i, column, columnIdx; + var styleWidth = table.style.width; - var containerWidth = _fnWrapperWidth(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; } @@ -5475,16 +5210,17 @@ 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 + // Construct a single row, worst case, table with the widest + // node in the data, 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 = $(table.cloneNode()) .css( 'visibility', 'hidden' ) - .css( 'margin', 0 ) .removeAttr( 'id' ); // Clean up the table body - tmpTable.append('') + tmpTable.append('') + var tr = $('').appendTo( tmpTable.find('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 @@ -5508,8 +5244,6 @@ // browser will collapse it. If this width is smaller than the // width the column requires, then it will have no effect if ( scrollX ) { - this.style.minWidth = width; - $( this ).append( $('
').css( { width: width, margin: 0, @@ -5524,37 +5258,23 @@ } } ); - // Get the widest strings for each of the visible columns and add them to - // our table to create a "worst case" - var longestData = []; - + // Find the widest piece of data for each column and put it into the table for ( i=0 ; i').appendTo( tmpTable.find('tbody') ); - - for ( j=0 ; j') - .addClass(autoClass) - .addClass(column.sClass) - .append(insert) - .appendTo(tr); - } - } + var longest = _fnGetMaxLenString(settings, columnIdx); + var autoClass = _ext.type.className[column.sType]; + var text = longest + column.sContentPadding; + var insert = longest.indexOf('<') === -1 + ? document.createTextNode(text) + : text + + $('') + .addClass(autoClass) + .addClass(column.sClass) + .append(insert) + .appendTo(tr); } // Tidy the temporary table - remove name attributes so there aren't @@ -5592,15 +5312,15 @@ // If there is no width attribute or style, then allow the table to // collapse - if ( tmpTable.outerWidth() < tableContainer.clientWidth && tableWidthAttr ) { - tmpTable.outerWidth( tableContainer.clientWidth ); + if ( tmpTable.width() < tableContainer.clientWidth && tableWidthAttr ) { + tmpTable.width( tableContainer.clientWidth ); } } else if ( scrollY ) { - tmpTable.outerWidth( tableContainer.clientWidth ); + tmpTable.width( tableContainer.clientWidth ); } else if ( tableWidthAttr ) { - tmpTable.outerWidth( tableWidthAttr ); + tmpTable.width( tableWidthAttr ); } // Get the width of each column in the constructed table @@ -5633,92 +5353,36 @@ } if ( (tableWidthAttr || scrollX) && ! settings._reszEvt ) { - var resize = DataTable.util.throttle( function () { - var newWidth = _fnWrapperWidth(settings); - - // Don't do it if destroying or the container width is 0 - if (! settings.bDestroying && newWidth !== 0) { - _fnAdjustColumnSizing( 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 = $(settings.nTableWrapper).is(':visible'); - - // Use an empty div to attach the observer so it isn't impacted by height changes - var resizer = $('
') - .css({ - width: '100%', - height: 0 - }) - .addClass('dt-autosize') - .appendTo(settings.nTableWrapper); - - settings.resizeObserver = new ResizeObserver(function (e) { - if (first) { - first = false; + var bindResize = function () { + $(window).on('resize.DT-'+settings.sInstance, DataTable.util.throttle( function () { + if (! settings.bDestroying) { + _fnAdjustColumnSizing( settings ); } - else { - resize(); - } - }); + } ) ); + }; - settings.resizeObserver.observe(resizer[0]); - } - else { - // For old browsers, the best we can do is listen for a window resize - $(window).on('resize.DT-'+settings.sInstance, resize); - } + bindResize(); settings._reszEvt = true; } } - /** - * Get the width of the DataTables wrapper element - * - * @param {*} settings DataTables settings object - * @returns Width - */ - function _fnWrapperWidth(settings) { - return $(settings.nTableWrapper).is(':visible') - ? $(settings.nTableWrapper).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) - * + * Get the maximum strlen for each data column * @param {object} settings dataTables settings object * @param {int} colIdx column of interest - * @returns {string[]} Array of the longest strings + * @returns {string} string of the max length * @memberof DataTable#oApi */ - function _fnGetWideStrings( settings, colIdx ) + function _fnGetMaxLenString( settings, colIdx ) { var column = settings.aoColumns[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.aiDisplayMaster.length ; i/gi, ' '); - - var noHtml = _stripHtml(cellString, ' ') + s = _stripHtml(cellString) .replace( / /g, ' ' ); - // The length is calculated on the text only, but we keep the HTML - // in the string so it can be used in the calculation table - collection.push({ - str: cellString, - len: noHtml.length - }); - - allStrings.push(noHtml); + if ( s.length > maxLen ) { + // We want the HTML in the string, but the length that + // is important is the stripped string + max = cellString; + maxLen = s.length; + } } - // 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 - let 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]); - } + column.maxLenString = max; } - return column.wideStrings; + return column.maxLenString; } @@ -5828,30 +5459,24 @@ function _fnSortInit( settings ) { var target = settings.nTHead; var headerRows = target.querySelectorAll('tr'); - var titleRow = settings.titleRow; + var legacyTop = settings.bSortCellsTop; var notSelector = ':not([data-dt-order="disable"]):not([data-dt-order="icon-only"])'; // Legacy support for `orderCellsTop` - if (titleRow === true) { + if (legacyTop === true) { target = headerRows[0]; } - else if (titleRow === false) { + else if (legacyTop === false) { target = headerRows[ headerRows.length - 1 ]; } - else if (titleRow !== null) { - target = headerRows[titleRow]; - } - // else - all rows - if (settings.orderHandler) { - _fnSortAttachListener( - settings, - target, - target === settings.nTHead - ? 'tr'+notSelector+' th'+notSelector+', tr'+notSelector+' td'+notSelector - : 'th'+notSelector+', td'+notSelector - ); - } + _fnSortAttachListener( + settings, + target, + target === settings.nTHead + ? '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 = []; @@ -5866,14 +5491,10 @@ var run = false; var columns = column === undefined ? _fnColumnsFromHeader( e.target ) - : typeof column === 'function' - ? column() - : Array.isArray(column) - ? column - : [column]; + : [column]; if ( columns.length ) { - for ( var i=0, iLen=columns.length ; i= columns.length) { - // If the column index is out of bounds ignore it and continue - return; - } - - settings.aaSorting.push(set); + settings.aaSorting.push( col[0] >= columns.length ? + [ 0, col[1] ] : + col + ); } ); } @@ -6519,66 +6125,30 @@ // Columns if ( s.columns ) { - var set = s.columns; - var incoming = _pluck(s.columns, 'name'); + for ( i=0, ien=s.columns.length ; i= 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({}); - } + // 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 { - // 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({}); + columns[i].bVisible = col.visible; } } + + // Search + if ( col.search !== undefined ) { + $.extend( settings.aoPreSearchCols[i], col.search ); + } } - - // 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 0 ? idx : null; - } - - // `:visible` on its own - return idx; + // `:visible` on its own + return columns.map( function (col, i) { + return col.bVisible ? i : null; } ); case 'name': - // Don't get names, unless needed, and only get once if it is - if (!names) { - names = _pluck( columns, 'sName' ); - } - // match by name. `names` is column index complete and in order return names.map( function (name, i) { return name === match[1] ? i : null; } ); case 'title': - if (!titles) { - titles = _pluck( columns, 'sTitle' ); - } - // match by column title return titles.map( function (title, i) { return title === match[1] ? i : null; @@ -8946,10 +8395,7 @@ .map( function () { return _fnColumnsFromHeader( this ); // `nodes` is column index complete and in order } ) - .toArray() - .sort(function (a, b) { - return a - b; - }); + .toArray(); if ( jqResult.length || ! s.nodeName ) { return jqResult; @@ -8963,11 +8409,7 @@ []; }; - var selected = _selector_run( 'column', selector, run, settings, opts ); - - return opts.columnOrder && opts.columnOrder === 'index' - ? selected.sort(function (a, b) { return a - b; }) - : selected; // implied + return _selector_run( 'column', selector, run, settings, opts ); }; @@ -8976,7 +8418,7 @@ cols = settings.aoColumns, col = cols[ column ], data = settings.aoData, - cells, i, iLen, tr; + cells, i, ien, tr; // Get if ( vis === undefined ) { @@ -8994,7 +8436,7 @@ // Need to decide if we should use appendChild or insertBefore var insertBefore = _pluck(cols, 'bVisible').indexOf(true, column+1); - for ( i=0, iLen=data.length ; i') - .attr('id', items.id || null) - .addClass(items.className || classes.row) + .addClass('dt-layout-row') .appendTo( container ); - DataTable.ext.renderer.layout._forLayoutRow(items, function (key, val) { - if (key === 'id' || key === 'className') { - return; - } - - var klass = ''; + $.each( items, function (key, val) { + var klass = ! val.table ? + 'dt-'+key+' ' : + ''; if (val.table) { - row.addClass(classes.tableRow); - klass += classes.tableCell + ' '; - } - - if (key === 'start') { - klass += classes.start; - } - else if (key === 'end') { - klass += classes.end; - } - else { - klass += classes.full; + row.addClass('dt-layout-table'); } $('
') .attr({ id: val.id || null, - "class": val.className - ? val.className - : classes.cell + ' ' + klass + "class": 'dt-layout-cell '+klass+(val.className || '') }) .append( val.contents ) .appendTo( row ); - }); - }, - - // Shared for use by the styling frameworks - _forLayoutRow: function (items, fn) { - // As we are inserting dom elements, we need start / end in a - // specific order, this function is used for sorting the layout - // keys. - var layoutEnum = function (x) { - switch (x) { - case '': return 0; - case 'start': return 1; - case 'end': return 2; - default: return 3; - } - }; - - Object - .keys(items) - .sort(function (a, b) { - return layoutEnum(a) - layoutEnum(b); - }) - .forEach(function (key) { - fn(key, items[key]); - }); + } ); } } } ); @@ -13488,25 +12576,6 @@ } }; - function _divProp(el, prop, val) { - if (val) { - el[prop] = val; - } - } - - DataTable.feature.register( 'div', function ( settings, opts ) { - var n = $('
')[0]; - - if (opts) { - _divProp(n, 'className', opts.className); - _divProp(n, 'id', opts.id); - _divProp(n, 'innerHTML', opts.html); - _divProp(n, 'textContent', opts.text); - } - - return n; - } ); - DataTable.feature.register( 'info', function ( settings, opts ) { // For compatibility with the legacy `info` top level option if (! settings.oFeatures.bInfo) { @@ -13606,7 +12675,6 @@ opts = $.extend({ placeholder: language.sSearchPlaceholder, - processing: false, text: language.sSearch }, opts); @@ -13650,15 +12718,13 @@ /* Now do the filter */ if ( val != previousSearch.search ) { - _fnProcessingRun(settings, opts.processing, function () { - previousSearch.search = val; - - _fnFilterComplete( settings, previousSearch ); - - // Need to redraw, without resorting - settings._iDisplayStart = 0; - _fnDraw( settings ); - }); + previousSearch.search = val; + + _fnFilterComplete( settings, previousSearch ); + + // Need to redraw, without resorting + settings._iDisplayStart = 0; + _fnDraw( settings ); } }; @@ -13716,21 +12782,17 @@ opts = $.extend({ buttons: DataTable.ext.pager.numbers_length, type: settings.sPaginationType, - boundaryNumbers: true, - firstLast: true, - previousNext: true, - numbers: true + boundaryNumbers: true }, opts); - var host = $('
') - .addClass(settings.oClasses.paging.container + (opts.type ? ' paging_' + opts.type : '')) - .append( - $('