Compare commits

..
34 Commits
Author SHA1 Message Date
Stefan MelmukandGitHub a0198d8d7c fix account key rotation (#6105) 2025-07-27 12:18:54 +02:00
Mathijs van VeluwandGitHub dfad931dca Update crates (#6100)
Updated crates and made adjustments where needed.
Also removed a struct which wasn't used and the nightly compiler complained about it.

Used pinact to update GitHub Actions.
Validated GitHub Actions with zizmor.

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-07-26 14:58:39 +02:00
25865efd79 fix: resolve group permission conflicts with multiple groups (#6017)
* fix: resolve group permission conflicts with multiple groups

When a user belonged to multiple groups with different permissions for the
same collection, only the permissions from one group were applied instead
of combining them properly. This caused users to see incorrect access levels
when initially viewing collection items.

The fix combines permissions from all user groups by taking the most
permissive settings:
- read_only: false if ANY group allows write access
- hide_passwords: false if ANY group allows password viewing
- manage: true if ANY group allows management

This ensures users immediately see the correct permissions when opening
collection entries, matching the behavior after editing and saving.

* Update src/api/core/ciphers.rs

Co-authored-by: Mathijs van Veluw <black.dex@gmail.com>

* fix: format

* fix: restrict collection manage permissions to managers only

Prevent users from getting logged out when they have manage permissions by only allowing manage permissions for MembershipType::Manager and higher roles.

* refactor: cipher permission logic to prioritize user access

Updated permission checks to return user collection permissions if available, otherwise fallback to group permissions. Clarified comments to indicate user permissions overrule group permissions and corrected the logic for the 'manage' flag to use boolean OR instead of AND.

---------

Co-authored-by: Mathijs van Veluw <black.dex@gmail.com>
2025-07-25 20:58:41 +02:00
Mathijs van VeluwandGitHub bcf627930e Adjust issue template to hopefully show better to search for closed and open issues (#6096) 2025-07-25 18:17:55 +02:00
ce70cd2cf4 Hide login form custom fields (#6054)
Co-authored-by: Timshel <timshel@480s>
2025-07-14 22:01:20 +02:00
DanielandGitHub 2ac589d4b4 Fix digest SHA extraction step (#6059) 2025-07-13 12:20:16 +02:00
Stefan MelmukandGitHub b2e2aef7de fix hash reference in release.yml (#6058) 2025-07-13 10:22:33 +02:00
Daniel GarcíaandGitHub 0755bb19c0 Update release.yml (#6057)
Seems like docker can't use the hash references: https://github.com/dani-garcia/vaultwarden/actions/runs/16242780267/job/45861396226
2025-07-13 01:01:08 +02:00
Mathijs van VeluwandGitHub fee0c1c711 Update crates, workflow and issue template (#6056)
- Updated all the crates, which probably fixes #5959
- Updated all the workflows and tested it with zizmor
  Also added zizmor as a workflow it self.
- Updated the issue template to better mention to search first.

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-07-13 00:48:56 +02:00
Stefan MelmukandGitHub f58539f0b4 close unmatched left parenthesis in the README (#6046) 2025-07-10 13:52:52 +02:00
Stefan MelmukandGitHub e718afb441 improve the usage section of the README (#6041) 2025-07-09 23:44:20 +02:00
Mathijs van VeluwandGitHub 55945ad793 Update web-vault and admin resources (#6044)
- Updated web-vault to v2025.7.0
- Updated admin JS and CSS files

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-07-09 23:26:12 +02:00
Stefan MelmukandGitHub 4fd22d8e3b fix hiding email as 2fa provider (#6026) 2025-07-09 23:25:11 +02:00
mountdiskandGitHub d6a8fb8e48 chore: fix some minor issues in the comments (#5998)
Signed-off-by: mountdisk <mountdisk@icloud.com>
2025-07-09 23:24:29 +02:00
Mathijs van VeluwandGitHub 3b48e6e903 Fix v2025.6.x clients and newer to delete items (#6004) 2025-07-01 10:33:22 +02:00
6b9333b33e Use existing reqwest client for AWS S3 requests (#5917)
This removes a lot of duplicate client dependency bloat for roughly
equivalent functionality.

Co-authored-by: Mathijs van Veluw <black.dex@gmail.com>
2025-06-30 22:57:00 +02:00
Daniel GarcíaandGitHub a545636ee5 Update flags version and enable manual error reporting (#5994) 2025-06-27 21:39:38 +02:00
Mathijs van VeluwandGitHub f125d5f1a1 Misc Updates and favicon fixes (#5993)
- Updated crates
- Switched to rustls instead of native-tls
  Some dependency were already using rustls by default or without option.
  By removing native-tls we also have just one way of working here.

Updated favicon fetching which now is able to fetch more icons.
- Use rustls instead of native-tls
  This seems to work better, probably because of tls sniffing
- Use different user-agent and added several other headers
- Added SVG support. SVG Images will be sanitized first before stored or presented.
  Also, a special CSP for images will be sent to prevent scripts etc.. from SVG images.

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-06-27 21:20:36 +02:00
Mathijs van VeluwandGitHub ad75ce281e Fix an issue with yubico keys not validating (#5991)
* Fix an issue with yubico keys not validating

When adding or updating yubico otp keys there were some issues with the validation.
Looks like the web-vault sends all keys, not only filled-in keys, which triggered a check on empty keys.
Also, we should only return filled-in keys, not the empty ones too.

Fixes #5986

Signed-off-by: BlackDex <black.dex@gmail.com>

* Use more idomatic code

Signed-off-by: BlackDex <black.dex@gmail.com>

* Use more idomatic code - take 2

Signed-off-by: BlackDex <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-06-26 21:46:56 +02:00
Stefan MelmukandGitHub 9059437c35 fix account recovery withdrawal (#5968)
since `web-v2025.4.0` the client sends `""` instead of `null`, so we
also have to check whether the `reset_password_key` is empty or not.
2025-06-17 18:55:11 +02:00
Stefan MelmukandGitHub c84db0daca allow signup for invited users (#5967)
invited users (e.g. via /admin panel or org invite) should be able to
register if email is disabled.
2025-06-17 11:15:36 +02:00
Mathijs van VeluwandGitHub 72adc239f5 Update crates and web-vault (#5955)
- Updated crates
- Updated web-vault to v2025.6.0

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-06-15 01:19:53 +02:00
Nick GrimshawandGitHub 34ebeeca76 Minor fixes to copy in .env.template (#5928) 2025-06-15 01:19:08 +02:00
Stefan MelmukandGitHub 0469d9ba4c make css for login-page position independent (#5906)
* make css for login-page position independent

starting with v2025.5.1 the login page will have custom classes so the
fields to be disabled can be targeted specifically without risking
side-effects

* hide buttons after cancelling login
2025-06-14 19:31:51 +02:00
DanielandGitHub eaa6ad06ed Update Alpine to version 3.22 (#5938) 2025-06-14 19:30:19 +02:00
TimshelandGitHub 0d3f283c37 Fix and improvements to policies (#5923) 2025-06-02 21:47:12 +02:00
Mathijs van VeluwandGitHub 51a1d641c5 Some small admin updates (#5909)
- Some tweaks on the diagnostics layout
- Always show the latest web-vault version also when running in a container
  Users can override the web-vault folder and forget
- Also updated to the latest crates.

Kinda fixes #5908

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-05-30 16:56:29 +02:00
Chase DouglasandGitHub 90f7e5ff80 Abstract persistent files through Apache OpenDAL (#5626)
* Abstract file access through Apache OpenDAL

* Add AWS S3 support via OpenDAL for data files

* PR improvements

* Additional PR improvements

* Config setting comments for local/remote data locations
2025-05-29 21:40:58 +02:00
Stefan MelmukandGitHub 200999c94e fix css for locked screen (#5905)
by making the selector more specific to the login page
the logout button on the locked screen should be visible again
2025-05-29 08:08:32 +02:00
Stefan MelmukandGitHub d363e647e9 fix css to hide login with passkey (#5890) 2025-05-27 06:31:48 +02:00
Mathijs van VeluwandGitHub 53f58b14d5 Fix admin diagnostics crash (#5886)
Better handle semver issues.
Fixes #5882
Fixes #5883
Fixes #5885

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-05-26 23:14:17 +02:00
Mathijs van VeluwandGitHub ef7835d1b0 Sync with Upstream (#5798)
* WIP Sync with Upstream

WIP on syncing API Responses with upstream.
This to prevent issues with new clients, and find possible current issues like members, collections, groups etc..

Signed-off-by: BlackDex <black.dex@gmail.com>

* More API Response fixes

- Some 2fa checks
- Some org checks
- Reconfigured the experimental flags and noted which are deprecated
  Also removed some hard-coded defaults.
- Updated crates

Signed-off-by: BlackDex <black.dex@gmail.com>

* Add avatar color to emergency access api

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix spelling and some crate updates

Signed-off-by: BlackDex <black.dex@gmail.com>

* Use PushId and always generate the PushId

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix clippy lints

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix several Push issues and API's

Signed-off-by: BlackDex <black.dex@gmail.com>

* Check if push_uuid is empty and generate when needed

Signed-off-by: BlackDex <black.dex@gmail.com>

* Updated some comments and removed old export format

Signed-off-by: BlackDex <black.dex@gmail.com>

* cargo update

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix bulk edit Fixes #5737

Signed-off-by: BlackDex <black.dex@gmail.com>

* Send an email when an account exists already

When you want to change your email address into an account which already exists, upstream sends an email to the existing account.
Lets do the same.

Kinda fixes #5630

Signed-off-by: BlackDex <black.dex@gmail.com>

* Update 2fa removal/revoke email

Signed-off-by: BlackDex <black.dex@gmail.com>

* Allow col managers to import

This commit adds functionality to allow users with manage access to a collection, or managers with all access to import into an organization.

Fixes #5592

Signed-off-by: BlackDex <black.dex@gmail.com>

* Filter deprected flags and only return active flags

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix grammer

Signed-off-by: BlackDex <black.dex@gmail.com>

* Rename Small to Compact

Signed-off-by: BlackDex <black.dex@gmail.com>

* Rebase with upstream and fix conflicts

Signed-off-by: BlackDex <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-05-26 21:00:59 +02:00
Mathijs van VeluwandGitHub 3a44dc963b Update admin interface (#5880)
- Updated Backend Admin dependencies
- Fixed NTP time by using CloudFlare trace - Fixes #5797
- Fixed web-vault version check = Fixes #5761
- Fixed an issue with the css not hiding the 'Create Account' link.
  There were no braces around the function call.
  Also added a hide for newer web-vault versions as it still causes confusion with the cached /api/config.

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-05-26 20:37:50 +02:00
TimshelandGitHub a039e227c7 web-client now request email 2fa (#5871) 2025-05-26 20:24:30 +02:00
72 changed files with 3460 additions and 1418 deletions
+25 -16
View File
@@ -15,6 +15,14 @@
#################### ####################
## Main data folder ## 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 # DATA_FOLDER=data
## Individual folders, these override %DATA_FOLDER% ## Individual folders, these override %DATA_FOLDER%
@@ -22,10 +30,13 @@
# ICON_CACHE_FOLDER=data/icon_cache # ICON_CACHE_FOLDER=data/icon_cache
# ATTACHMENTS_FOLDER=data/attachments # ATTACHMENTS_FOLDER=data/attachments
# SENDS_FOLDER=data/sends # SENDS_FOLDER=data/sends
## Temporary folder used for storing temporary file uploads
## Must be a local path.
# TMP_FOLDER=data/tmp # TMP_FOLDER=data/tmp
## Templates data folder, by default uses embedded templates ## HTML template overrides data folder
## Check source code to see the format ## Must be a local path.
# TEMPLATES_FOLDER=data/templates # TEMPLATES_FOLDER=data/templates
## Automatically reload the templates for every request, slow, use only for development ## Automatically reload the templates for every request, slow, use only for development
# RELOAD_TEMPLATES=false # RELOAD_TEMPLATES=false
@@ -39,7 +50,9 @@
######################### #########################
## Database URL ## Database URL
## When using SQLite, this is the path to the DB file, default to %DATA_FOLDER%/db.sqlite3 ## 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.
# DATABASE_URL=data/db.sqlite3 # DATABASE_URL=data/db.sqlite3
## When using MySQL, specify an appropriate connection URI. ## When using MySQL, specify an appropriate connection URI.
## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html ## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html
@@ -117,7 +130,7 @@
## and are always in terms of UTC time (regardless of your local time zone settings). ## 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. ## The schedule format is a bit different from crontab as crontab does not contains seconds.
## You can test the the format here: https://crontab.guru, but remove the first digit! ## You can test the format here: https://crontab.guru, but remove the first digit!
## SEC MIN HOUR DAY OF MONTH MONTH DAY OF WEEK ## SEC MIN HOUR DAY OF MONTH MONTH DAY OF WEEK
## "0 30 9,12,15 1,15 May-Aug Mon,Wed,Fri" ## "0 30 9,12,15 1,15 May-Aug Mon,Wed,Fri"
## "0 30 * * * * " ## "0 30 * * * * "
@@ -260,7 +273,7 @@
## A comma-separated list means only those users can create orgs: ## A comma-separated list means only those users can create orgs:
# ORG_CREATION_USERS=admin1@example.com,admin2@example.com # ORG_CREATION_USERS=admin1@example.com,admin2@example.com
## Invitations org admins to invite users, even when signups are disabled ## Allows org admins to invite users, even when signups are disabled
# INVITATIONS_ALLOWED=true # INVITATIONS_ALLOWED=true
## Name shown in the invitation emails that don't come from a specific organization ## Name shown in the invitation emails that don't come from a specific organization
# INVITATION_ORG_NAME=Vaultwarden # INVITATION_ORG_NAME=Vaultwarden
@@ -328,37 +341,33 @@
## Icon download timeout ## Icon download timeout
## Configure the timeout value when downloading the favicons. ## Configure the timeout value when downloading the favicons.
## The default is 10 seconds, but this could be to low on slower network connections ## The default is 10 seconds, but this could be too low on slower network connections
# ICON_DOWNLOAD_TIMEOUT=10 # ICON_DOWNLOAD_TIMEOUT=10
## Block HTTP domains/IPs by Regex ## Block HTTP domains/IPs by Regex
## Any domains or IPs that match this regex won't be fetched by the internal HTTP client. ## 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 ## Useful to hide other servers in the local network. Check the WIKI for more details
## NOTE: Always enclose this regex withing single quotes! ## NOTE: Always enclose this regex within single quotes!
# HTTP_REQUEST_BLOCK_REGEX='^(192\.168\.0\.[0-9]+|192\.168\.1\.[0-9]+)$' # 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 ## 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 # HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true
## Client Settings ## Client Settings
## Enable experimental feature flags for clients. ## Enable experimental feature flags for clients.
## This is a comma-separated list of flags, e.g. "flag1,flag2,flag3". ## 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: ## The following flags are available:
## - "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.
## - "inline-menu-positioning-improvements": Enable the use of inline menu password generator and identity suggestions in the browser extension. ## - "inline-menu-positioning-improvements": Enable the use of inline menu password generator and identity suggestions in the browser extension.
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Needs clients >=2024.12.0) ## - "inline-menu-totp": Enable the use of inline menu TOTP codes in the browser extension.
## - "ssh-agent": Enable SSH agent support on Desktop. (Needs desktop >=2024.12.0) ## - "ssh-agent": Enable SSH agent support on Desktop. (Needs desktop >=2024.12.0)
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Needs clients >=2024.12.0)
## - "export-attachments": Enable support for exporting attachments (Clients >=2025.4.0)
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0) ## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0)
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0) ## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0)
## - "mutual-tls": Enable the use of mutual TLS on Android (Client >= 2025.2.0) ## - "mutual-tls": Enable the use of mutual TLS on Android (Client >= 2025.2.0)
## - "export-attachments": Enable support for exporting attachments (Clients >=2025.4.0)
## - "inline-menu-totp": Enable the use of inline menu TOTP codes in the browser extension.
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=fido2-vault-credentials # EXPERIMENTAL_CLIENT_FEATURE_FLAGS=fido2-vault-credentials
## Require new device emails. When a user logs in an email is required to be sent. ## Require new device emails. When a user logs in an email is required to be sent.
+1
View File
@@ -1,5 +1,6 @@
/.github @dani-garcia @BlackDex /.github @dani-garcia @BlackDex
/.github/** @dani-garcia @BlackDex /.github/** @dani-garcia @BlackDex
/.github/CODEOWNERS @dani-garcia @BlackDex /.github/CODEOWNERS @dani-garcia @BlackDex
/.github/ISSUE_TEMPLATE/** @dani-garcia @BlackDex
/.github/workflows/** @dani-garcia @BlackDex /.github/workflows/** @dani-garcia @BlackDex
/SECURITY.md @dani-garcia @BlackDex /SECURITY.md @dani-garcia @BlackDex
+20 -5
View File
@@ -8,15 +8,30 @@ body:
value: | value: |
Thanks for taking the time to fill out this bug report! 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. 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! 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! 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). 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 - id: support-string
type: textarea type: textarea
@@ -36,7 +51,7 @@ body:
attributes: attributes:
label: Vaultwarden Build Version label: Vaultwarden Build Version
description: What version of Vaultwarden are you running? description: What version of Vaultwarden are you running?
placeholder: ex. v1.31.0 or v1.32.0-3466a804 placeholder: ex. v1.34.0 or v1.34.1-53f58b14
validations: validations:
required: true required: true
# #
@@ -67,7 +82,7 @@ body:
attributes: attributes:
label: Reverse Proxy label: Reverse Proxy
description: Are you using a reverse proxy, if so which and what version? description: Are you using a reverse proxy, if so which and what version?
placeholder: ex. nginx 1.26.2, caddy 2.8.4, traefik 3.1.2, haproxy 3.0 placeholder: ex. nginx 1.29.0, caddy 2.10.0, traefik 3.4.4, haproxy 3.2
validations: validations:
required: true required: true
# #
@@ -115,7 +130,7 @@ body:
attributes: attributes:
label: Client Version label: Client Version
description: What version(s) of the client(s) are you seeing the problem on? description: What version(s) of the client(s) are you seeing the problem on?
placeholder: ex. CLI v2024.7.2, Firefox 130 - v2024.7.0 placeholder: ex. CLI v2025.7.0, Firefox 140 - v2025.6.1
# #
- id: reproduce - id: reproduce
type: textarea type: textarea
+8 -6
View File
@@ -66,13 +66,15 @@ jobs:
- name: Init Variables - name: Init Variables
id: toolchain id: toolchain
shell: bash shell: bash
env:
CHANNEL: ${{ matrix.channel }}
run: | run: |
if [[ "${{ matrix.channel }}" == 'rust-toolchain' ]]; then if [[ "${CHANNEL}" == 'rust-toolchain' ]]; then
RUST_TOOLCHAIN="$(grep -oP 'channel.*"(\K.*?)(?=")' rust-toolchain.toml)" RUST_TOOLCHAIN="$(grep -oP 'channel.*"(\K.*?)(?=")' rust-toolchain.toml)"
elif [[ "${{ matrix.channel }}" == 'msrv' ]]; then elif [[ "${CHANNEL}" == 'msrv' ]]; then
RUST_TOOLCHAIN="$(grep -oP 'rust-version.*"(\K.*?)(?=")' Cargo.toml)" RUST_TOOLCHAIN="$(grep -oP 'rust-version.*"(\K.*?)(?=")' Cargo.toml)"
else else
RUST_TOOLCHAIN="${{ matrix.channel }}" RUST_TOOLCHAIN="${CHANNEL}"
fi fi
echo "RUST_TOOLCHAIN=${RUST_TOOLCHAIN}" | tee -a "${GITHUB_OUTPUT}" echo "RUST_TOOLCHAIN=${RUST_TOOLCHAIN}" | tee -a "${GITHUB_OUTPUT}"
# End Determine rust-toolchain version # End Determine rust-toolchain version
@@ -80,7 +82,7 @@ jobs:
# Only install the clippy and rustfmt components on the default rust-toolchain # Only install the clippy and rustfmt components on the default rust-toolchain
- name: "Install rust-toolchain version" - name: "Install rust-toolchain version"
uses: dtolnay/rust-toolchain@56f84321dbccf38fb67ce29ab63e4754056677e0 # master @ Mar 18, 2025, 8:14 PM GMT+1 uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # master @ Apr 29, 2025, 9:22 PM GMT+2
if: ${{ matrix.channel == 'rust-toolchain' }} if: ${{ matrix.channel == 'rust-toolchain' }}
with: with:
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}" toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
@@ -90,7 +92,7 @@ jobs:
# Install the any other channel to be used for which we do not execute clippy and rustfmt # Install the any other channel to be used for which we do not execute clippy and rustfmt
- name: "Install MSRV version" - name: "Install MSRV version"
uses: dtolnay/rust-toolchain@56f84321dbccf38fb67ce29ab63e4754056677e0 # master @ Mar 18, 2025, 8:14 PM GMT+1 uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # master @ Apr 29, 2025, 9:22 PM GMT+2
if: ${{ matrix.channel != 'rust-toolchain' }} if: ${{ matrix.channel != 'rust-toolchain' }}
with: with:
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}" toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
@@ -115,7 +117,7 @@ jobs:
# Enable Rust Caching # Enable Rust Caching
- name: Rust Caching - name: Rust Caching
uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8 uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with: with:
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes. # 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. # Like changing the build host from Ubuntu 20.04 to 22.04 for example.
+3 -2
View File
@@ -4,7 +4,8 @@ permissions: {}
on: [ push, pull_request ] on: [ push, pull_request ]
jobs: jobs:
docker-templates: docker-templates:
name: Validate docker templates
permissions: permissions:
contents: read contents: read
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -20,7 +21,7 @@ jobs:
- name: Run make to rebuild templates - name: Run make to rebuild templates
working-directory: docker working-directory: docker
run: make run: make
- name: Check for unstaged changes - name: Check for unstaged changes
working-directory: docker working-directory: docker
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
steps: steps:
# Start Docker Buildx # Start Docker Buildx
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
# https://github.com/moby/buildkit/issues/3969 # https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with: with:
+15 -13
View File
@@ -47,7 +47,7 @@ jobs:
# Start a local docker registry to extract the compiled binaries to upload as artifacts and attest them # Start a local docker registry to extract the compiled binaries to upload as artifacts and attest them
services: services:
registry: registry:
image: registry:2 image: registry@sha256:1fc7de654f2ac1247f0b67e8a459e273b0993be7d2beda1f3f56fbf1001ed3e7 # v3.0.0
ports: ports:
- 5000:5000 - 5000:5000
env: env:
@@ -76,7 +76,7 @@ jobs:
# Start Docker Buildx # Start Docker Buildx
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
# https://github.com/moby/buildkit/issues/3969 # https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with: with:
@@ -192,7 +192,7 @@ jobs:
- name: Bake ${{ matrix.base_image }} containers - name: Bake ${{ matrix.base_image }} containers
id: bake_vw id: bake_vw
uses: docker/bake-action@4ba453fbc2db7735392b93edf935aaf9b1e8f747 # v6.5.0 uses: docker/bake-action@37816e747588cb137173af99ab33873600c46ea8 # v6.8.0
env: env:
BASE_TAGS: "${{ env.BASE_TAGS }}" BASE_TAGS: "${{ env.BASE_TAGS }}"
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}" SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
@@ -213,14 +213,15 @@ jobs:
shell: bash shell: bash
env: env:
BAKE_METADATA: ${{ steps.bake_vw.outputs.metadata }} BAKE_METADATA: ${{ steps.bake_vw.outputs.metadata }}
BASE_IMAGE: ${{ matrix.base_image }}
run: | run: |
GET_DIGEST_SHA="$(jq -r '.["${{ matrix.base_image }}-multi"]."containerimage.digest"' <<< "${BAKE_METADATA}")" 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}" echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}"
# Attest container images # Attest container images
- name: Attest - docker.io - ${{ matrix.base_image }} - name: Attest - docker.io - ${{ matrix.base_image }}
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}} if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3 uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
with: with:
subject-name: ${{ vars.DOCKERHUB_REPO }} subject-name: ${{ vars.DOCKERHUB_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
@@ -228,7 +229,7 @@ jobs:
- name: Attest - ghcr.io - ${{ matrix.base_image }} - name: Attest - ghcr.io - ${{ matrix.base_image }}
if: ${{ env.HAVE_GHCR_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}} if: ${{ env.HAVE_GHCR_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3 uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
with: with:
subject-name: ${{ vars.GHCR_REPO }} subject-name: ${{ vars.GHCR_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
@@ -236,7 +237,7 @@ jobs:
- name: Attest - quay.io - ${{ matrix.base_image }} - name: Attest - quay.io - ${{ matrix.base_image }}
if: ${{ env.HAVE_QUAY_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}} if: ${{ env.HAVE_QUAY_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3 uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
with: with:
subject-name: ${{ vars.QUAY_REPO }} subject-name: ${{ vars.QUAY_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
@@ -248,6 +249,7 @@ jobs:
shell: bash shell: bash
env: env:
REF_TYPE: ${{ github.ref_type }} REF_TYPE: ${{ github.ref_type }}
BASE_IMAGE: ${{ matrix.base_image }}
run: | run: |
# Check which main tag we are going to build determined by ref_type # Check which main tag we are going to build determined by ref_type
if [[ "${REF_TYPE}" == "tag" ]]; then if [[ "${REF_TYPE}" == "tag" ]]; then
@@ -257,7 +259,7 @@ jobs:
fi fi
# Check which base_image was used and append -alpine if needed # Check which base_image was used and append -alpine if needed
if [[ "${{ matrix.base_image }}" == "alpine" ]]; then if [[ "${BASE_IMAGE}" == "alpine" ]]; then
EXTRACT_TAG="${EXTRACT_TAG}-alpine" EXTRACT_TAG="${EXTRACT_TAG}-alpine"
fi fi
@@ -266,25 +268,25 @@ jobs:
# Extract amd64 binary # Extract amd64 binary
docker create --name amd64 --platform=linux/amd64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}" docker create --name amd64 --platform=linux/amd64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
docker cp amd64:/vaultwarden vaultwarden-amd64-${{ matrix.base_image }} docker cp amd64:/vaultwarden vaultwarden-amd64-${BASE_IMAGE}
docker rm --force amd64 docker rm --force amd64
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}" docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
# Extract arm64 binary # Extract arm64 binary
docker create --name arm64 --platform=linux/arm64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}" docker create --name arm64 --platform=linux/arm64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
docker cp arm64:/vaultwarden vaultwarden-arm64-${{ matrix.base_image }} docker cp arm64:/vaultwarden vaultwarden-arm64-${BASE_IMAGE}
docker rm --force arm64 docker rm --force arm64
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}" docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
# Extract armv7 binary # Extract armv7 binary
docker create --name armv7 --platform=linux/arm/v7 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}" docker create --name armv7 --platform=linux/arm/v7 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
docker cp armv7:/vaultwarden vaultwarden-armv7-${{ matrix.base_image }} docker cp armv7:/vaultwarden vaultwarden-armv7-${BASE_IMAGE}
docker rm --force armv7 docker rm --force armv7
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}" docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
# Extract armv6 binary # Extract armv6 binary
docker create --name armv6 --platform=linux/arm/v6 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}" docker create --name armv6 --platform=linux/arm/v6 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
docker cp armv6:/vaultwarden vaultwarden-armv6-${{ matrix.base_image }} docker cp armv6:/vaultwarden vaultwarden-armv6-${BASE_IMAGE}
docker rm --force armv6 docker rm --force armv6
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}" docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
@@ -314,7 +316,7 @@ jobs:
path: vaultwarden-armv6-${{ matrix.base_image }} path: vaultwarden-armv6-${{ matrix.base_image }}
- name: "Attest artifacts ${{ matrix.base_image }}" - name: "Attest artifacts ${{ matrix.base_image }}"
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3 uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
with: with:
subject-path: vaultwarden-* subject-path: vaultwarden-*
# End Upload artifacts to Github Actions # End Upload artifacts to Github Actions
+2 -2
View File
@@ -36,7 +36,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Run Trivy vulnerability scanner - name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0 uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # v0.32.0
env: env:
TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2 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 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
@@ -48,6 +48,6 @@ jobs:
severity: CRITICAL,HIGH severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab - name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@86b04fb0e47484f7282357688f21d5d0e32175fe # v3.27.5 uses: github/codeql-action/upload-sarif@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
with: with:
sarif_file: 'trivy-results.sarif' sarif_file: 'trivy-results.sarif'
+28
View File
@@ -0,0 +1,28 @@
name: Security Analysis with zizmor
on:
push:
branches: ["main"]
pull_request:
branches: ["**"]
permissions: {}
jobs:
zizmor:
name: Run zizmor
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@f52a838cfabf134edcbaa7c8b3677dde20045018 # v0.1.1
with:
# intentionally not scanning the entire repository,
# since it contains integration tests.
inputs: ./.github/
Generated
+1335 -403
View File
File diff suppressed because it is too large Load Diff
+28 -15
View File
@@ -6,7 +6,7 @@ name = "vaultwarden"
version = "1.0.0" version = "1.0.0"
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"] authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
edition = "2021" edition = "2021"
rust-version = "1.85.0" rust-version = "1.86.0"
resolver = "2" resolver = "2"
repository = "https://github.com/dani-garcia/vaultwarden" repository = "https://github.com/dani-garcia/vaultwarden"
@@ -32,6 +32,7 @@ enable_mimalloc = ["dep:mimalloc"]
# You also need to set an env variable `QUERY_LOGGER=1` to fully activate this so you do not have to re-compile # 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. # if you want to turn off the logging for a specific run.
query_logger = ["dep:diesel_logger"] query_logger = ["dep:diesel_logger"]
s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:anyhow", "dep:http", "dep:reqsign"]
# Enable unstable features, requires nightly # Enable unstable features, requires nightly
# Currently only used to enable rusts official ip support # Currently only used to enable rusts official ip support
@@ -72,14 +73,15 @@ dashmap = "6.1.0"
# Async futures # Async futures
futures = "0.3.31" futures = "0.3.31"
tokio = { version = "1.45.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] } tokio = { version = "1.46.1", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
tokio-util = { version = "0.7.15", features = ["compat"]}
# A generic serialization/deserialization framework # A generic serialization/deserialization framework
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140" serde_json = "1.0.141"
# A safe, extensible ORM and Query builder # A safe, extensible ORM and Query builder
diesel = { version = "2.2.10", features = ["chrono", "r2d2", "numeric"] } diesel = { version = "2.2.12", features = ["chrono", "r2d2", "numeric"] }
diesel_migrations = "2.2.0" diesel_migrations = "2.2.0"
diesel_logger = { version = "0.4.0", optional = true } diesel_logger = { version = "0.4.0", optional = true }
@@ -87,19 +89,19 @@ derive_more = { version = "2.0.1", features = ["from", "into", "as_ref", "deref"
diesel-derive-newtype = "2.1.2" diesel-derive-newtype = "2.1.2"
# Bundled/Static SQLite # Bundled/Static SQLite
libsqlite3-sys = { version = "0.33.0", features = ["bundled"], optional = true } libsqlite3-sys = { version = "0.35.0", features = ["bundled"], optional = true }
# Crypto-related libraries # Crypto-related libraries
rand = "0.9.1" rand = "0.9.2"
ring = "0.17.14" ring = "0.17.14"
subtle = "2.6.1" subtle = "2.6.1"
# UUID generation # UUID generation
uuid = { version = "1.16.0", features = ["v4"] } uuid = { version = "1.17.0", features = ["v4"] }
# Date and time libraries # Date and time libraries
chrono = { version = "0.4.41", features = ["clock", "serde"], default-features = false } chrono = { version = "0.4.41", features = ["clock", "serde"], default-features = false }
chrono-tz = "0.10.3" chrono-tz = "0.10.4"
time = "0.3.41" time = "0.3.41"
# Job scheduler # Job scheduler
@@ -124,7 +126,7 @@ webauthn-rs = "0.3.2"
url = "2.5.4" url = "2.5.4"
# Email libraries # Email libraries
lettre = { version = "0.11.16", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "tokio1-native-tls", "hostname", "tracing", "tokio1"], default-features = false } lettre = { version = "0.11.17", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
percent-encoding = "2.3.1" # URL encoding library used for URL's in the emails percent-encoding = "2.3.1" # URL encoding library used for URL's in the emails
email_address = "0.2.9" email_address = "0.2.9"
@@ -132,7 +134,7 @@ email_address = "0.2.9"
handlebars = { version = "6.3.2", features = ["dir_source"] } handlebars = { version = "6.3.2", features = ["dir_source"] }
# HTTP client (Used for favicons, version check, DUO and HIBP API) # HTTP client (Used for favicons, version check, DUO and HIBP API)
reqwest = { version = "0.12.15", features = ["native-tls-alpn", "stream", "json", "gzip", "brotli", "socks", "cookies"] } reqwest = { version = "0.12.22", 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.25.2" hickory-resolver = "0.25.2"
# Favicon extraction libraries # Favicon extraction libraries
@@ -140,16 +142,17 @@ html5gum = "0.7.0"
regex = { version = "1.11.1", features = ["std", "perf", "unicode-perl"], default-features = false } regex = { version = "1.11.1", features = ["std", "perf", "unicode-perl"], default-features = false }
data-url = "0.3.1" data-url = "0.3.1"
bytes = "1.10.1" bytes = "1.10.1"
svg-hush = "0.9.5"
# Cache function results (Used for version check and favicon fetching) # Cache function results (Used for version check and favicon fetching)
cached = { version = "0.55.1", features = ["async"] } cached = { version = "0.56.0", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction # Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.1" cookie = "0.18.1"
cookie_store = "0.21.1" cookie_store = "0.21.1"
# Used by U2F, JWT and PostgreSQL # Used by U2F, JWT and PostgreSQL
openssl = "0.10.72" openssl = "0.10.73"
# CLI argument parsing # CLI argument parsing
pico-args = "0.5.0" pico-args = "0.5.0"
@@ -163,9 +166,9 @@ semver = "1.0.26"
# Allow overriding the default memory allocator # Allow overriding the default memory allocator
# Mainly used for the musl builds, since the default musl malloc is very slow # Mainly used for the musl builds, since the default musl malloc is very slow
mimalloc = { version = "0.1.46", features = ["secure"], default-features = false, optional = true } mimalloc = { version = "0.1.47", features = ["secure"], default-features = false, optional = true }
which = "7.0.3" which = "8.0.0"
# Argon2 library with support for the PHC format # Argon2 library with support for the PHC format
argon2 = "0.5.3" argon2 = "0.5.3"
@@ -176,6 +179,17 @@ rpassword = "7.4.0"
# Loading a dynamic CSS Stylesheet # Loading a dynamic CSS Stylesheet
grass_compiler = { version = "0.13.4", default-features = false } grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL
opendal = { version = "0.54.0", features = ["services-fs"], default-features = false }
# For retrieving AWS credentials, including temporary SSO credentials
anyhow = { version = "1.0.98", optional = true }
aws-config = { version = "1.8.3", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
aws-credential-types = { version = "1.2.4", optional = true }
aws-smithy-runtime-api = { version = "1.8.5", optional = true }
http = { version = "1.3.1", optional = true }
reqsign = { version = "0.16.5", optional = true }
# Strip debuginfo from the release builds # Strip debuginfo from the release builds
# The debug symbols are to provide better panic traces # The debug symbols are to provide better panic traces
# Also enable fat LTO and use 1 codegen unit for optimizations # Also enable fat LTO and use 1 codegen unit for optimizations
@@ -265,7 +279,6 @@ macro_use_imports = "deny"
manual_assert = "deny" manual_assert = "deny"
manual_instant_elapsed = "deny" manual_instant_elapsed = "deny"
manual_string_new = "deny" manual_string_new = "deny"
match_on_vec_items = "deny"
match_wildcard_for_single_variants = "deny" match_wildcard_for_single_variants = "deny"
mem_forget = "deny" mem_forget = "deny"
needless_continue = "deny" needless_continue = "deny"
+13 -11
View File
@@ -59,19 +59,21 @@ A nearly complete implementation of the Bitwarden Client API is provided, includ
## Usage ## Usage
> [!IMPORTANT] > [!IMPORTANT]
> 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. > The web-vault requires the use 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 via `http://localhost:8000` (using the port from the example below) or if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS).
>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)).
> 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).
>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). 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)).
> [!TIP] > [!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).** >**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 ### Docker/Podman CLI
Pull the container image and mount a volume from the host for persistent storage.<br> Pull the container image and mount a volume from the host for persistent storage.<br>
@@ -83,7 +85,7 @@ docker run --detach --name vaultwarden \
--env DOMAIN="https://vw.domain.tld" \ --env DOMAIN="https://vw.domain.tld" \
--volume /vw-data/:/data/ \ --volume /vw-data/:/data/ \
--restart unless-stopped \ --restart unless-stopped \
--publish 80:80 \ --publish 127.0.0.1:8000:80 \
vaultwarden/server:latest vaultwarden/server:latest
``` ```
@@ -104,7 +106,7 @@ services:
volumes: volumes:
- ./vw-data/:/data/ - ./vw-data/:/data/
ports: ports:
- 80:80 - 127.0.0.1:8000:80
``` ```
<br> <br>
+3
View File
@@ -11,6 +11,8 @@ fn main() {
println!("cargo:rustc-cfg=postgresql"); println!("cargo:rustc-cfg=postgresql");
#[cfg(feature = "query_logger")] #[cfg(feature = "query_logger")]
println!("cargo:rustc-cfg=query_logger"); println!("cargo:rustc-cfg=query_logger");
#[cfg(feature = "s3")]
println!("cargo:rustc-cfg=s3");
#[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))] #[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))]
compile_error!( compile_error!(
@@ -23,6 +25,7 @@ fn main() {
println!("cargo::rustc-check-cfg=cfg(mysql)"); println!("cargo::rustc-check-cfg=cfg(mysql)");
println!("cargo::rustc-check-cfg=cfg(postgresql)"); println!("cargo::rustc-check-cfg=cfg(postgresql)");
println!("cargo::rustc-check-cfg=cfg(query_logger)"); println!("cargo::rustc-check-cfg=cfg(query_logger)");
println!("cargo::rustc-check-cfg=cfg(s3)");
// Rerun when these paths are changed. // Rerun when these paths are changed.
// Someone could have checked-out a tag or specific commit, but no other files changed. // Someone could have checked-out a tag or specific commit, but no other files changed.
+4 -4
View File
@@ -1,13 +1,13 @@
--- ---
vault_version: "v2025.5.0" vault_version: "v2025.7.0"
vault_image_digest: "sha256:a0a377b810e66a4ebf1416f732d2be06f3262bf5a5238695af88d3ec6871cc0e" vault_image_digest: "sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e"
# Cross Compile Docker Helper Scripts v1.6.1 # Cross Compile Docker Helper Scripts v1.6.1
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts # 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 # https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
xx_image_digest: "sha256:9c207bead753dda9430bdd15425c6518fc7a03d866103c516a2c6889188f5894" xx_image_digest: "sha256:9c207bead753dda9430bdd15425c6518fc7a03d866103c516a2c6889188f5894"
rust_version: 1.87.0 # Rust version to be used rust_version: 1.88.0 # Rust version to be used
debian_version: bookworm # Debian release name to be used debian_version: bookworm # Debian release name to be used
alpine_version: "3.21" # Alpine version to be used alpine_version: "3.22" # Alpine version to be used
# For which platforms/architectures will we try to build images # For which platforms/architectures will we try to build images
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"] platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
# Determine the build images per OS/Arch # Determine the build images per OS/Arch
+11 -11
View File
@@ -19,23 +19,23 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # - 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. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2025.5.0 # $ docker pull docker.io/vaultwarden/web-vault:v2025.7.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.5.0 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.7.0
# [docker.io/vaultwarden/web-vault@sha256:a0a377b810e66a4ebf1416f732d2be06f3262bf5a5238695af88d3ec6871cc0e] # [docker.io/vaultwarden/web-vault@sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:a0a377b810e66a4ebf1416f732d2be06f3262bf5a5238695af88d3ec6871cc0e # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e
# [docker.io/vaultwarden/web-vault:v2025.5.0] # [docker.io/vaultwarden/web-vault:v2025.7.0]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:a0a377b810e66a4ebf1416f732d2be06f3262bf5a5238695af88d3ec6871cc0e AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e AS vault
########################## ALPINE BUILD IMAGES ########################## ########################## ALPINE BUILD IMAGES ##########################
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 ## 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 ## And for Alpine we define all build images here, they will only be loaded when actually used
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.87.0 AS build_amd64 FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.88.0 AS build_amd64
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.87.0 AS build_arm64 FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.88.0 AS build_arm64
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.87.0 AS build_armv7 FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.88.0 AS build_armv7
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.87.0 AS build_armv6 FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.88.0 AS build_armv6
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
@@ -127,7 +127,7 @@ RUN source /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # 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 # 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.21 FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.22
ENV ROCKET_PROFILE="release" \ ENV ROCKET_PROFILE="release" \
ROCKET_ADDRESS=0.0.0.0 \ ROCKET_ADDRESS=0.0.0.0 \
+7 -7
View File
@@ -19,15 +19,15 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # - 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. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2025.5.0 # $ docker pull docker.io/vaultwarden/web-vault:v2025.7.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.5.0 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.7.0
# [docker.io/vaultwarden/web-vault@sha256:a0a377b810e66a4ebf1416f732d2be06f3262bf5a5238695af88d3ec6871cc0e] # [docker.io/vaultwarden/web-vault@sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:a0a377b810e66a4ebf1416f732d2be06f3262bf5a5238695af88d3ec6871cc0e # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e
# [docker.io/vaultwarden/web-vault:v2025.5.0] # [docker.io/vaultwarden/web-vault:v2025.7.0]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:a0a377b810e66a4ebf1416f732d2be06f3262bf5a5238695af88d3ec6871cc0e AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e AS vault
########################## Cross Compile Docker Helper Scripts ########################## ########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts ## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
@@ -36,7 +36,7 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:9c207bead753dda9430bd
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.87.0-slim-bookworm AS build FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.88.0-slim-bookworm AS build
COPY --from=xx / / COPY --from=xx / /
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT ARG TARGETVARIANT
+1 -1
View File
@@ -10,7 +10,7 @@ proc-macro = true
[dependencies] [dependencies]
quote = "1.0.40" quote = "1.0.40"
syn = "2.0.101" syn = "2.0.104"
[lints] [lints]
workspace = true workspace = true
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain] [toolchain]
channel = "1.87.0" channel = "1.88.0"
components = [ "rustfmt", "clippy" ] components = [ "rustfmt", "clippy" ]
profile = "minimal" profile = "minimal"
+47 -45
View File
@@ -421,11 +421,11 @@ async fn delete_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> Em
async fn deauth_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { async fn deauth_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &mut conn).await?; let mut user = get_user_or_404(&user_id, &mut conn).await?;
nt.send_logout(&user, None).await; nt.send_logout(&user, None, &mut conn).await;
if CONFIG.push_enabled() { if CONFIG.push_enabled() {
for device in Device::find_push_devices_by_user(&user.uuid, &mut conn).await { for device in Device::find_push_devices_by_user(&user.uuid, &mut conn).await {
match unregister_push_device(device.push_uuid).await { match unregister_push_device(&device.push_uuid).await {
Ok(r) => r, 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}"),
}; };
@@ -447,7 +447,7 @@ async fn disable_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt:
let save_result = user.save(&mut conn).await; let save_result = user.save(&mut conn).await;
nt.send_logout(&user, None).await; nt.send_logout(&user, None, &mut conn).await;
save_result save_result
} }
@@ -591,20 +591,14 @@ struct GitCommit {
sha: String, sha: String,
} }
#[derive(Deserialize)]
struct TimeApi {
year: u16,
month: u8,
day: u8,
hour: u8,
minute: u8,
seconds: u8,
}
async fn get_json_api<T: DeserializeOwned>(url: &str) -> Result<T, Error> { async fn get_json_api<T: DeserializeOwned>(url: &str) -> Result<T, Error> {
Ok(make_http_request(Method::GET, url)?.send().await?.error_for_status()?.json::<T>().await?) Ok(make_http_request(Method::GET, url)?.send().await?.error_for_status()?.json::<T>().await?)
} }
async fn get_text_api(url: &str) -> Result<String, Error> {
Ok(make_http_request(Method::GET, url)?.send().await?.error_for_status()?.text().await?)
}
async fn has_http_access() -> bool { async fn has_http_access() -> bool {
let Ok(req) = make_http_request(Method::HEAD, "https://github.com/dani-garcia/vaultwarden") else { let Ok(req) = make_http_request(Method::HEAD, "https://github.com/dani-garcia/vaultwarden") else {
return false; return false;
@@ -616,10 +610,12 @@ async fn has_http_access() -> bool {
} }
use cached::proc_macro::cached; 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. /// 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. /// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
#[cached(time = 300, sync_writes = "default")] /// Any cache will be lost if Vaultwarden is restarted
async fn get_release_info(has_http_access: bool, running_within_container: bool) -> (String, String, String) { use std::time::Duration; // Needed for cached
#[cached(time = 600, sync_writes = "default")]
async fn get_release_info(has_http_access: 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 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 { if has_http_access {
( (
@@ -636,19 +632,13 @@ async fn get_release_info(has_http_access: bool, running_within_container: bool)
} }
_ => "-".to_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 // The web-vault version is embedded within the container it self, and should not be updated manually
if running_within_container { match get_json_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest")
"-".to_string()
} else {
match get_json_api::<GitRelease>(
"https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest",
)
.await .await
{ {
Ok(r) => r.tag_name.trim_start_matches('v').to_string(), Ok(r) => r.tag_name.trim_start_matches('v').to_string(),
_ => "-".to_string(), _ => "-".to_string(),
}
}, },
) )
} else { } else {
@@ -658,17 +648,18 @@ async fn get_release_info(has_http_access: bool, running_within_container: bool)
async fn get_ntp_time(has_http_access: bool) -> String { async fn get_ntp_time(has_http_access: bool) -> String {
if has_http_access { if has_http_access {
if let Ok(ntp_time) = get_json_api::<TimeApi>("https://www.timeapi.io/api/Time/current/zone?timeZone=UTC").await if let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
{ for line in cf_trace.lines() {
return format!( if let Some((key, value)) = line.split_once('=') {
"{year}-{month:02}-{day:02} {hour:02}:{minute:02}:{seconds:02} UTC", if key == "ts" {
year = ntp_time.year, let ts = value.split_once('.').map_or(value, |(s, _)| s);
month = ntp_time.month, if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") {
day = ntp_time.day, return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string();
hour = ntp_time.hour, }
minute = ntp_time.minute, break;
seconds = ntp_time.seconds }
); }
}
} }
} }
String::from("Unable to fetch NTP time.") String::from("Unable to fetch NTP time.")
@@ -693,14 +684,23 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn)
_ => "Unable to resolve domain name.".to_string(), _ => "Unable to resolve domain name.".to_string(),
}; };
let (latest_release, latest_commit, latest_web_build) = let (latest_release, latest_commit, latest_web_build) = get_release_info(has_http_access).await;
get_release_info(has_http_access, running_within_container).await;
let ip_header_name = &ip_header.0.unwrap_or_default(); let ip_header_name = &ip_header.0.unwrap_or_default();
// Get current running versions // Get current running versions
let web_vault_version = get_web_vault_version(); let web_vault_version = get_web_vault_version();
// Check if the running version is newer than the latest stable released version
let web_vault_pre_release = if let Ok(web_ver_match) = semver::VersionReq::parse(&format!(">{latest_web_build}")) {
web_ver_match.matches(
&semver::Version::parse(&web_vault_version).unwrap_or_else(|_| semver::Version::parse("2025.1.1").unwrap()),
)
} else {
error!("Unable to parse latest_web_build: '{latest_web_build}'");
false
};
let diagnostics_json = json!({ let diagnostics_json = json!({
"dns_resolved": dns_resolved, "dns_resolved": dns_resolved,
"current_release": VERSION, "current_release": VERSION,
@@ -709,6 +709,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn)
"web_vault_enabled": &CONFIG.web_vault_enabled(), "web_vault_enabled": &CONFIG.web_vault_enabled(),
"web_vault_version": web_vault_version, "web_vault_version": web_vault_version,
"latest_web_build": latest_web_build, "latest_web_build": latest_web_build,
"web_vault_pre_release": web_vault_pre_release,
"running_within_container": running_within_container, "running_within_container": running_within_container,
"container_base_image": if running_within_container { container_base_image() } else { "Not applicable" }, "container_base_image": if running_within_container { container_base_image() } else { "Not applicable" },
"has_http_access": has_http_access, "has_http_access": has_http_access,
@@ -724,6 +725,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn)
"overrides": &CONFIG.get_overrides().join(", "), "overrides": &CONFIG.get_overrides().join(", "),
"host_arch": env::consts::ARCH, "host_arch": env::consts::ARCH,
"host_os": env::consts::OS, "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_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 "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 "ntp_time": get_ntp_time(has_http_access).await, // Run the ntp check as late as possible to minimize the time difference
@@ -745,17 +747,17 @@ fn get_diagnostics_http(code: u16, _token: AdminToken) -> EmptyResult {
} }
#[post("/config", format = "application/json", data = "<data>")] #[post("/config", format = "application/json", data = "<data>")]
fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult { async fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
let data: ConfigBuilder = data.into_inner(); let data: ConfigBuilder = data.into_inner();
if let Err(e) = CONFIG.update_config(data, true) { if let Err(e) = CONFIG.update_config(data, true).await {
err!(format!("Unable to save config: {e:?}")) err!(format!("Unable to save config: {e:?}"))
} }
Ok(()) Ok(())
} }
#[post("/config/delete", format = "application/json")] #[post("/config/delete", format = "application/json")]
fn delete_config(_token: AdminToken) -> EmptyResult { async fn delete_config(_token: AdminToken) -> EmptyResult {
if let Err(e) = CONFIG.delete_user_config() { if let Err(e) = CONFIG.delete_user_config().await {
err!(format!("Unable to delete config: {e:?}")) err!(format!("Unable to delete config: {e:?}"))
} }
Ok(()) Ok(())
+120 -56
View File
@@ -8,8 +8,8 @@ use serde_json::Value;
use crate::{ use crate::{
api::{ api::{
core::{log_user_event, two_factor::email}, core::{log_user_event, two_factor::email},
register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult, Notify, master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult,
PasswordOrOtpData, UpdateType, Notify, PasswordOrOtpData, UpdateType,
}, },
auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers}, auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers},
crypto, crypto,
@@ -128,9 +128,8 @@ async fn is_email_2fa_required(member_id: Option<MembershipId>, conn: &mut DbCon
if CONFIG.email_2fa_enforce_on_verified_invite() { if CONFIG.email_2fa_enforce_on_verified_invite() {
return true; return true;
} }
if member_id.is_some() { if let Some(member_id) = member_id {
return OrgPolicy::is_enabled_for_member(&member_id.unwrap(), OrgPolicyType::TwoFactorAuthentication, conn) return OrgPolicy::is_enabled_for_member(&member_id, OrgPolicyType::TwoFactorAuthentication, conn).await;
.await;
} }
false false
} }
@@ -337,7 +336,6 @@ async fn profile(headers: Headers, mut conn: DbConn) -> Json<Value> {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct ProfileData { struct ProfileData {
// culture: String, // Ignored, always use en-US // culture: String, // Ignored, always use en-US
// masterPasswordHint: Option<String>, // Ignored, has been moved to ChangePassData
name: String, name: String,
} }
@@ -463,7 +461,7 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, mut conn: D
// Prevent logging out the client where the user requested this endpoint from. // 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. // If you do logout the user it will causes issues at the client side.
// Adding the device uuid will prevent this. // Adding the device uuid will prevent this.
nt.send_logout(&user, Some(headers.device.uuid.clone())).await; nt.send_logout(&user, Some(headers.device.uuid.clone()), &mut conn).await;
save_result save_result
} }
@@ -523,7 +521,7 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, mut conn: DbConn,
user.set_password(&data.new_master_password_hash, Some(data.key), true, None); user.set_password(&data.new_master_password_hash, Some(data.key), true, None);
let save_result = user.save(&mut conn).await; let save_result = user.save(&mut conn).await;
nt.send_logout(&user, Some(headers.device.uuid.clone())).await; nt.send_logout(&user, Some(headers.device.uuid.clone()), &mut conn).await;
save_result save_result
} }
@@ -558,14 +556,45 @@ use super::sends::{update_send_from_data, SendData};
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct KeyData { 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<UpdateEmergencyAccessData>,
master_password_unlock_data: MasterPasswordUnlockData,
organization_account_recovery_unlock_data: Vec<UpdateResetPasswordData>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MasterPasswordUnlockData {
kdf_type: i32,
kdf_iterations: i32,
kdf_parallelism: Option<i32>,
kdf_memory: Option<i32>,
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<CipherData>, ciphers: Vec<CipherData>,
folders: Vec<UpdateFolderData>, folders: Vec<UpdateFolderData>,
sends: Vec<SendData>, sends: Vec<SendData>,
emergency_access_keys: Vec<UpdateEmergencyAccessData>,
reset_password_keys: Vec<UpdateResetPasswordData>,
key: String,
master_password_hash: String,
private_key: String,
} }
fn validate_keydata( fn validate_keydata(
@@ -575,10 +604,24 @@ fn validate_keydata(
existing_emergency_access: &[EmergencyAccess], existing_emergency_access: &[EmergencyAccess],
existing_memberships: &[Membership], existing_memberships: &[Membership],
existing_sends: &[Send], existing_sends: &[Send],
user: &User,
) -> EmptyResult { ) -> 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 // Check that we're correctly rotating all the user's ciphers
let existing_cipher_ids = existing_ciphers.iter().map(|c| &c.uuid).collect::<HashSet<&CipherId>>(); let existing_cipher_ids = existing_ciphers.iter().map(|c| &c.uuid).collect::<HashSet<&CipherId>>();
let provided_cipher_ids = data let provided_cipher_ids = data
.account_data
.ciphers .ciphers
.iter() .iter()
.filter(|c| c.organization_id.is_none()) .filter(|c| c.organization_id.is_none())
@@ -590,7 +633,8 @@ fn validate_keydata(
// Check that we're correctly rotating all the user's folders // Check that we're correctly rotating all the user's folders
let existing_folder_ids = existing_folders.iter().map(|f| &f.uuid).collect::<HashSet<&FolderId>>(); let existing_folder_ids = existing_folders.iter().map(|f| &f.uuid).collect::<HashSet<&FolderId>>();
let provided_folder_ids = data.folders.iter().filter_map(|f| f.id.as_ref()).collect::<HashSet<&FolderId>>(); let provided_folder_ids =
data.account_data.folders.iter().filter_map(|f| f.id.as_ref()).collect::<HashSet<&FolderId>>();
if !provided_folder_ids.is_superset(&existing_folder_ids) { if !provided_folder_ids.is_superset(&existing_folder_ids) {
err!("All existing folders must be included in the rotation") err!("All existing folders must be included in the rotation")
} }
@@ -598,8 +642,12 @@ fn validate_keydata(
// Check that we're correctly rotating all the user's emergency access keys // Check that we're correctly rotating all the user's emergency access keys
let existing_emergency_access_ids = let existing_emergency_access_ids =
existing_emergency_access.iter().map(|ea| &ea.uuid).collect::<HashSet<&EmergencyAccessId>>(); existing_emergency_access.iter().map(|ea| &ea.uuid).collect::<HashSet<&EmergencyAccessId>>();
let provided_emergency_access_ids = let provided_emergency_access_ids = data
data.emergency_access_keys.iter().map(|ea| &ea.id).collect::<HashSet<&EmergencyAccessId>>(); .account_unlock_data
.emergency_access_unlock_data
.iter()
.map(|ea| &ea.id)
.collect::<HashSet<&EmergencyAccessId>>();
if !provided_emergency_access_ids.is_superset(&existing_emergency_access_ids) { if !provided_emergency_access_ids.is_superset(&existing_emergency_access_ids) {
err!("All existing emergency access keys must be included in the rotation") err!("All existing emergency access keys must be included in the rotation")
} }
@@ -607,15 +655,19 @@ fn validate_keydata(
// Check that we're correctly rotating all the user's reset password keys // Check that we're correctly rotating all the user's reset password keys
let existing_reset_password_ids = let existing_reset_password_ids =
existing_memberships.iter().map(|m| &m.org_uuid).collect::<HashSet<&OrganizationId>>(); existing_memberships.iter().map(|m| &m.org_uuid).collect::<HashSet<&OrganizationId>>();
let provided_reset_password_ids = let provided_reset_password_ids = data
data.reset_password_keys.iter().map(|rp| &rp.organization_id).collect::<HashSet<&OrganizationId>>(); .account_unlock_data
.organization_account_recovery_unlock_data
.iter()
.map(|rp| &rp.organization_id)
.collect::<HashSet<&OrganizationId>>();
if !provided_reset_password_ids.is_superset(&existing_reset_password_ids) { if !provided_reset_password_ids.is_superset(&existing_reset_password_ids) {
err!("All existing reset password keys must be included in the rotation") err!("All existing reset password keys must be included in the rotation")
} }
// Check that we're correctly rotating all the user's sends // Check that we're correctly rotating all the user's sends
let existing_send_ids = existing_sends.iter().map(|s| &s.uuid).collect::<HashSet<&SendId>>(); let existing_send_ids = existing_sends.iter().map(|s| &s.uuid).collect::<HashSet<&SendId>>();
let provided_send_ids = data.sends.iter().filter_map(|s| s.id.as_ref()).collect::<HashSet<&SendId>>(); let provided_send_ids = data.account_data.sends.iter().filter_map(|s| s.id.as_ref()).collect::<HashSet<&SendId>>();
if !provided_send_ids.is_superset(&existing_send_ids) { if !provided_send_ids.is_superset(&existing_send_ids) {
err!("All existing sends must be included in the rotation") err!("All existing sends must be included in the rotation")
} }
@@ -623,12 +675,12 @@ fn validate_keydata(
Ok(()) Ok(())
} }
#[post("/accounts/key", data = "<data>")] #[post("/accounts/key-management/rotate-user-account-keys", data = "<data>")]
async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { async fn post_rotatekey(data: Json<KeyData>, 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. // TODO: See if we can wrap everything within a SQL Transaction. If something fails it should revert everything.
let data: KeyData = data.into_inner(); let data: KeyData = data.into_inner();
if !headers.user.check_valid_password(&data.master_password_hash) { if !headers.user.check_valid_password(&data.old_master_key_authentication_hash) {
err!("Invalid password") err!("Invalid password")
} }
@@ -636,7 +688,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
// Bitwarden does not process the import if there is one item invalid. // 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. // 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. // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
Cipher::validate_cipher_data(&data.ciphers)?; Cipher::validate_cipher_data(&data.account_data.ciphers)?;
let user_id = &headers.user.uuid; let user_id = &headers.user.uuid;
@@ -657,10 +709,11 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
&existing_emergency_access, &existing_emergency_access,
&existing_memberships, &existing_memberships,
&existing_sends, &existing_sends,
&headers.user,
)?; )?;
// Update folder data // Update folder data
for folder_data in data.folders { for folder_data in data.account_data.folders {
// Skip `null` folder id entries. // Skip `null` folder id entries.
// See: https://github.com/bitwarden/clients/issues/8453 // See: https://github.com/bitwarden/clients/issues/8453
if let Some(folder_id) = folder_data.id { if let Some(folder_id) = folder_data.id {
@@ -674,7 +727,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
} }
// Update emergency access data // Update emergency access data
for emergency_access_data in data.emergency_access_keys { for emergency_access_data in data.account_unlock_data.emergency_access_unlock_data {
let Some(saved_emergency_access) = let Some(saved_emergency_access) =
existing_emergency_access.iter_mut().find(|ea| ea.uuid == emergency_access_data.id) existing_emergency_access.iter_mut().find(|ea| ea.uuid == emergency_access_data.id)
else { else {
@@ -686,7 +739,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
} }
// Update reset password data // Update reset password data
for reset_password_data in data.reset_password_keys { for reset_password_data in data.account_unlock_data.organization_account_recovery_unlock_data {
let Some(membership) = let Some(membership) =
existing_memberships.iter_mut().find(|m| m.org_uuid == reset_password_data.organization_id) existing_memberships.iter_mut().find(|m| m.org_uuid == reset_password_data.organization_id)
else { else {
@@ -698,7 +751,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
} }
// Update send data // Update send data
for send_data in data.sends { for send_data in data.account_data.sends {
let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else { let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else {
err!("Send doesn't exist") err!("Send doesn't exist")
}; };
@@ -709,7 +762,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
// Update cipher data // Update cipher data
use super::ciphers::update_cipher_from_data; use super::ciphers::update_cipher_from_data;
for cipher_data in data.ciphers { for cipher_data in data.account_data.ciphers {
if cipher_data.organization_id.is_none() { if cipher_data.organization_id.is_none() {
let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap()) let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap())
else { else {
@@ -726,16 +779,20 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
// Update user data // Update user data
let mut user = headers.user; let mut user = headers.user;
user.akey = data.key; user.private_key = Some(data.account_keys.user_key_encrypted_account_private_key);
user.private_key = Some(data.private_key); user.set_password(
user.reset_security_stamp(); &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,
);
let save_result = user.save(&mut conn).await; let save_result = user.save(&mut conn).await;
// Prevent logging out the client where the user requested this endpoint from. // 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. // If you do logout the user it will causes issues at the client side.
// Adding the device uuid will prevent this. // Adding the device uuid will prevent this.
nt.send_logout(&user, Some(headers.device.uuid.clone())).await; nt.send_logout(&user, Some(headers.device.uuid.clone()), &mut conn).await;
save_result save_result
} }
@@ -751,7 +808,7 @@ async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, mut conn:
user.reset_security_stamp(); user.reset_security_stamp();
let save_result = user.save(&mut conn).await; let save_result = user.save(&mut conn).await;
nt.send_logout(&user, None).await; nt.send_logout(&user, None, &mut conn).await;
save_result save_result
} }
@@ -777,6 +834,11 @@ async fn post_email_token(data: Json<EmailTokenData>, headers: Headers, mut conn
} }
if User::find_by_mail(&data.new_email, &mut conn).await.is_some() { if User::find_by_mail(&data.new_email, &mut conn).await.is_some() {
if CONFIG.mail_enabled() {
if let Err(e) = mail::send_change_email_existing(&data.new_email, &user.email).await {
error!("Error sending change-email-existing email: {e:#?}");
}
}
err!("Email already in use"); err!("Email already in use");
} }
@@ -859,7 +921,7 @@ async fn post_email(data: Json<ChangeEmailData>, headers: Headers, mut conn: DbC
let save_result = user.save(&mut conn).await; let save_result = user.save(&mut conn).await;
nt.send_logout(&user, None).await; nt.send_logout(&user, None, &mut conn).await;
save_result save_result
} }
@@ -1057,7 +1119,7 @@ pub async fn _prelogin(data: Json<PreloginData>, mut conn: DbConn) -> Json<Value
})) }))
} }
// https://github.com/bitwarden/server/blob/master/src/Api/Models/Request/Accounts/SecretVerificationRequestModel.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct SecretVerificationRequest { struct SecretVerificationRequest {
@@ -1065,7 +1127,7 @@ struct SecretVerificationRequest {
} }
#[post("/accounts/verify-password", data = "<data>")] #[post("/accounts/verify-password", data = "<data>")]
fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers) -> EmptyResult { async fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers, conn: DbConn) -> JsonResult {
let data: SecretVerificationRequest = data.into_inner(); let data: SecretVerificationRequest = data.into_inner();
let user = headers.user; let user = headers.user;
@@ -1073,7 +1135,7 @@ fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers) -> E
err!("Invalid password") err!("Invalid password")
} }
Ok(()) Ok(Json(master_password_policy(&user, &conn).await))
} }
async fn _api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers, mut conn: DbConn) -> JsonResult { async fn _api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers, mut conn: DbConn) -> JsonResult {
@@ -1198,19 +1260,14 @@ async fn put_device_token(
err!(format!("Error: device {device_id} should be present before a token can be assigned")) err!(format!("Error: device {device_id} should be present before a token can be assigned"))
}; };
// if the device already has been registered // Check if the new token is the same as the registered token
if device.is_registered() { // Although upstream seems to always register a device on login, we do not.
// check if the new token is the same as the registered token // Unless this causes issues, lets keep it this way, else we might need to also register on every login.
if device.push_token.is_some() && device.push_token.unwrap() == token.clone() { if device.push_token.as_ref() == Some(&token) {
debug!("Device {device_id} is already registered and token is the same"); debug!("Device {device_id} for user {} is already registered and token is identical", headers.user.uuid);
return Ok(()); 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); device.push_token = Some(token);
if let Err(e) = device.save(&mut 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}")); err!(format!("An error occurred while trying to save the device push token: {e}"));
@@ -1224,16 +1281,19 @@ async fn put_device_token(
#[put("/devices/identifier/<device_id>/clear-token")] #[put("/devices/identifier/<device_id>/clear-token")]
async fn put_clear_device_token(device_id: DeviceId, mut conn: DbConn) -> EmptyResult { async fn put_clear_device_token(device_id: DeviceId, mut conn: DbConn) -> EmptyResult {
// This only clears push token // This only clears push token
// https://github.com/bitwarden/core/blob/master/src/Api/Controllers/DevicesController.cs#L109 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215
// https://github.com/bitwarden/core/blob/master/src/Core/Services/Implementations/DeviceService.cs#L37 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37
// This is somehow not implemented in any app, added it in case it is required // 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() { if !CONFIG.push_enabled() {
return Ok(()); return Ok(());
} }
if let Some(device) = Device::find_by_uuid(&device_id, &mut conn).await { if let Some(device) = Device::find_by_uuid(&device_id, &mut conn).await {
Device::clear_push_token_by_uuid(&device_id, &mut conn).await?; Device::clear_push_token_by_uuid(&device_id, &mut conn).await?;
unregister_push_device(device.push_uuid).await?; unregister_push_device(&device.push_uuid).await?;
} }
Ok(()) Ok(())
@@ -1271,10 +1331,10 @@ async fn post_auth_request(
}; };
// Validate device uuid and type // Validate device uuid and type
match Device::find_by_uuid_and_user(&data.device_identifier, &user.uuid, &mut conn).await { let device = match Device::find_by_uuid_and_user(&data.device_identifier, &user.uuid, &mut conn).await {
Some(device) if device.atype == client_headers.device_type => {} Some(device) if device.atype == client_headers.device_type => device,
_ => err!("AuthRequest doesn't exist", "Device verification failed"), _ => err!("AuthRequest doesn't exist", "Device verification failed"),
} };
let mut auth_request = AuthRequest::new( let mut auth_request = AuthRequest::new(
user.uuid.clone(), user.uuid.clone(),
@@ -1286,7 +1346,7 @@ async fn post_auth_request(
); );
auth_request.save(&mut conn).await?; auth_request.save(&mut conn).await?;
nt.send_auth_request(&user.uuid, &auth_request.uuid, &data.device_identifier, &mut conn).await; nt.send_auth_request(&user.uuid, &auth_request.uuid, &device, &mut conn).await;
log_user_event( log_user_event(
EventType::UserRequestedDeviceApproval as i32, EventType::UserRequestedDeviceApproval as i32,
@@ -1361,6 +1421,10 @@ async fn put_auth_request(
err!("AuthRequest doesn't exist", "Record not found or user uuid does not match") err!("AuthRequest doesn't exist", "Record not found or user uuid does not match")
}; };
if headers.device.uuid != data.device_identifier {
err!("AuthRequest doesn't exist", "Device verification failed")
}
if auth_request.approved.is_some() { if auth_request.approved.is_some() {
err!("An authentication request with the same device already exists") err!("An authentication request with the same device already exists")
} }
@@ -1377,7 +1441,7 @@ async fn put_auth_request(
auth_request.save(&mut conn).await?; auth_request.save(&mut conn).await?;
ant.send_auth_response(&auth_request.user_uuid, &auth_request.uuid).await; ant.send_auth_response(&auth_request.user_uuid, &auth_request.uuid).await;
nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &data.device_identifier, &mut conn).await; nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &headers.device, &mut conn).await;
log_user_event( log_user_event(
EventType::OrganizationUserApprovedAuthRequest as i32, EventType::OrganizationUserApprovedAuthRequest as i32,
+51 -51
View File
@@ -11,10 +11,11 @@ use rocket::{
use serde_json::Value; use serde_json::Value;
use crate::auth::ClientVersion; use crate::auth::ClientVersion;
use crate::util::NumberOrString; use crate::util::{save_temp_file, NumberOrString};
use crate::{ use crate::{
api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType}, api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
auth::Headers, auth::Headers,
config::PathType,
crypto, crypto,
db::{models::*, DbConn, DbPool}, db::{models::*, DbConn, DbPool},
CONFIG, CONFIG,
@@ -105,12 +106,7 @@ struct SyncData {
} }
#[get("/sync?<data..>")] #[get("/sync?<data..>")]
async fn sync( async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVersion>, mut conn: DbConn) -> JsonResult {
data: SyncData,
headers: Headers,
client_version: Option<ClientVersion>,
mut conn: DbConn,
) -> Json<Value> {
let user_json = headers.user.to_json(&mut conn).await; let user_json = headers.user.to_json(&mut conn).await;
// Get all ciphers which are visible by the user // Get all ciphers which are visible by the user
@@ -134,7 +130,7 @@ async fn sync(
for c in ciphers { for c in ciphers {
ciphers_json.push( ciphers_json.push(
c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &mut conn) c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &mut conn)
.await, .await?,
); );
} }
@@ -159,7 +155,7 @@ async fn sync(
api::core::_get_eq_domains(headers, true).into_inner() api::core::_get_eq_domains(headers, true).into_inner()
}; };
Json(json!({ Ok(Json(json!({
"profile": user_json, "profile": user_json,
"folders": folders_json, "folders": folders_json,
"collections": collections_json, "collections": collections_json,
@@ -168,11 +164,11 @@ async fn sync(
"domains": domains_json, "domains": domains_json,
"sends": sends_json, "sends": sends_json,
"object": "sync" "object": "sync"
})) })))
} }
#[get("/ciphers")] #[get("/ciphers")]
async fn get_ciphers(headers: Headers, mut conn: DbConn) -> Json<Value> { async fn get_ciphers(headers: Headers, mut conn: DbConn) -> JsonResult {
let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &mut conn).await; 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 cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &mut conn).await;
@@ -180,15 +176,15 @@ async fn get_ciphers(headers: Headers, mut conn: DbConn) -> Json<Value> {
for c in ciphers { for c in ciphers {
ciphers_json.push( ciphers_json.push(
c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &mut conn) c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &mut conn)
.await, .await?,
); );
} }
Json(json!({ Ok(Json(json!({
"data": ciphers_json, "data": ciphers_json,
"object": "list", "object": "list",
"continuationToken": null "continuationToken": null
})) })))
} }
#[get("/ciphers/<cipher_id>")] #[get("/ciphers/<cipher_id>")]
@@ -201,7 +197,7 @@ async fn get_cipher(cipher_id: CipherId, headers: Headers, mut conn: DbConn) ->
err!("Cipher is not owned by user") err!("Cipher is not owned by user")
} }
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await?))
} }
#[get("/ciphers/<cipher_id>/admin")] #[get("/ciphers/<cipher_id>/admin")]
@@ -339,7 +335,7 @@ async fn post_ciphers(data: Json<CipherData>, headers: Headers, mut conn: DbConn
let mut cipher = Cipher::new(data.r#type, data.name.clone()); let mut cipher = Cipher::new(data.r#type, data.name.clone());
update_cipher_from_data(&mut cipher, data, &headers, None, &mut 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, &mut 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. /// Enforces the personal ownership policy on user-owned ciphers, if applicable.
@@ -535,7 +531,7 @@ pub async fn update_cipher_from_data(
ut, ut,
cipher, cipher,
&cipher.update_users_revision(conn).await, &cipher.update_users_revision(conn).await,
&headers.device.uuid, &headers.device,
shared_to_collections, shared_to_collections,
conn, conn,
) )
@@ -612,7 +608,7 @@ async fn post_ciphers_import(
let mut user = headers.user; let mut user = headers.user;
user.update_revision(&mut conn).await?; user.update_revision(&mut conn).await?;
nt.send_user_update(UpdateType::SyncVault, &user).await; nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &mut conn).await;
Ok(()) Ok(())
} }
@@ -676,7 +672,7 @@ async fn put_cipher(
update_cipher_from_data(&mut cipher, data, &headers, None, &mut 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, &mut conn).await)) Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await?))
} }
#[post("/ciphers/<cipher_id>/partial", data = "<data>")] #[post("/ciphers/<cipher_id>/partial", data = "<data>")]
@@ -714,7 +710,7 @@ async fn put_cipher_partial(
// Update favorite // Update favorite
cipher.set_favorite(Some(data.favorite), &headers.user.uuid, &mut 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, &mut conn).await)) Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await?))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -808,7 +804,7 @@ async fn post_collections_update(
UpdateType::SyncCipherUpdate, UpdateType::SyncCipherUpdate,
&cipher, &cipher,
&cipher.update_users_revision(&mut conn).await, &cipher.update_users_revision(&mut conn).await,
&headers.device.uuid, &headers.device,
Some(Vec::from_iter(posted_collections)), Some(Vec::from_iter(posted_collections)),
&mut conn, &mut conn,
) )
@@ -825,7 +821,7 @@ async fn post_collections_update(
) )
.await; .await;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await?))
} }
#[put("/ciphers/<cipher_id>/collections-admin", data = "<data>")] #[put("/ciphers/<cipher_id>/collections-admin", data = "<data>")]
@@ -885,7 +881,7 @@ async fn post_collections_admin(
UpdateType::SyncCipherUpdate, UpdateType::SyncCipherUpdate,
&cipher, &cipher,
&cipher.update_users_revision(&mut conn).await, &cipher.update_users_revision(&mut conn).await,
&headers.device.uuid, &headers.device,
Some(Vec::from_iter(posted_collections)), Some(Vec::from_iter(posted_collections)),
&mut conn, &mut conn,
) )
@@ -1030,7 +1026,7 @@ async fn share_cipher_by_uuid(
update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?; 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 /// v2 API for downloading an attachment. This just redirects the client to
@@ -1055,7 +1051,7 @@ async fn get_attachment(
} }
match Attachment::find_by_id(&attachment_id, &mut conn).await { match Attachment::find_by_id(&attachment_id, &mut conn).await {
Some(attachment) if cipher_id == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host))), Some(attachment) if cipher_id == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host).await?)),
Some(_) => err!("Attachment doesn't belong to cipher"), Some(_) => err!("Attachment doesn't belong to cipher"),
None => err!("Attachment doesn't exist"), None => err!("Attachment doesn't exist"),
} }
@@ -1116,7 +1112,7 @@ async fn post_attachment_v2(
"attachmentId": attachment_id, "attachmentId": attachment_id,
"url": url, "url": url,
"fileUploadType": FileUploadType::Direct as i32, "fileUploadType": FileUploadType::Direct as i32,
response_key: cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await, response_key: cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await?,
}))) })))
} }
@@ -1142,7 +1138,7 @@ async fn save_attachment(
mut conn: DbConn, mut conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> Result<(Cipher, DbConn), crate::error::Error> { ) -> Result<(Cipher, DbConn), crate::error::Error> {
let mut data = data.into_inner(); let data = data.into_inner();
let Some(size) = data.data.len().to_i64() else { let Some(size) = data.data.len().to_i64() else {
err!("Attachment data size overflow"); err!("Attachment data size overflow");
@@ -1269,19 +1265,13 @@ async fn save_attachment(
attachment.save(&mut conn).await.expect("Error saving attachment"); attachment.save(&mut conn).await.expect("Error saving attachment");
} }
let folder_path = tokio::fs::canonicalize(&CONFIG.attachments_folder()).await?.join(cipher_id.as_ref()); save_temp_file(PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?;
let file_path = folder_path.join(file_id.as_ref());
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( nt.send_cipher_update(
UpdateType::SyncCipherUpdate, UpdateType::SyncCipherUpdate,
&cipher, &cipher,
&cipher.update_users_revision(&mut conn).await, &cipher.update_users_revision(&mut conn).await,
&headers.device.uuid, &headers.device,
None, None,
&mut conn, &mut conn,
) )
@@ -1342,7 +1332,7 @@ async fn post_attachment(
let (cipher, mut conn) = save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; let (cipher, mut conn) = save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await?))
} }
#[post("/ciphers/<cipher_id>/attachment-admin", format = "multipart/form-data", data = "<data>")] #[post("/ciphers/<cipher_id>/attachment-admin", format = "multipart/form-data", data = "<data>")]
@@ -1581,8 +1571,8 @@ async fn move_cipher_selected(
nt.send_cipher_update( nt.send_cipher_update(
UpdateType::SyncCipherUpdate, UpdateType::SyncCipherUpdate,
&cipher, &cipher,
&[user_id.clone()], std::slice::from_ref(&user_id),
&headers.device.uuid, &headers.device,
None, None,
&mut conn, &mut conn,
) )
@@ -1629,7 +1619,7 @@ async fn delete_all(
Some(member) => { Some(member) => {
if member.atype == MembershipType::Owner { if member.atype == MembershipType::Owner {
Cipher::delete_all_by_organization(&org_data.org_id, &mut conn).await?; Cipher::delete_all_by_organization(&org_data.org_id, &mut conn).await?;
nt.send_user_update(UpdateType::SyncVault, &user).await; nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &mut conn).await;
log_event( log_event(
EventType::OrganizationPurgedVault as i32, EventType::OrganizationPurgedVault as i32,
@@ -1662,7 +1652,7 @@ async fn delete_all(
} }
user.update_revision(&mut conn).await?; user.update_revision(&mut conn).await?;
nt.send_user_update(UpdateType::SyncVault, &user).await; nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &mut conn).await;
Ok(()) Ok(())
} }
@@ -1691,7 +1681,7 @@ async fn _delete_cipher_by_uuid(
UpdateType::SyncCipherUpdate, UpdateType::SyncCipherUpdate,
&cipher, &cipher,
&cipher.update_users_revision(conn).await, &cipher.update_users_revision(conn).await,
&headers.device.uuid, &headers.device,
None, None,
conn, conn,
) )
@@ -1702,7 +1692,7 @@ async fn _delete_cipher_by_uuid(
UpdateType::SyncCipherDelete, UpdateType::SyncCipherDelete,
&cipher, &cipher,
&cipher.update_users_revision(conn).await, &cipher.update_users_revision(conn).await,
&headers.device.uuid, &headers.device,
None, None,
conn, conn,
) )
@@ -1767,7 +1757,7 @@ async fn _restore_cipher_by_uuid(
UpdateType::SyncCipherUpdate, UpdateType::SyncCipherUpdate,
&cipher, &cipher,
&cipher.update_users_revision(conn).await, &cipher.update_users_revision(conn).await,
&headers.device.uuid, &headers.device,
None, None,
conn, conn,
) )
@@ -1786,7 +1776,7 @@ async fn _restore_cipher_by_uuid(
.await; .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( async fn _restore_multiple_ciphers(
@@ -1841,7 +1831,7 @@ async fn _delete_cipher_attachment_by_id(
UpdateType::SyncCipherUpdate, UpdateType::SyncCipherUpdate,
&cipher, &cipher,
&cipher.update_users_revision(conn).await, &cipher.update_users_revision(conn).await,
&headers.device.uuid, &headers.device,
None, None,
conn, conn,
) )
@@ -1859,7 +1849,7 @@ async fn _delete_cipher_attachment_by_id(
) )
.await; .await;
} }
let cipher_json = cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await; let cipher_json = cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?;
Ok(Json(json!({"cipher":cipher_json}))) Ok(Json(json!({"cipher":cipher_json})))
} }
@@ -1934,11 +1924,21 @@ impl CipherSyncData {
// Generate a HashMap with the collections_uuid as key and the CollectionGroup record // Generate a HashMap with the collections_uuid as key and the CollectionGroup record
let user_collections_groups: HashMap<CollectionId, CollectionGroup> = if CONFIG.org_groups_enabled() { let user_collections_groups: HashMap<CollectionId, CollectionGroup> = if CONFIG.org_groups_enabled() {
CollectionGroup::find_by_user(user_id, conn) CollectionGroup::find_by_user(user_id, conn).await.into_iter().fold(
.await HashMap::new(),
.into_iter() |mut combined_permissions, cg| {
.map(|collection_group| (collection_group.collections_uuid.clone(), collection_group)) combined_permissions
.collect() .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
},
)
} else { } else {
HashMap::new() HashMap::new()
}; };
+1 -1
View File
@@ -582,7 +582,7 @@ async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut
CipherSyncType::User, CipherSyncType::User,
&mut conn, &mut conn,
) )
.await, .await?,
); );
} }
+3 -3
View File
@@ -29,7 +29,7 @@ struct EventRange {
continuation_token: Option<String>, continuation_token: Option<String>,
} }
// Upstream: https://github.com/bitwarden/server/blob/9ecf69d9cabce732cf2c57976dd9afa5728578fb/src/Api/Controllers/EventsController.cs#LL84C35-L84C41 // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
#[get("/organizations/<org_id>/events?<data..>")] #[get("/organizations/<org_id>/events?<data..>")]
async fn get_org_events( async fn get_org_events(
org_id: OrganizationId, org_id: OrganizationId,
@@ -169,8 +169,8 @@ struct EventCollection {
} }
// Upstream: // Upstream:
// https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Events/Controllers/CollectController.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Events/Controllers/CollectController.cs
// https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Core/Services/Implementations/EventService.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs
#[post("/collect", format = "application/json", data = "<data>")] #[post("/collect", format = "application/json", data = "<data>")]
async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers, mut conn: DbConn) -> EmptyResult { async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers, mut conn: DbConn) -> EmptyResult {
if !CONFIG.org_events_enabled() { if !CONFIG.org_events_enabled() {
+3 -3
View File
@@ -45,7 +45,7 @@ async fn post_folders(data: Json<FolderData>, headers: Headers, mut conn: DbConn
let mut folder = Folder::new(headers.user.uuid, data.name); let mut folder = Folder::new(headers.user.uuid, data.name);
folder.save(&mut conn).await?; folder.save(&mut conn).await?;
nt.send_folder_update(UpdateType::SyncFolderCreate, &folder, &headers.device.uuid, &mut conn).await; nt.send_folder_update(UpdateType::SyncFolderCreate, &folder, &headers.device, &mut conn).await;
Ok(Json(folder.to_json())) Ok(Json(folder.to_json()))
} }
@@ -78,7 +78,7 @@ async fn put_folder(
folder.name = data.name; folder.name = data.name;
folder.save(&mut conn).await?; folder.save(&mut conn).await?;
nt.send_folder_update(UpdateType::SyncFolderUpdate, &folder, &headers.device.uuid, &mut conn).await; nt.send_folder_update(UpdateType::SyncFolderUpdate, &folder, &headers.device, &mut conn).await;
Ok(Json(folder.to_json())) Ok(Json(folder.to_json()))
} }
@@ -97,6 +97,6 @@ async fn delete_folder(folder_id: FolderId, headers: Headers, mut conn: DbConn,
// Delete the actual folder entry // Delete the actual folder entry
folder.delete(&mut conn).await?; folder.delete(&mut conn).await?;
nt.send_folder_update(UpdateType::SyncFolderDelete, &folder, &headers.device.uuid, &mut conn).await; nt.send_folder_update(UpdateType::SyncFolderDelete, &folder, &headers.device, &mut conn).await;
Ok(()) Ok(())
} }
+16 -6
View File
@@ -124,7 +124,7 @@ async fn post_eq_domains(
user.save(&mut conn).await?; user.save(&mut conn).await?;
nt.send_user_update(UpdateType::SyncSettings, &user).await; nt.send_user_update(UpdateType::SyncSettings, &user, &headers.device.push_uuid, &mut conn).await;
Ok(Json(json!({}))) Ok(Json(json!({})))
} }
@@ -199,14 +199,18 @@ fn get_api_webauthn(_headers: Headers) -> Json<Value> {
#[get("/config")] #[get("/config")]
fn config() -> Json<Value> { fn config() -> Json<Value> {
let domain = crate::CONFIG.domain(); let domain = crate::CONFIG.domain();
// Official available feature flags can be found here:
// Server (v2025.6.2): https://github.com/bitwarden/server/blob/d094be3267f2030bd0dc62106bc6871cf82682f5/src/Core/Constants.cs#L103
// Client (web-v2025.6.1): https://github.com/bitwarden/clients/blob/747c2fd6a1c348a57a76e4a7de8128466ffd3c01/libs/common/src/enums/feature-flag.enum.ts#L12
// Android (v2025.6.0): https://github.com/bitwarden/android/blob/b5b022caaad33390c31b3021b2c1205925b0e1a2/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt#L22
// iOS (v2025.6.0): https://github.com/bitwarden/ios/blob/ff06d9c6cc8da89f78f37f376495800201d7261a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
let mut feature_states = let mut feature_states =
parse_experimental_client_feature_flags(&crate::CONFIG.experimental_client_feature_flags()); parse_experimental_client_feature_flags(&crate::CONFIG.experimental_client_feature_flags());
// Force the new key rotation feature feature_states.insert("duo-redirect".to_string(), true);
feature_states.insert("key-rotation-improvements".to_string(), true);
feature_states.insert("flexible-collections-v-1".to_string(), false);
feature_states.insert("email-verification".to_string(), true); feature_states.insert("email-verification".to_string(), true);
feature_states.insert("unauth-ui-refresh".to_string(), true); feature_states.insert("unauth-ui-refresh".to_string(), true);
feature_states.insert("enable-pm-flight-recorder".to_string(), true);
feature_states.insert("mobile-error-reporting".to_string(), true);
Json(json!({ Json(json!({
// Note: The clients use this version to handle backwards compatibility concerns // Note: The clients use this version to handle backwards compatibility concerns
@@ -214,7 +218,7 @@ fn config() -> Json<Value> {
// We should make sure that we keep this updated when we support the new server features // We should make sure that we keep this updated when we support the new server features
// Version history: // Version history:
// - Individual cipher key encryption: 2024.2.0 // - Individual cipher key encryption: 2024.2.0
"version": "2025.1.0", "version": "2025.6.0",
"gitHash": option_env!("GIT_REV"), "gitHash": option_env!("GIT_REV"),
"server": { "server": {
"name": "Vaultwarden", "name": "Vaultwarden",
@@ -229,6 +233,12 @@ fn config() -> Json<Value> {
"identity": format!("{domain}/identity"), "identity": format!("{domain}/identity"),
"notifications": format!("{domain}/notifications"), "notifications": format!("{domain}/notifications"),
"sso": "", "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, "featureStates": feature_states,
"object": "config", "object": "config",
+151 -53
View File
@@ -374,6 +374,21 @@ async fn get_org_collections_details(
|| (CONFIG.org_groups_enabled() || (CONFIG.org_groups_enabled()
&& GroupUser::has_full_access_by_member(&org_id, &member.uuid, &mut conn).await); && GroupUser::has_full_access_by_member(&org_id, &member.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<Value> = Membership::find_confirmed_and_manage_all_by_org(&org_id, &mut conn)
.await
.into_iter()
.map(|member| {
json!({
"id": member.uuid,
"readOnly": false,
"hidePasswords": false,
"manage": true,
})
})
.collect();
for col in Collection::find_by_organization(&org_id, &mut 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 // check whether the current user has access to the given collection
let assigned = has_full_access_to_org let assigned = has_full_access_to_org
@@ -382,7 +397,7 @@ async fn get_org_collections_details(
&& GroupUser::has_access_to_collection_by_member(&col.uuid, &member.uuid, &mut conn).await); && GroupUser::has_access_to_collection_by_member(&col.uuid, &member.uuid, &mut conn).await);
// get the users assigned directly to the given collection // get the users assigned directly to the given collection
let users: Vec<Value> = col_users let mut users: Vec<Value> = col_users
.iter() .iter()
.filter(|collection_member| collection_member.collection_uuid == col.uuid) .filter(|collection_member| collection_member.collection_uuid == col.uuid)
.map(|collection_member| { .map(|collection_member| {
@@ -391,6 +406,7 @@ async fn get_org_collections_details(
) )
}) })
.collect(); .collect();
users.extend_from_slice(&manage_all_members);
// get the group details for the given collection // get the group details for the given collection
let groups: Vec<Value> = if CONFIG.org_groups_enabled() { let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
@@ -681,6 +697,9 @@ async fn _delete_organization_collection(
headers: &ManagerHeaders, headers: &ManagerHeaders,
conn: &mut DbConn, conn: &mut DbConn,
) -> EmptyResult { ) -> 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 { 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") err!("Collection not found", "Collection does not exist or does not belong to this organization")
}; };
@@ -707,15 +726,6 @@ async fn delete_organization_collection(
_delete_organization_collection(&org_id, &col_id, &headers, &mut conn).await _delete_organization_collection(&org_id, &col_id, &headers, &mut conn).await
} }
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct DeleteCollectionData {
#[allow(dead_code)]
id: String,
#[allow(dead_code)]
org_id: OrganizationId,
}
#[post("/organizations/<org_id>/collections/<col_id>/delete")] #[post("/organizations/<org_id>/collections/<col_id>/delete")]
async fn post_organization_collection_delete( async fn post_organization_collection_delete(
org_id: OrganizationId, org_id: OrganizationId,
@@ -893,26 +903,31 @@ struct OrgIdData {
#[get("/ciphers/organization-details?<data..>")] #[get("/ciphers/organization-details?<data..>")]
async fn get_org_details(data: OrgIdData, headers: OrgMemberHeaders, mut conn: DbConn) -> JsonResult { async fn get_org_details(data: OrgIdData, headers: OrgMemberHeaders, mut conn: DbConn) -> JsonResult {
if data.organization_id != headers.org_id { if data.organization_id != headers.membership.org_uuid {
err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code); err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code);
} }
Ok(Json(json!({ Ok(Json(json!({
"data": _get_org_details(&data.organization_id, &headers.host, &headers.user.uuid, &mut conn).await, "data": _get_org_details(&data.organization_id, &headers.host, &headers.user.uuid, &mut conn).await?,
"object": "list", "object": "list",
"continuationToken": null, "continuationToken": null,
}))) })))
} }
async fn _get_org_details(org_id: &OrganizationId, host: &str, user_id: &UserId, conn: &mut DbConn) -> Value { async fn _get_org_details(
org_id: &OrganizationId,
host: &str,
user_id: &UserId,
conn: &mut DbConn,
) -> Result<Value, crate::Error> {
let ciphers = Cipher::find_by_org(org_id, conn).await; 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_id, CipherSyncType::Organization, conn).await;
let mut ciphers_json = Vec::with_capacity(ciphers.len()); let mut ciphers_json = Vec::with_capacity(ciphers.len());
for c in ciphers { 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_id, Some(&cipher_sync_data), CipherSyncType::Organization, conn).await?);
} }
json!(ciphers_json) Ok(json!(ciphers_json))
} }
#[derive(FromForm)] #[derive(FromForm)]
@@ -1180,6 +1195,9 @@ async fn reinvite_member(
headers: AdminHeaders, headers: AdminHeaders,
mut conn: DbConn, mut conn: DbConn,
) -> EmptyResult { ) -> 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, &mut conn).await _reinvite_member(&org_id, &member_id, &headers.user.email, &mut conn).await
} }
@@ -1397,6 +1415,9 @@ async fn _confirm_invite(
conn: &mut DbConn, conn: &mut DbConn,
nt: &Notify<'_>, nt: &Notify<'_>,
) -> EmptyResult { ) -> 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() || member_id.is_empty() {
err!("Key or UserId is not set, unable to process request"); err!("Key or UserId is not set, unable to process request");
} }
@@ -1460,7 +1481,7 @@ async fn _confirm_invite(
let save_result = member_to_confirm.save(conn).await; let save_result = member_to_confirm.save(conn).await;
if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await { if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await {
nt.send_user_update(UpdateType::SyncOrgKeys, &user).await; nt.send_user_update(UpdateType::SyncOrgKeys, &user, &headers.device.push_uuid, conn).await;
} }
save_result save_result
@@ -1719,6 +1740,9 @@ async fn _delete_member(
conn: &mut DbConn, conn: &mut DbConn,
nt: &Notify<'_>, nt: &Notify<'_>,
) -> EmptyResult { ) -> 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 { 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") err!("User to delete isn't member of the organization")
}; };
@@ -1747,7 +1771,7 @@ async fn _delete_member(
.await; .await;
if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await { if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await {
nt.send_user_update(UpdateType::SyncOrgKeys, &user).await; nt.send_user_update(UpdateType::SyncOrgKeys, &user, &headers.device.push_uuid, conn).await;
} }
member_to_delete.delete(conn).await member_to_delete.delete(conn).await
@@ -1813,16 +1837,20 @@ struct RelationsData {
value: usize, value: usize,
} }
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/ImportCiphersController.cs#L62
#[post("/ciphers/import-organization?<query..>", data = "<data>")] #[post("/ciphers/import-organization?<query..>", data = "<data>")]
async fn post_org_import( async fn post_org_import(
query: OrgIdData, query: OrgIdData,
data: Json<ImportData>, data: Json<ImportData>,
headers: AdminHeaders, headers: OrgMemberHeaders,
mut conn: DbConn, mut conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
let data: ImportData = data.into_inner();
let org_id = query.organization_id; 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();
// Validate the import before continuing // Validate the import before continuing
// Bitwarden does not process the import if there is one item invalid. // Bitwarden does not process the import if there is one item invalid.
@@ -1835,8 +1863,20 @@ async fn post_org_import(
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len()); let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len());
for col in data.collections { for col in data.collections {
let collection_uuid = if existing_collections.contains(&col.id) { let collection_uuid = if existing_collections.contains(&col.id) {
col.id.unwrap() 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, &mut conn).await
{
err!(Compact, "The current user isn't allowed to manage this collection")
}
col_id
} else { } 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); let new_collection = Collection::new(org_id.clone(), col.name, col.external_id);
new_collection.save(&mut conn).await?; new_collection.save(&mut conn).await?;
new_collection.uuid new_collection.uuid
@@ -1859,7 +1899,17 @@ async fn post_org_import(
// Always clear folder_id's via an organization import // Always clear folder_id's via an organization import
cipher_data.folder_id = None; cipher_data.folder_id = None;
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &mut conn, &nt, UpdateType::None).await.ok(); update_cipher_from_data(
&mut cipher,
cipher_data,
&headers,
Some(collections.clone()),
&mut conn,
&nt,
UpdateType::None,
)
.await
.ok();
ciphers.push(cipher.uuid); ciphers.push(cipher.uuid);
} }
@@ -1890,12 +1940,6 @@ struct BulkCollectionsData {
async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers, mut conn: DbConn) -> EmptyResult { async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers, mut conn: DbConn) -> EmptyResult {
let data: BulkCollectionsData = data.into_inner(); let data: BulkCollectionsData = data.into_inner();
// 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 // Get all the collection available to the user in one query
// Also filter based upon the provided collections // Also filter based upon the provided collections
let user_collections: HashMap<CollectionId, Collection> = let user_collections: HashMap<CollectionId, Collection> =
@@ -1924,8 +1968,16 @@ async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers
// Do not abort the operation just ignore it, it could be a cipher was just deleted for example // 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, &mut 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 { if cipher.is_write_accessible_to_user(&headers.user.uuid, &mut conn).await {
for collection in &data.collection_ids { // When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection
CollectionCipher::save(&cipher.uuid, collection, &mut conn).await?; // 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, &mut conn).await?;
}
} else {
for collection in &data.collection_ids {
CollectionCipher::save(&cipher.uuid, collection, &mut conn).await?;
}
} }
} }
}; };
@@ -2402,6 +2454,9 @@ async fn _revoke_member(
headers: &AdminHeaders, headers: &AdminHeaders,
conn: &mut DbConn, conn: &mut DbConn,
) -> EmptyResult { ) -> 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 { match Membership::find_by_uuid_and_org(member_id, org_id, conn).await {
Some(mut member) if member.status > MembershipStatus::Revoked as i32 => { Some(mut member) if member.status > MembershipStatus::Revoked as i32 => {
if member.user_uuid == headers.user.uuid { if member.user_uuid == headers.user.uuid {
@@ -2509,6 +2564,9 @@ async fn _restore_member(
headers: &AdminHeaders, headers: &AdminHeaders,
conn: &mut DbConn, conn: &mut DbConn,
) -> EmptyResult { ) -> 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 { match Membership::find_by_uuid_and_org(member_id, org_id, conn).await {
Some(mut member) if member.status < MembershipStatus::Accepted as i32 => { Some(mut member) if member.status < MembershipStatus::Accepted as i32 => {
if member.user_uuid == headers.user.uuid { if member.user_uuid == headers.user.uuid {
@@ -2556,18 +2614,27 @@ async fn _restore_member(
Ok(()) Ok(())
} }
#[get("/organizations/<org_id>/groups")] async fn get_groups_data(
async fn get_groups(org_id: OrganizationId, headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult { details: bool,
org_id: OrganizationId,
headers: ManagerHeadersLoose,
mut conn: DbConn,
) -> JsonResult {
if org_id != headers.membership.org_uuid { if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
let groups: Vec<Value> = if CONFIG.org_groups_enabled() { let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
// Group::find_by_organization(&org_id, &mut conn).await.iter().map(Group::to_json).collect::<Value>()
let groups = Group::find_by_organization(&org_id, &mut conn).await; let groups = Group::find_by_organization(&org_id, &mut conn).await;
let mut groups_json = Vec::with_capacity(groups.len()); let mut groups_json = Vec::with_capacity(groups.len());
for g in groups { if details {
groups_json.push(g.to_json_details(&mut conn).await) for g in groups {
groups_json.push(g.to_json_details(&mut conn).await)
}
} else {
for g in groups {
groups_json.push(g.to_json())
}
} }
groups_json groups_json
} else { } else {
@@ -2583,9 +2650,14 @@ async fn get_groups(org_id: OrganizationId, headers: ManagerHeadersLoose, mut co
}))) })))
} }
#[get("/organizations/<org_id>/groups")]
async fn get_groups(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
get_groups_data(false, org_id, headers, conn).await
}
#[get("/organizations/<org_id>/groups/details", rank = 1)] #[get("/organizations/<org_id>/groups/details", rank = 1)]
async fn get_groups_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { async fn get_groups_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
get_groups(org_id, headers, conn).await get_groups_data(true, org_id, headers, conn).await
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -2647,6 +2719,9 @@ async fn post_groups(
data: Json<GroupRequest>, data: Json<GroupRequest>,
mut conn: DbConn, mut conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
if !CONFIG.org_groups_enabled() { if !CONFIG.org_groups_enabled() {
err!("Group support is disabled"); err!("Group support is disabled");
} }
@@ -2676,6 +2751,9 @@ async fn put_group(
headers: AdminHeaders, headers: AdminHeaders,
mut conn: DbConn, mut conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
if !CONFIG.org_groups_enabled() { if !CONFIG.org_groups_enabled() {
err!("Group support is disabled"); err!("Group support is disabled");
} }
@@ -2740,7 +2818,8 @@ async fn add_update_group(
"organizationId": group.organizations_uuid, "organizationId": group.organizations_uuid,
"name": group.name, "name": group.name,
"accessAll": group.access_all, "accessAll": group.access_all,
"externalId": group.external_id "externalId": group.external_id,
"object": "group"
}))) })))
} }
@@ -2791,6 +2870,9 @@ async fn _delete_group(
headers: &AdminHeaders, headers: &AdminHeaders,
conn: &mut DbConn, conn: &mut DbConn,
) -> EmptyResult { ) -> EmptyResult {
if org_id != &headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
if !CONFIG.org_groups_enabled() { if !CONFIG.org_groups_enabled() {
err!("Group support is disabled"); err!("Group support is disabled");
} }
@@ -2820,6 +2902,9 @@ async fn bulk_delete_groups(
headers: AdminHeaders, headers: AdminHeaders,
mut conn: DbConn, mut conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
if !CONFIG.org_groups_enabled() { if !CONFIG.org_groups_enabled() {
err!("Group support is disabled"); err!("Group support is disabled");
} }
@@ -2883,6 +2968,9 @@ async fn put_group_members(
data: Json<Vec<MembershipId>>, data: Json<Vec<MembershipId>>,
mut conn: DbConn, mut conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
if !CONFIG.org_groups_enabled() { if !CONFIG.org_groups_enabled() {
err!("Group support is disabled"); err!("Group support is disabled");
} }
@@ -3067,7 +3155,7 @@ async fn get_organization_public_key(
headers: OrgMemberHeaders, headers: OrgMemberHeaders,
mut conn: DbConn, mut conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
let Some(org) = Organization::find_by_uuid(&org_id, &mut conn).await else { let Some(org) = Organization::find_by_uuid(&org_id, &mut conn).await else {
@@ -3081,7 +3169,7 @@ async fn get_organization_public_key(
} }
// Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients // Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients
// https://github.com/bitwarden/server/blob/25dc0c9178e3e3584074bbef0d4be827b7c89415/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L463-L468 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492
#[get("/organizations/<org_id>/keys")] #[get("/organizations/<org_id>/keys")]
async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
get_organization_public_key(org_id, headers, conn).await get_organization_public_key(org_id, headers, conn).await
@@ -3132,7 +3220,7 @@ async fn put_reset_password(
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None); user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None);
user.save(&mut conn).await?; user.save(&mut conn).await?;
nt.send_logout(&user, None).await; nt.send_logout(&user, None, &mut conn).await;
log_event( log_event(
EventType::OrganizationUserAdminResetPassword as i32, EventType::OrganizationUserAdminResetPassword as i32,
@@ -3172,16 +3260,16 @@ async fn get_reset_password_details(
check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers, &mut conn).await?; check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers, &mut conn).await?;
// https://github.com/bitwarden/server/blob/3b50ccb9f804efaacdc46bed5b60e5b28eddefcf/src/Api/Models/Response/Organizations/OrganizationUserResponseModel.cs#L111 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs#L190
Ok(Json(json!({ Ok(Json(json!({
"object": "organizationUserResetPasswordDetails", "object": "organizationUserResetPasswordDetails",
"kdf":user.client_kdf_type, "organizationUserId": member_id,
"kdfIterations":user.client_kdf_iter, "kdf": user.client_kdf_type,
"kdfMemory":user.client_kdf_memory, "kdfIterations": user.client_kdf_iter,
"kdfParallelism":user.client_kdf_parallelism, "kdfMemory": user.client_kdf_memory,
"resetPasswordKey":member.reset_password_key, "kdfParallelism": user.client_kdf_parallelism,
"encryptedPrivateKey":org.private_key, "resetPasswordKey": member.reset_password_key,
"encryptedPrivateKey": org.private_key,
}))) })))
} }
@@ -3237,13 +3325,17 @@ async fn put_reset_password_enrollment(
let reset_request = data.into_inner(); let reset_request = data.into_inner();
if reset_request.reset_password_key.is_none() let reset_password_key = match reset_request.reset_password_key {
&& OrgPolicy::org_is_reset_password_auto_enroll(&org_id, &mut conn).await 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, &mut conn).await {
err!("Reset password can't be withdrawn due to an enterprise policy"); err!("Reset password can't be withdrawn due to an enterprise policy");
} }
if reset_request.reset_password_key.is_some() { if reset_password_key.is_some() {
PasswordOrOtpData { PasswordOrOtpData {
master_password_hash: reset_request.master_password_hash, master_password_hash: reset_request.master_password_hash,
otp: reset_request.otp, otp: reset_request.otp,
@@ -3252,7 +3344,7 @@ async fn put_reset_password_enrollment(
.await?; .await?;
} }
member.reset_password_key = reset_request.reset_password_key; member.reset_password_key = reset_password_key;
member.save(&mut conn).await?; member.save(&mut conn).await?;
let log_id = if member.reset_password_key.is_some() { let log_id = if member.reset_password_key.is_some() {
@@ -3269,6 +3361,9 @@ async fn put_reset_password_enrollment(
// NOTE: It seems clients can't handle uppercase-first keys!! // 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. // 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. // 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/<org_id>/export")] #[get("/organizations/<org_id>/export")]
async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, mut conn: DbConn) -> JsonResult { async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, mut conn: DbConn) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
@@ -3277,7 +3372,7 @@ async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, mut conn:
Ok(Json(json!({ Ok(Json(json!({
"collections": convert_json_key_lcase_first(_get_org_collections(&org_id, &mut conn).await), "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), "ciphers": convert_json_key_lcase_first(_get_org_details(&org_id, &headers.host, &headers.user.uuid, &mut conn).await?),
}))) })))
} }
@@ -3288,6 +3383,9 @@ async fn _api_key(
headers: AdminHeaders, headers: AdminHeaders,
mut conn: DbConn, mut conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != &headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
let data: PasswordOrOtpData = data.into_inner(); let data: PasswordOrOtpData = data.into_inner();
let user = headers.user; let user = headers.user;
+1 -1
View File
@@ -46,7 +46,7 @@ struct OrgImportData {
#[post("/public/organization/import", data = "<data>")] #[post("/public/organization/import", data = "<data>")]
async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: DbConn) -> EmptyResult { async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: DbConn) -> EmptyResult {
// Most of the logic for this function can be found here // Most of the logic for this function can be found here
// https://github.com/bitwarden/server/blob/fd892b2ff4547648a276734fb2b14a8abae2c6f5/src/Core/Services/Implementations/OrganizationService.cs#L1797 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs#L1203
let org_id = token.0; let org_id = token.0;
let data = data.into_inner(); let data = data.into_inner();
+50 -34
View File
@@ -1,7 +1,9 @@
use std::path::Path; use std::path::Path;
use std::time::Duration;
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use num_traits::ToPrimitive; use num_traits::ToPrimitive;
use once_cell::sync::Lazy;
use rocket::form::Form; use rocket::form::Form;
use rocket::fs::NamedFile; use rocket::fs::NamedFile;
use rocket::fs::TempFile; use rocket::fs::TempFile;
@@ -11,12 +13,28 @@ use serde_json::Value;
use crate::{ use crate::{
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
auth::{ClientIp, Headers, Host}, auth::{ClientIp, Headers, Host},
config::PathType,
db::{models::*, DbConn, DbPool}, db::{models::*, DbConn, DbPool},
util::NumberOrString, util::{save_temp_file, NumberOrString},
CONFIG, CONFIG,
}; };
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available"; const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
static ANON_PUSH_DEVICE: Lazy<Device> = Lazy::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 // The max file size allowed by Bitwarden clients and add an extra 5% to avoid issues
const SIZE_525_MB: i64 = 550_502_400; const SIZE_525_MB: i64 = 550_502_400;
@@ -182,7 +200,7 @@ async fn post_send(data: Json<SendData>, headers: Headers, mut conn: DbConn, nt:
UpdateType::SyncSendCreate, UpdateType::SyncSendCreate,
&send, &send,
&send.update_users_revision(&mut conn).await, &send.update_users_revision(&mut conn).await,
&headers.device.uuid, &headers.device,
&mut conn, &mut conn,
) )
.await; .await;
@@ -204,13 +222,15 @@ struct UploadDataV2<'f> {
// @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads (v2). // @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. // 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 // 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 = "<data>")] #[post("/sends/file", format = "multipart/form-data", data = "<data>")]
async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult { async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult {
enforce_disable_send_policy(&headers, &mut conn).await?; enforce_disable_send_policy(&headers, &mut conn).await?;
let UploadData { let UploadData {
model, model,
mut data, data,
} = data.into_inner(); } = data.into_inner();
let model = model.into_inner(); let model = model.into_inner();
@@ -250,13 +270,8 @@ async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, mut conn:
} }
let file_id = crate::crypto::generate_send_file_id(); let file_id = crate::crypto::generate_send_file_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?;
if let Err(_err) = data.persist_to(&file_path).await { save_temp_file(PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?;
data.move_copy_to(file_path).await?
}
let mut data_value: Value = serde_json::from_str(&send.data)?; let mut data_value: Value = serde_json::from_str(&send.data)?;
if let Some(o) = data_value.as_object_mut() { if let Some(o) = data_value.as_object_mut() {
@@ -272,7 +287,7 @@ async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, mut conn:
UpdateType::SyncSendCreate, UpdateType::SyncSendCreate,
&send, &send,
&send.update_users_revision(&mut conn).await, &send.update_users_revision(&mut conn).await,
&headers.device.uuid, &headers.device,
&mut conn, &mut conn,
) )
.await; .await;
@@ -280,7 +295,7 @@ async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, mut conn:
Ok(Json(send.to_json())) Ok(Json(send.to_json()))
} }
// Upstream: https://github.com/bitwarden/server/blob/d0c793c95181dfb1b447eb450f85ba0bfd7ef643/src/Api/Controllers/SendsController.cs#L190 // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165
#[post("/sends/file/v2", data = "<data>")] #[post("/sends/file/v2", data = "<data>")]
async fn post_send_file_v2(data: Json<SendData>, headers: Headers, mut conn: DbConn) -> JsonResult { async fn post_send_file_v2(data: Json<SendData>, headers: Headers, mut conn: DbConn) -> JsonResult {
enforce_disable_send_policy(&headers, &mut conn).await?; enforce_disable_send_policy(&headers, &mut conn).await?;
@@ -351,7 +366,7 @@ pub struct SendFileData {
fileName: String, fileName: String,
} }
// https://github.com/bitwarden/server/blob/66f95d1c443490b653e5a15d32977e2f5a3f9e32/src/Api/Tools/Controllers/SendsController.cs#L250 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>")] #[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>")]
async fn post_send_file_v2_data( async fn post_send_file_v2_data(
send_id: SendId, send_id: SendId,
@@ -363,7 +378,7 @@ async fn post_send_file_v2_data(
) -> EmptyResult { ) -> EmptyResult {
enforce_disable_send_policy(&headers, &mut conn).await?; enforce_disable_send_policy(&headers, &mut conn).await?;
let mut data = data.into_inner(); let data = data.into_inner();
let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &mut conn).await else { let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &mut conn).await else {
err!("Send not found. Unable to save the file.", "Invalid send uuid or does not belong to user.") err!("Send not found. Unable to save the file.", "Invalid send uuid or does not belong to user.")
@@ -406,25 +421,15 @@ 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)); err!("Send file size does not match.", format!("Expected a file size of {} got {size}", send_data.size));
} }
let folder_path = tokio::fs::canonicalize(&CONFIG.sends_folder()).await?.join(send_id); let file_path = format!("{send_id}/{file_id}");
let file_path = folder_path.join(file_id);
// Check if the file already exists, if that is the case do not overwrite it save_temp_file(PathType::Sends, &file_path, data.data, false).await?;
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( nt.send_send_update(
UpdateType::SyncSendCreate, UpdateType::SyncSendCreate,
&send, &send,
&send.update_users_revision(&mut conn).await, &send.update_users_revision(&mut conn).await,
&headers.device.uuid, &headers.device,
&mut conn, &mut conn,
) )
.await; .await;
@@ -489,7 +494,7 @@ async fn post_access(
UpdateType::SyncSendUpdate, UpdateType::SyncSendUpdate,
&send, &send,
&send.update_users_revision(&mut conn).await, &send.update_users_revision(&mut conn).await,
&String::from("00000000-0000-0000-0000-000000000000").into(), &ANON_PUSH_DEVICE,
&mut conn, &mut conn,
) )
.await; .await;
@@ -546,20 +551,31 @@ async fn post_access_file(
UpdateType::SyncSendUpdate, UpdateType::SyncSendUpdate,
&send, &send,
&send.update_users_revision(&mut conn).await, &send.update_users_revision(&mut conn).await,
&String::from("00000000-0000-0000-0000-000000000000").into(), &ANON_PUSH_DEVICE,
&mut conn, &mut conn,
) )
.await; .await;
let token_claims = crate::auth::generate_send_claims(&send_id, &file_id);
let token = crate::auth::encode_jwt(&token_claims);
Ok(Json(json!({ Ok(Json(json!({
"object": "send-fileDownload", "object": "send-fileDownload",
"id": file_id, "id": file_id,
"url": format!("{}/api/sends/{send_id}/{file_id}?t={token}", &host.host) "url": download_url(&host, &send_id, &file_id).await?,
}))) })))
} }
async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> {
let operator = CONFIG.opendal_operator_for_path_type(PathType::Sends)?;
if operator.info().scheme() == 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_secs(5 * 60)).await?.uri().to_string())
}
}
#[get("/sends/<send_id>/<file_id>?<t>")] #[get("/sends/<send_id>/<file_id>?<t>")]
async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> { async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> {
if let Ok(claims) = crate::auth::decode_send(t) { if let Ok(claims) = crate::auth::decode_send(t) {
@@ -645,7 +661,7 @@ pub async fn update_send_from_data(
send.save(conn).await?; send.save(conn).await?;
if ut != UpdateType::None { if ut != UpdateType::None {
nt.send_send_update(ut, send, &send.update_users_revision(conn).await, &headers.device.uuid, conn).await; nt.send_send_update(ut, send, &send.update_users_revision(conn).await, &headers.device, conn).await;
} }
Ok(()) Ok(())
} }
@@ -661,7 +677,7 @@ async fn delete_send(send_id: SendId, headers: Headers, mut conn: DbConn, nt: No
UpdateType::SyncSendDelete, UpdateType::SyncSendDelete,
&send, &send,
&send.update_users_revision(&mut conn).await, &send.update_users_revision(&mut conn).await,
&headers.device.uuid, &headers.device,
&mut conn, &mut conn,
) )
.await; .await;
@@ -683,7 +699,7 @@ async fn put_remove_password(send_id: SendId, headers: Headers, mut conn: DbConn
UpdateType::SyncSendUpdate, UpdateType::SyncSendUpdate,
&send, &send,
&send.update_users_revision(&mut conn).await, &send.update_users_revision(&mut conn).await,
&headers.device.uuid, &headers.device,
&mut conn, &mut conn,
) )
.await; .await;
+4
View File
@@ -34,6 +34,10 @@ async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers,
_ => (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!({ Ok(Json(json!({
"enabled": enabled, "enabled": enabled,
"key": key, "key": key,
+4 -1
View File
@@ -118,6 +118,9 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, mut conn: DbCo
} else { } else {
json!({ json!({
"enabled": enabled, "enabled": enabled,
"host": null,
"clientSecret": null,
"clientId": null,
"object": "twoFactorDuo" "object": "twoFactorDuo"
}) })
}; };
@@ -258,7 +261,7 @@ pub(crate) async fn get_duo_keys_email(email: &str, conn: &mut DbConn) -> ApiRes
} }
.map_res("Can't fetch Duo Keys")?; .map_res("Can't fetch Duo Keys")?;
Ok((data.ik, data.sk, CONFIG.get_duo_akey(), data.host)) Ok((data.ik, data.sk, CONFIG.get_duo_akey().await, data.host))
} }
pub async fn generate_duo_signature(email: &str, conn: &mut DbConn) -> ApiResult<(String, String)> { pub async fn generate_duo_signature(email: &str, conn: &mut DbConn) -> ApiResult<(String, String)> {
+1 -1
View File
@@ -21,7 +21,7 @@ use url::Url;
// The location on this service that Duo should redirect users to. For us, this is a bridge // The location on this service that Duo should redirect users to. For us, this is a bridge
// built in to the Bitwarden clients. // built in to the Bitwarden clients.
// See: https://github.com/bitwarden/clients/blob/main/apps/web/src/connectors/duo-redirect.ts // See: https://github.com/bitwarden/clients/blob/5fb46df3415aefced0b52f2db86c873962255448/apps/web/src/connectors/duo-redirect.ts
const DUO_REDIRECT_LOCATION: &str = "duo-redirect-connector.html"; const DUO_REDIRECT_LOCATION: &str = "duo-redirect-connector.html";
// Number of seconds that a JWT we generate for Duo should be valid for. // Number of seconds that a JWT we generate for Duo should be valid for.
+2 -3
View File
@@ -145,15 +145,14 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, mut c
// Ensure they are valid OTPs // Ensure they are valid OTPs
for yubikey in &yubikeys { for yubikey in &yubikeys {
if yubikey.len() == 12 { if yubikey.is_empty() || yubikey.len() == 12 {
// YubiKey ID
continue; continue;
} }
verify_yubikey_otp(yubikey.to_owned()).await.map_res("Invalid Yubikey OTP provided")?; verify_yubikey_otp(yubikey.to_owned()).await.map_res("Invalid Yubikey OTP provided")?;
} }
let yubikey_ids: Vec<String> = yubikeys.into_iter().map(|x| (x[..12]).to_owned()).collect(); let yubikey_ids: Vec<String> = yubikeys.into_iter().filter_map(|x| x.get(..12).map(str::to_owned)).collect();
let yubikey_metadata = YubikeyMetadata { let yubikey_metadata = YubikeyMetadata {
keys: yubikey_ids, keys: yubikey_ids,
+80 -30
View File
@@ -14,14 +14,12 @@ use reqwest::{
Client, Response, Client, Response,
}; };
use rocket::{http::ContentType, response::Redirect, Route}; use rocket::{http::ContentType, response::Redirect, Route};
use tokio::{ use svg_hush::{data_url_filter, Filter};
fs::{create_dir_all, remove_file, symlink_metadata, File},
io::{AsyncReadExt, AsyncWriteExt},
};
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer}; use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
use crate::{ use crate::{
config::PathType,
error::Error, error::Error,
http_client::{get_reqwest_client_builder, should_block_address, CustomHttpClientError}, http_client::{get_reqwest_client_builder, should_block_address, CustomHttpClientError},
util::Cached, util::Cached,
@@ -38,11 +36,29 @@ pub fn routes() -> Vec<Route> {
static CLIENT: Lazy<Client> = Lazy::new(|| { static CLIENT: Lazy<Client> = Lazy::new(|| {
// Generate the default headers // Generate the default headers
let mut default_headers = HeaderMap::new(); let mut default_headers = HeaderMap::new();
default_headers.insert(header::USER_AGENT, HeaderValue::from_static("Links (2.22; Linux X86_64; GNU C; text)")); default_headers.insert(
default_headers.insert(header::ACCEPT, HeaderValue::from_static("text/html, text/*;q=0.5, image/*, */*;q=0.1")); header::USER_AGENT,
default_headers.insert(header::ACCEPT_LANGUAGE, HeaderValue::from_static("en,*;q=0.1")); 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::CACHE_CONTROL, HeaderValue::from_static("no-cache")); 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::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 // Generate the cookie store
let cookie_store = Arc::new(Jar::default()); let cookie_store = Arc::new(Jar::default());
@@ -56,6 +72,7 @@ static CLIENT: Lazy<Client> = Lazy::new(|| {
.pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections .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 .pool_idle_timeout(pool_idle_timeout) // Configure the Hyper Pool to timeout after 10 seconds
.default_headers(default_headers.clone()) .default_headers(default_headers.clone())
.http1_title_case_headers()
.build() .build()
.expect("Failed to build client") .expect("Failed to build client")
}); });
@@ -158,7 +175,7 @@ fn is_valid_domain(domain: &str) -> bool {
} }
async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> { async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
let path = format!("{}/{domain}.png", CONFIG.icon_cache_folder()); let path = format!("{domain}.png");
// Check for expiration of negatively cached copy // Check for expiration of negatively cached copy
if icon_is_negcached(&path).await { if icon_is_negcached(&path).await {
@@ -177,7 +194,7 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
// Get the icon, or None in case of error // Get the icon, or None in case of error
match download_icon(domain).await { match download_icon(domain).await {
Ok((icon, icon_type)) => { Ok((icon, icon_type)) => {
save_icon(&path, &icon).await; save_icon(&path, icon.to_vec()).await;
Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_string())) Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_string()))
} }
Err(e) => { Err(e) => {
@@ -190,7 +207,7 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
warn!("Unable to download icon: {e:?}"); warn!("Unable to download icon: {e:?}");
let miss_indicator = path + ".miss"; let miss_indicator = path + ".miss";
save_icon(&miss_indicator, &[]).await; save_icon(&miss_indicator, vec![]).await;
None None
} }
} }
@@ -203,11 +220,9 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
} }
// Try to read the cached icon, and return it if it exists // Try to read the cached icon, and return it if it exists
if let Ok(mut f) = File::open(path).await { if let Ok(operator) = CONFIG.opendal_operator_for_path_type(PathType::IconCache) {
let mut buffer = Vec::new(); if let Ok(buf) = operator.read(path).await {
return Some(buf.to_vec());
if f.read_to_end(&mut buffer).await.is_ok() {
return Some(buffer);
} }
} }
@@ -215,9 +230,11 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
} }
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> { async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
let meta = symlink_metadata(path).await?; let operator = CONFIG.opendal_operator_for_path_type(PathType::IconCache)?;
let modified = meta.modified()?; let meta = operator.stat(path).await?;
let age = SystemTime::now().duration_since(modified)?; let modified =
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
let age = SystemTime::now().duration_since(modified.into())?;
Ok(ttl > 0 && ttl <= age.as_secs()) Ok(ttl > 0 && ttl <= age.as_secs())
} }
@@ -229,8 +246,13 @@ async fn icon_is_negcached(path: &str) -> bool {
match expired { match expired {
// No longer negatively cached, drop the marker // No longer negatively cached, drop the marker
Ok(true) => { Ok(true) => {
if let Err(e) = remove_file(&miss_indicator).await { match CONFIG.opendal_operator_for_path_type(PathType::IconCache) {
error!("Could not remove negative cache indicator for icon {path:?}: {e:?}"); 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:?}"),
} }
false false
} }
@@ -316,7 +338,7 @@ struct IconUrlResult {
/// Returns a IconUrlResult which holds a Vector IconList and a string which holds the referer. /// 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. /// There will always two items within the iconlist which holds http(s)://domain.tld/favicon.ico.
/// This does not mean that that location does exists, but it is the default location browser use. /// This does not mean that location exists, but (it) is the default location the browser uses.
/// ///
/// # Argument /// # Argument
/// * `domain` - A string which holds the domain with extension. /// * `domain` - A string which holds the domain with extension.
@@ -559,26 +581,46 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
if buffer.is_empty() { if buffer.is_empty() {
err_silent!("Empty response or unable find a valid icon", domain); 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)) Ok((buffer, icon_type))
} }
async fn save_icon(path: &str, icon: &[u8]) { async fn save_icon(path: &str, icon: Vec<u8>) {
match File::create(path).await { let operator = match CONFIG.opendal_operator_for_path_type(PathType::IconCache) {
Ok(mut f) => { Ok(operator) => operator,
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) => { Err(e) => {
warn!("Unable to save icon: {e:?}"); warn!("Failed to get OpenDAL operator while saving icon: {e}");
return;
} }
};
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 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("<svg") || content.contains("<SVG") {
return Some("svg+xml");
}
}
None
}
match bytes { match bytes {
[137, 80, 78, 71, ..] => Some("png"), [137, 80, 78, 71, ..] => Some("png"),
[0, 0, 1, 0, ..] => Some("x-icon"), [0, 0, 1, 0, ..] => Some("x-icon"),
@@ -586,6 +628,8 @@ fn get_icon_type(bytes: &[u8]) -> Option<&'static str> {
[255, 216, 255, ..] => Some("jpeg"), [255, 216, 255, ..] => Some("jpeg"),
[71, 73, 70, 56, ..] => Some("gif"), [71, 73, 70, 56, ..] => Some("gif"),
[66, 77, ..] => Some("bmp"), [66, 77, ..] => Some("bmp"),
[60, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg
[60, 63, 120, 109, 108, ..] => check_svg_after_xml_declaration(bytes), // An svg starting with <?xml
_ => None, _ => None,
} }
} }
@@ -597,6 +641,12 @@ async fn stream_to_bytes_limit(res: Response, max_size: usize) -> Result<Bytes,
let mut buf = BytesMut::new(); let mut buf = BytesMut::new();
let mut size = 0; let mut size = 0;
while let Some(chunk) = stream.next().await { while let Some(chunk) = stream.next().await {
// It is possible that there might occure UnexpectedEof errors or others
// This is most of the time no issue, and if there is no chunked data anymore or at all parsing the HTML will not happen anyway.
// Therfore if chunk is an err, just break and continue with the data be have received.
if chunk.is_err() {
break;
}
let chunk = &chunk?; let chunk = &chunk?;
size += chunk.len(); size += chunk.len();
buf.extend(chunk); buf.extend(chunk);
+36 -53
View File
@@ -14,10 +14,11 @@ use crate::{
log_user_event, log_user_event,
two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey}, two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey},
}, },
master_password_policy,
push::register_push_device, push::register_push_device,
ApiResult, EmptyResult, JsonResult, ApiResult, EmptyResult, JsonResult,
}, },
auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp}, auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp, ClientVersion},
db::{models::*, DbConn}, db::{models::*, DbConn},
error::MapResult, error::MapResult,
mail, util, CONFIG, mail, util, CONFIG,
@@ -28,7 +29,12 @@ pub fn routes() -> Vec<Route> {
} }
#[post("/connect/token", data = "<data>")] #[post("/connect/token", data = "<data>")]
async fn login(data: Form<ConnectData>, client_header: ClientHeaders, mut conn: DbConn) -> JsonResult { async fn login(
data: Form<ConnectData>,
client_header: ClientHeaders,
client_version: Option<ClientVersion>,
mut conn: DbConn,
) -> JsonResult {
let data: ConnectData = data.into_inner(); let data: ConnectData = data.into_inner();
let mut user_id: Option<UserId> = None; let mut user_id: Option<UserId> = None;
@@ -48,7 +54,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, mut conn:
_check_is_some(&data.device_name, "device_name 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")?; _check_is_some(&data.device_type, "device_type cannot be blank")?;
_password_login(data, &mut user_id, &mut conn, &client_header.ip).await _password_login(data, &mut user_id, &mut conn, &client_header.ip, &client_version).await
} }
"client_credentials" => { "client_credentials" => {
_check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(&data.client_id, "client_id cannot be blank")?;
@@ -112,7 +118,7 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult {
// See: https://github.com/dani-garcia/vaultwarden/issues/4156 // See: https://github.com/dani-garcia/vaultwarden/issues/4156
// --- // ---
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id);
device.save(conn).await?; device.save(conn).await?;
let result = json!({ let result = json!({
@@ -127,23 +133,12 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult {
Ok(Json(result)) Ok(Json(result))
} }
#[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( async fn _password_login(
data: ConnectData, data: ConnectData,
user_id: &mut Option<UserId>, user_id: &mut Option<UserId>,
conn: &mut DbConn, conn: &mut DbConn,
ip: &ClientIp, ip: &ClientIp,
client_version: &Option<ClientVersion>,
) -> JsonResult { ) -> JsonResult {
// Validate scope // Validate scope
let scope = data.scope.as_ref().unwrap(); let scope = data.scope.as_ref().unwrap();
@@ -262,7 +257,7 @@ async fn _password_login(
let (mut device, new_device) = get_device(&data, conn, &user).await; let (mut device, new_device) = get_device(&data, conn, &user).await;
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, conn).await?; let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, conn).await?;
if CONFIG.mail_enabled() && new_device { 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 { if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await {
@@ -291,38 +286,10 @@ async fn _password_login(
// See: https://github.com/dani-garcia/vaultwarden/issues/4156 // See: https://github.com/dani-garcia/vaultwarden/issues/4156
// --- // ---
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id);
device.save(conn).await?; device.save(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_policy = master_password_policy(&user, conn).await;
let master_password_policies: Vec<MasterPasswordPolicy> =
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 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 {
json!({"object": "masterPasswordPolicy"})
};
let mut result = json!({ let mut result = json!({
"access_token": access_token, "access_token": access_token,
@@ -441,7 +408,7 @@ async fn _user_api_key_login(
// See: https://github.com/dani-garcia/vaultwarden/issues/4156 // See: https://github.com/dani-garcia/vaultwarden/issues/4156
// --- // ---
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id);
device.save(conn).await?; device.save(conn).await?;
info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip);
@@ -520,6 +487,7 @@ async fn twofactor_auth(
data: &ConnectData, data: &ConnectData,
device: &mut Device, device: &mut Device,
ip: &ClientIp, ip: &ClientIp,
client_version: &Option<ClientVersion>,
conn: &mut DbConn, conn: &mut DbConn,
) -> ApiResult<Option<String>> { ) -> ApiResult<Option<String>> {
let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await;
@@ -538,7 +506,10 @@ async fn twofactor_auth(
let twofactor_code = match data.two_factor_token { let twofactor_code = match data.two_factor_token {
Some(ref code) => code, Some(ref code) => code,
None => { None => {
err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA token not provided") err_json!(
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
"2FA token not provided"
)
} }
}; };
@@ -585,7 +556,7 @@ async fn twofactor_auth(
} }
_ => { _ => {
err_json!( err_json!(
_json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
"2FA Remember token not provided" "2FA Remember token not provided"
) )
} }
@@ -617,6 +588,7 @@ async fn _json_err_twofactor(
providers: &[i32], providers: &[i32],
user_id: &UserId, user_id: &UserId,
data: &ConnectData, data: &ConnectData,
client_version: &Option<ClientVersion>,
conn: &mut DbConn, conn: &mut DbConn,
) -> ApiResult<Value> { ) -> ApiResult<Value> {
let mut result = json!({ let mut result = json!({
@@ -689,8 +661,16 @@ async fn _json_err_twofactor(
err!("No twofactor email registered") err!("No twofactor email registered")
}; };
// Send email immediately if email is the only 2FA option // Starting with version 2025.5.0 the client will call `/api/two-factor/send-email-login`.
if providers.len() == 1 { 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? email::send_token(user_id, conn).await?
} }
@@ -738,7 +718,10 @@ async fn register_verification_email(
) -> ApiResult<RegisterVerificationResponse> { ) -> ApiResult<RegisterVerificationResponse> {
let data = data.into_inner(); let data = data.into_inner();
if !CONFIG.is_signup_allowed(&data.email) { // 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, &mut conn).await.is_some()))
{
err!("Registration not allowed or user already exists") err!("Registration not allowed or user already exists")
} }
+50 -1
View File
@@ -32,7 +32,10 @@ pub use crate::api::{
web::routes as web_routes, web::routes as web_routes,
web::static_files, web::static_files,
}; };
use crate::db::{models::User, DbConn}; use crate::db::{
models::{OrgPolicy, OrgPolicyType, User},
DbConn,
};
// Type aliases for API methods results // Type aliases for API methods results
type ApiResult<T> = Result<T, crate::error::Error>; type ApiResult<T> = Result<T, crate::error::Error>;
@@ -68,3 +71,49 @@ impl PasswordOrOtpData {
Ok(()) Ok(())
} }
} }
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MasterPasswordPolicy {
min_complexity: Option<u8>,
min_length: Option<u32>,
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<MasterPasswordPolicy> =
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 {
json!({})
};
// NOTE: Upstream still uses PascalCase here for `Object`!
mpp_json["Object"] = json!("masterPasswordPolicy");
mpp_json
}
+19 -25
View File
@@ -10,7 +10,7 @@ use rocket_ws::{Message, WebSocket};
use crate::{ use crate::{
auth::{ClientIp, WsAccessTokenHeader}, auth::{ClientIp, WsAccessTokenHeader},
db::{ db::{
models::{AuthRequestId, Cipher, CollectionId, DeviceId, Folder, Send as DbSend, User, UserId}, models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId},
DbConn, DbConn,
}, },
Error, CONFIG, Error, CONFIG,
@@ -339,7 +339,7 @@ impl WebSocketUsers {
} }
// NOTE: The last modified date needs to be updated before calling these methods // NOTE: The last modified date needs to be updated before calling these methods
pub async fn send_user_update(&self, ut: UpdateType, user: &User) { pub async fn send_user_update(&self, ut: UpdateType, user: &User, push_uuid: &Option<PushId>, conn: &mut DbConn) {
// Skip any processing if both WebSockets and Push are not active // Skip any processing if both WebSockets and Push are not active
if *NOTIFICATIONS_DISABLED { if *NOTIFICATIONS_DISABLED {
return; return;
@@ -355,11 +355,11 @@ impl WebSocketUsers {
} }
if CONFIG.push_enabled() { if CONFIG.push_enabled() {
push_user_update(ut, user); push_user_update(ut, user, push_uuid, conn).await;
} }
} }
pub async fn send_logout(&self, user: &User, acting_device_id: Option<DeviceId>) { pub async fn send_logout(&self, user: &User, acting_device_id: Option<DeviceId>, conn: &mut DbConn) {
// Skip any processing if both WebSockets and Push are not active // Skip any processing if both WebSockets and Push are not active
if *NOTIFICATIONS_DISABLED { if *NOTIFICATIONS_DISABLED {
return; return;
@@ -375,17 +375,11 @@ impl WebSocketUsers {
} }
if CONFIG.push_enabled() { if CONFIG.push_enabled() {
push_logout(user, acting_device_id.clone()); push_logout(user, acting_device_id.clone(), conn).await;
} }
} }
pub async fn send_folder_update( pub async fn send_folder_update(&self, ut: UpdateType, folder: &Folder, device: &Device, conn: &mut DbConn) {
&self,
ut: UpdateType,
folder: &Folder,
acting_device_id: &DeviceId,
conn: &mut DbConn,
) {
// Skip any processing if both WebSockets and Push are not active // Skip any processing if both WebSockets and Push are not active
if *NOTIFICATIONS_DISABLED { if *NOTIFICATIONS_DISABLED {
return; return;
@@ -397,7 +391,7 @@ impl WebSocketUsers {
("RevisionDate".into(), serialize_date(folder.updated_at)), ("RevisionDate".into(), serialize_date(folder.updated_at)),
], ],
ut, ut,
Some(acting_device_id.clone()), Some(device.uuid.clone()),
); );
if CONFIG.enable_websocket() { if CONFIG.enable_websocket() {
@@ -405,7 +399,7 @@ impl WebSocketUsers {
} }
if CONFIG.push_enabled() { if CONFIG.push_enabled() {
push_folder_update(ut, folder, acting_device_id, conn).await; push_folder_update(ut, folder, device, conn).await;
} }
} }
@@ -414,7 +408,7 @@ impl WebSocketUsers {
ut: UpdateType, ut: UpdateType,
cipher: &Cipher, cipher: &Cipher,
user_ids: &[UserId], user_ids: &[UserId],
acting_device_id: &DeviceId, device: &Device,
collection_uuids: Option<Vec<CollectionId>>, collection_uuids: Option<Vec<CollectionId>>,
conn: &mut DbConn, conn: &mut DbConn,
) { ) {
@@ -444,7 +438,7 @@ impl WebSocketUsers {
("RevisionDate".into(), revision_date), ("RevisionDate".into(), revision_date),
], ],
ut, ut,
Some(acting_device_id.clone()), Some(device.uuid.clone()), // Acting device id (unique device/app uuid)
); );
if CONFIG.enable_websocket() { if CONFIG.enable_websocket() {
@@ -454,7 +448,7 @@ impl WebSocketUsers {
} }
if CONFIG.push_enabled() && user_ids.len() == 1 { if CONFIG.push_enabled() && user_ids.len() == 1 {
push_cipher_update(ut, cipher, acting_device_id, conn).await; push_cipher_update(ut, cipher, device, conn).await;
} }
} }
@@ -463,7 +457,7 @@ impl WebSocketUsers {
ut: UpdateType, ut: UpdateType,
send: &DbSend, send: &DbSend,
user_ids: &[UserId], user_ids: &[UserId],
acting_device_id: &DeviceId, device: &Device,
conn: &mut DbConn, conn: &mut DbConn,
) { ) {
// Skip any processing if both WebSockets and Push are not active // Skip any processing if both WebSockets and Push are not active
@@ -488,7 +482,7 @@ impl WebSocketUsers {
} }
} }
if CONFIG.push_enabled() && user_ids.len() == 1 { if CONFIG.push_enabled() && user_ids.len() == 1 {
push_send_update(ut, send, acting_device_id, conn).await; push_send_update(ut, send, device, conn).await;
} }
} }
@@ -496,7 +490,7 @@ impl WebSocketUsers {
&self, &self,
user_id: &UserId, user_id: &UserId,
auth_request_uuid: &str, auth_request_uuid: &str,
acting_device_id: &DeviceId, device: &Device,
conn: &mut DbConn, conn: &mut DbConn,
) { ) {
// Skip any processing if both WebSockets and Push are not active // Skip any processing if both WebSockets and Push are not active
@@ -506,14 +500,14 @@ impl WebSocketUsers {
let data = create_update( let data = create_update(
vec![("Id".into(), auth_request_uuid.to_owned().into()), ("UserId".into(), user_id.to_string().into())], vec![("Id".into(), auth_request_uuid.to_owned().into()), ("UserId".into(), user_id.to_string().into())],
UpdateType::AuthRequest, UpdateType::AuthRequest,
Some(acting_device_id.clone()), Some(device.uuid.clone()),
); );
if CONFIG.enable_websocket() { if CONFIG.enable_websocket() {
self.send_update(user_id, &data).await; self.send_update(user_id, &data).await;
} }
if CONFIG.push_enabled() { if CONFIG.push_enabled() {
push_auth_request(user_id.clone(), auth_request_uuid.to_owned(), conn).await; push_auth_request(user_id, auth_request_uuid, device, conn).await;
} }
} }
@@ -521,7 +515,7 @@ impl WebSocketUsers {
&self, &self,
user_id: &UserId, user_id: &UserId,
auth_request_id: &AuthRequestId, auth_request_id: &AuthRequestId,
approving_device_id: &DeviceId, device: &Device,
conn: &mut DbConn, conn: &mut DbConn,
) { ) {
// Skip any processing if both WebSockets and Push are not active // Skip any processing if both WebSockets and Push are not active
@@ -531,14 +525,14 @@ impl WebSocketUsers {
let data = create_update( let data = create_update(
vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())], vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())],
UpdateType::AuthRequestResponse, UpdateType::AuthRequestResponse,
Some(approving_device_id.clone()), Some(device.uuid.clone()),
); );
if CONFIG.enable_websocket() { if CONFIG.enable_websocket() {
self.send_update(user_id, &data).await; self.send_update(user_id, &data).await;
} }
if CONFIG.push_enabled() { if CONFIG.push_enabled() {
push_auth_response(user_id, auth_request_id, approving_device_id, conn).await; push_auth_response(user_id, auth_request_id, device, conn).await;
} }
} }
} }
+127 -111
View File
@@ -7,9 +7,9 @@ use tokio::sync::RwLock;
use crate::{ use crate::{
api::{ApiResult, EmptyResult, UpdateType}, api::{ApiResult, EmptyResult, UpdateType},
db::models::{AuthRequestId, Cipher, Device, DeviceId, Folder, Send, User, UserId}, db::models::{AuthRequestId, Cipher, Device, DeviceId, Folder, PushId, Send, User, UserId},
http_client::make_http_request, http_client::make_http_request,
util::format_date, util::{format_date, get_uuid},
CONFIG, CONFIG,
}; };
@@ -28,20 +28,20 @@ struct LocalAuthPushToken {
valid_until: Instant, valid_until: Instant,
} }
async fn get_auth_push_token() -> ApiResult<String> { async fn get_auth_api_token() -> ApiResult<String> {
static PUSH_TOKEN: Lazy<RwLock<LocalAuthPushToken>> = Lazy::new(|| { static API_TOKEN: Lazy<RwLock<LocalAuthPushToken>> = Lazy::new(|| {
RwLock::new(LocalAuthPushToken { RwLock::new(LocalAuthPushToken {
access_token: String::new(), access_token: String::new(),
valid_until: Instant::now(), valid_until: Instant::now(),
}) })
}); });
let push_token = PUSH_TOKEN.read().await; let api_token = API_TOKEN.read().await;
if push_token.valid_until.saturating_duration_since(Instant::now()).as_secs() > 0 { if api_token.valid_until.saturating_duration_since(Instant::now()).as_secs() > 0 {
debug!("Auth Push token still valid, no need for a new one"); debug!("Auth Push token still valid, no need for a new one");
return Ok(push_token.access_token.clone()); return Ok(api_token.access_token.clone());
} }
drop(push_token); // Drop the read lock now drop(api_token); // Drop the read lock now
let installation_id = CONFIG.push_installation_id(); let installation_id = CONFIG.push_installation_id();
let client_id = format!("installation.{installation_id}"); let client_id = format!("installation.{installation_id}");
@@ -68,44 +68,48 @@ async fn get_auth_push_token() -> ApiResult<String> {
Err(e) => err!(format!("Unexpected push token received from bitwarden server: {e}")), Err(e) => err!(format!("Unexpected push token received from bitwarden server: {e}")),
}; };
let mut push_token = PUSH_TOKEN.write().await; let mut api_token = API_TOKEN.write().await;
push_token.valid_until = Instant::now() api_token.valid_until = Instant::now()
.checked_add(Duration::new((json_pushtoken.expires_in / 2) as u64, 0)) // Token valid for half the specified time .checked_add(Duration::new((json_pushtoken.expires_in / 2) as u64, 0)) // Token valid for half the specified time
.unwrap(); .unwrap();
push_token.access_token = json_pushtoken.access_token; api_token.access_token = json_pushtoken.access_token;
debug!("Token still valid for {}", push_token.valid_until.saturating_duration_since(Instant::now()).as_secs()); debug!("Token still valid for {}", api_token.valid_until.saturating_duration_since(Instant::now()).as_secs());
Ok(push_token.access_token.clone()) Ok(api_token.access_token.clone())
} }
pub async fn register_push_device(device: &mut Device, conn: &mut crate::db::DbConn) -> EmptyResult { 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() { if !CONFIG.push_enabled() || !device.is_push_device() {
return Ok(()); return Ok(());
} }
if device.push_token.is_none() { if device.push_token.is_none() {
warn!("Skipping the registration of the device {} because the push_token field is empty.", device.uuid); 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."); warn!("To get rid of this message you need to logout, clear the app data and login again on the device.");
return Ok(()); return Ok(());
} }
debug!("Registering Device {}", device.uuid); debug!("Registering Device {:?}", device.push_uuid);
// generate a random push_uuid so we know the device is registered // Generate a random push_uuid so if it doesn't already have one
device.push_uuid = Some(uuid::Uuid::new_v4().to_string()); if device.push_uuid.is_none() {
device.push_uuid = Some(PushId(get_uuid()));
}
//Needed to register a device for push to bitwarden : //Needed to register a device for push to bitwarden :
let data = json!({ let data = json!({
"deviceId": device.push_uuid, // Unique UUID per user/device
"pushToken": device.push_token,
"userId": device.user_uuid, "userId": device.user_uuid,
"deviceId": device.push_uuid,
"identifier": device.uuid,
"type": device.atype, "type": device.atype,
"pushToken": device.push_token "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(),
}); });
let auth_push_token = get_auth_push_token().await?; let auth_api_token = get_auth_api_token().await?;
let auth_header = format!("Bearer {}", &auth_push_token); let auth_header = format!("Bearer {auth_api_token}");
if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/register"))? if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/register"))?
.header(CONTENT_TYPE, "application/json") .header(CONTENT_TYPE, "application/json")
@@ -126,18 +130,21 @@ pub async fn register_push_device(device: &mut Device, conn: &mut crate::db::DbC
Ok(()) Ok(())
} }
pub async fn unregister_push_device(push_id: Option<String>) -> EmptyResult { pub async fn unregister_push_device(push_id: &Option<PushId>) -> EmptyResult {
if !CONFIG.push_enabled() || push_id.is_none() { if !CONFIG.push_enabled() || push_id.is_none() {
return Ok(()); return Ok(());
} }
let auth_push_token = get_auth_push_token().await?; let auth_api_token = get_auth_api_token().await?;
let auth_header = format!("Bearer {}", &auth_push_token); let auth_header = format!("Bearer {auth_api_token}");
match make_http_request(Method::DELETE, &(CONFIG.push_relay_uri() + "/push/" + &push_id.unwrap()))? match make_http_request(
.header(AUTHORIZATION, auth_header) Method::POST,
.send() &format!("{}/push/delete/{}", CONFIG.push_relay_uri(), push_id.as_ref().unwrap()),
.await )?
.header(AUTHORIZATION, auth_header)
.send()
.await
{ {
Ok(r) => r, Ok(r) => r,
Err(e) => err!(format!("An error occurred during device unregistration: {e}")), Err(e) => err!(format!("An error occurred during device unregistration: {e}")),
@@ -145,12 +152,7 @@ pub async fn unregister_push_device(push_id: Option<String>) -> EmptyResult {
Ok(()) Ok(())
} }
pub async fn push_cipher_update( pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device, conn: &mut crate::db::DbConn) {
ut: UpdateType,
cipher: &Cipher,
acting_device_id: &DeviceId,
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. // 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() { if cipher.organization_uuid.is_some() {
return; return;
@@ -163,87 +165,97 @@ pub async fn push_cipher_update(
if Device::check_user_has_push_device(user_id, conn).await { if Device::check_user_has_push_device(user_id, conn).await {
send_to_push_relay(json!({ send_to_push_relay(json!({
"userId": user_id, "userId": user_id,
"organizationId": (), "organizationId": null,
"deviceId": acting_device_id, "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device)
"identifier": acting_device_id, "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app)
"type": ut as i32, "type": ut as i32,
"payload": { "payload": {
"Id": cipher.uuid, "id": cipher.uuid,
"UserId": cipher.user_uuid, "userId": cipher.user_uuid,
"OrganizationId": (), "organizationId": null,
"RevisionDate": format_date(&cipher.updated_at) "collectionIds": null,
} "revisionDate": format_date(&cipher.updated_at)
},
"clientType": null,
"installationId": null
})) }))
.await; .await;
} }
} }
pub fn push_logout(user: &User, acting_device_id: Option<DeviceId>) { pub async fn push_logout(user: &User, acting_device_id: Option<DeviceId>, conn: &mut crate::db::DbConn) {
let acting_device_id: Value = acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| Value::Null); let acting_device_id: Value = acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| Value::Null);
tokio::task::spawn(send_to_push_relay(json!({ if Device::check_user_has_push_device(&user.uuid, conn).await {
"userId": user.uuid,
"organizationId": (),
"deviceId": acting_device_id,
"identifier": acting_device_id,
"type": UpdateType::LogOut as i32,
"payload": {
"UserId": user.uuid,
"Date": format_date(&user.updated_at)
}
})));
}
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,
"Date": format_date(&user.updated_at)
}
})));
}
pub async fn push_folder_update(
ut: UpdateType,
folder: &Folder,
acting_device_id: &DeviceId,
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!({ tokio::task::spawn(send_to_push_relay(json!({
"userId": folder.user_uuid, "userId": user.uuid,
"organizationId": (), "organizationId": (),
"deviceId": acting_device_id, "deviceId": acting_device_id,
"identifier": acting_device_id, "identifier": acting_device_id,
"type": ut as i32, "type": UpdateType::LogOut as i32,
"payload": { "payload": {
"Id": folder.uuid, "userId": user.uuid,
"UserId": folder.user_uuid, "date": format_date(&user.updated_at)
"RevisionDate": format_date(&folder.updated_at) },
} "clientType": null,
"installationId": null
}))); })));
} }
} }
pub async fn push_send_update(ut: UpdateType, send: &Send, acting_device_id: &DeviceId, conn: &mut crate::db::DbConn) { pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: &Option<PushId>, conn: &mut crate::db::DbConn) {
if Device::check_user_has_push_device(&user.uuid, conn).await {
tokio::task::spawn(send_to_push_relay(json!({
"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
})));
}
}
pub async fn push_folder_update(ut: UpdateType, folder: &Folder, device: &Device, 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)
"type": ut as i32,
"payload": {
"id": folder.uuid,
"userId": folder.user_uuid,
"revisionDate": format_date(&folder.updated_at)
},
"clientType": null,
"installationId": null
})));
}
}
pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn: &mut crate::db::DbConn) {
if let Some(s) = &send.user_uuid { if let Some(s) = &send.user_uuid {
if Device::check_user_has_push_device(s, conn).await { if Device::check_user_has_push_device(s, conn).await {
tokio::task::spawn(send_to_push_relay(json!({ tokio::task::spawn(send_to_push_relay(json!({
"userId": send.user_uuid, "userId": send.user_uuid,
"organizationId": (), "organizationId": null,
"deviceId": acting_device_id, "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device)
"identifier": acting_device_id, "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app)
"type": ut as i32, "type": ut as i32,
"payload": { "payload": {
"Id": send.uuid, "id": send.uuid,
"UserId": send.user_uuid, "userId": send.user_uuid,
"RevisionDate": format_date(&send.revision_date) "revisionDate": format_date(&send.revision_date)
} },
"clientType": null,
"installationId": null
}))); })));
} }
} }
@@ -254,7 +266,7 @@ async fn send_to_push_relay(notification_data: Value) {
return; return;
} }
let auth_push_token = match get_auth_push_token().await { let auth_api_token = match get_auth_api_token().await {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
debug!("Could not get the auth push token: {e}"); debug!("Could not get the auth push token: {e}");
@@ -262,7 +274,7 @@ async fn send_to_push_relay(notification_data: Value) {
} }
}; };
let auth_header = format!("Bearer {}", &auth_push_token); let auth_header = format!("Bearer {auth_api_token}");
let req = match make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/send")) { let req = match make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/send")) {
Ok(r) => r, Ok(r) => r,
@@ -284,18 +296,20 @@ async fn send_to_push_relay(notification_data: Value) {
}; };
} }
pub async fn push_auth_request(user_id: UserId, auth_request_id: String, conn: &mut crate::db::DbConn) { pub async fn push_auth_request(user_id: &UserId, auth_request_id: &str, device: &Device, conn: &mut crate::db::DbConn) {
if Device::check_user_has_push_device(&user_id, conn).await { if Device::check_user_has_push_device(user_id, conn).await {
tokio::task::spawn(send_to_push_relay(json!({ tokio::task::spawn(send_to_push_relay(json!({
"userId": user_id, "userId": user_id,
"organizationId": (), "organizationId": null,
"deviceId": null, "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device)
"identifier": null, "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app)
"type": UpdateType::AuthRequest as i32, "type": UpdateType::AuthRequest as i32,
"payload": { "payload": {
"Id": auth_request_id, "userId": user_id,
"UserId": user_id, "id": auth_request_id,
} },
"clientType": null,
"installationId": null
}))); })));
} }
} }
@@ -303,20 +317,22 @@ pub async fn push_auth_request(user_id: UserId, auth_request_id: String, conn: &
pub async fn push_auth_response( pub async fn push_auth_response(
user_id: &UserId, user_id: &UserId,
auth_request_id: &AuthRequestId, auth_request_id: &AuthRequestId,
approving_device_id: &DeviceId, device: &Device,
conn: &mut crate::db::DbConn, conn: &mut crate::db::DbConn,
) { ) {
if Device::check_user_has_push_device(user_id, conn).await { if Device::check_user_has_push_device(user_id, conn).await {
tokio::task::spawn(send_to_push_relay(json!({ tokio::task::spawn(send_to_push_relay(json!({
"userId": user_id, "userId": user_id,
"organizationId": (), "organizationId": null,
"deviceId": approving_device_id, "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device)
"identifier": approving_device_id, "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app)
"type": UpdateType::AuthRequestResponse as i32, "type": UpdateType::AuthRequestResponse as i32,
"payload": { "payload": {
"Id": auth_request_id, "userId": user_id,
"UserId": user_id, "id": auth_request_id,
} },
"clientType": null,
"installationId": null
}))); })));
} }
} }
+1
View File
@@ -57,6 +57,7 @@ fn vaultwarden_css() -> Cached<Css<String>> {
let css_options = json!({ let css_options = json!({
"signup_disabled": !CONFIG.signups_allowed() && CONFIG.signups_domains_whitelist().is_empty(), "signup_disabled": !CONFIG.signups_allowed() && CONFIG.signups_domains_whitelist().is_empty(),
"mail_enabled": CONFIG.mail_enabled(), "mail_enabled": CONFIG.mail_enabled(),
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
"yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(), "yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(),
"emergency_access_allowed": CONFIG.emergency_access_allowed(), "emergency_access_allowed": CONFIG.emergency_access_allowed(),
"sends_allowed": CONFIG.sends_allowed(), "sends_allowed": CONFIG.sends_allowed(),
+52 -49
View File
@@ -7,16 +7,14 @@ use once_cell::sync::{Lazy, OnceCell};
use openssl::rsa::Rsa; use openssl::rsa::Rsa;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::ser::Serialize; use serde::ser::Serialize;
use std::{ use std::{env, net::IpAddr};
env,
fs::File,
io::{Read, Write},
net::IpAddr,
};
use crate::db::models::{ use crate::{
AttachmentId, CipherId, CollectionId, DeviceId, EmergencyAccessId, MembershipId, OrgApiKeyId, OrganizationId, config::PathType,
SendFileId, SendId, UserId, db::models::{
AttachmentId, CipherId, CollectionId, DeviceId, EmergencyAccessId, MembershipId, OrgApiKeyId, OrganizationId,
SendFileId, SendId, UserId,
},
}; };
use crate::{error::Error, CONFIG}; use crate::{error::Error, CONFIG};
@@ -40,37 +38,33 @@ static JWT_REGISTER_VERIFY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|regis
static PRIVATE_RSA_KEY: OnceCell<EncodingKey> = OnceCell::new(); static PRIVATE_RSA_KEY: OnceCell<EncodingKey> = OnceCell::new();
static PUBLIC_RSA_KEY: OnceCell<DecodingKey> = OnceCell::new(); static PUBLIC_RSA_KEY: OnceCell<DecodingKey> = OnceCell::new();
pub fn initialize_keys() -> Result<(), Error> { pub async fn initialize_keys() -> Result<(), Error> {
fn read_key(create_if_missing: bool) -> Result<(Rsa<openssl::pkey::Private>, Vec<u8>), Error> { use std::io::Error;
let mut priv_key_buffer = Vec::with_capacity(2048);
let mut priv_key_file = File::options() let rsa_key_filename = std::path::PathBuf::from(CONFIG.private_rsa_key())
.create(create_if_missing) .file_name()
.truncate(false) .ok_or_else(|| Error::other("Private RSA key path missing filename"))?
.read(true) .to_str()
.write(create_if_missing) .ok_or_else(|| Error::other("Private RSA key path filename is not valid UTF-8"))?
.open(CONFIG.private_rsa_key())?; .to_string();
#[allow(clippy::verbose_file_reads)] let operator = CONFIG.opendal_operator_for_path_type(PathType::RsaKey).map_err(Error::other)?;
let bytes_read = priv_key_file.read_to_end(&mut priv_key_buffer)?;
let rsa_key = if bytes_read > 0 { let priv_key_buffer = match operator.read(&rsa_key_filename).await {
Rsa::private_key_from_pem(&priv_key_buffer[..bytes_read])? Ok(buffer) => Some(buffer),
} else if create_if_missing { Err(e) if e.kind() == opendal::ErrorKind::NotFound => None,
// Only create the key if the file doesn't exist or is empty Err(e) => return Err(e.into()),
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());
};
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 (priv_key, priv_key_buffer) = read_key(true).or_else(|_| read_key(false))?; 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 pub_key_buffer = priv_key.public_key_to_pem()?; let pub_key_buffer = priv_key.public_key_to_pem()?;
let enc = EncodingKey::from_rsa_pem(&priv_key_buffer)?; let enc = EncodingKey::from_rsa_pem(&priv_key_buffer)?;
@@ -181,6 +175,11 @@ pub struct LoginJwtClaims {
pub sstamp: String, pub sstamp: String,
// device uuid // device uuid
pub device: DeviceId, 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,
// [ "api", "offline_access" ] // [ "api", "offline_access" ]
pub scope: Vec<String>, pub scope: Vec<String>,
// [ "Application" ] // [ "Application" ]
@@ -689,17 +688,6 @@ impl<'r> FromRequest<'r> for AdminHeaders {
} }
} }
impl From<AdminHeaders> 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/<org_id>/collections/<col_id>"), // col_id is usually the fourth path param ("/organizations/<org_id>/collections/<col_id>"),
// but there could be cases where it is a query value. // 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. // First check the path, if this is not a valid uuid, try the query values.
@@ -869,8 +857,10 @@ impl<'r> FromRequest<'r> for OwnerHeaders {
pub struct OrgMemberHeaders { pub struct OrgMemberHeaders {
pub host: String, pub host: String,
pub device: Device,
pub user: User, pub user: User,
pub org_id: OrganizationId, pub membership: Membership,
pub ip: ClientIp,
} }
#[rocket::async_trait] #[rocket::async_trait]
@@ -882,8 +872,10 @@ impl<'r> FromRequest<'r> for OrgMemberHeaders {
if headers.is_member() { if headers.is_member() {
Outcome::Success(Self { Outcome::Success(Self {
host: headers.host, host: headers.host,
device: headers.device,
user: headers.user, user: headers.user,
org_id: headers.membership.org_uuid, membership: headers.membership,
ip: headers.ip,
}) })
} else { } else {
err_handler!("You need to be a Member of the Organization to call this endpoint") err_handler!("You need to be a Member of the Organization to call this endpoint")
@@ -891,6 +883,17 @@ impl<'r> FromRequest<'r> for OrgMemberHeaders {
} }
} }
impl From<OrgMemberHeaders> for Headers {
fn from(h: OrgMemberHeaders) -> Headers {
Headers {
host: h.host,
device: h.device,
user: h.user,
ip: h.ip,
}
}
}
// //
// Client IP address detection // Client IP address detection
// //
+165 -28
View File
@@ -3,7 +3,7 @@ use std::{
process::exit, process::exit,
sync::{ sync::{
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
RwLock, LazyLock, RwLock,
}, },
}; };
@@ -22,10 +22,32 @@ static CONFIG_FILE: Lazy<String> = Lazy::new(|| {
get_env("CONFIG_FILE").unwrap_or_else(|| format!("{data_folder}/config.json")) get_env("CONFIG_FILE").unwrap_or_else(|| format!("{data_folder}/config.json"))
}); });
static CONFIG_FILE_PARENT_DIR: LazyLock<String> = 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<String> = 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 SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false);
pub static CONFIG: Lazy<Config> = Lazy::new(|| { pub static CONFIG: Lazy<Config> = Lazy::new(|| {
Config::load().unwrap_or_else(|e| { 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| {
println!("Error loading config:\n {e:?}\n"); println!("Error loading config:\n {e:?}\n");
exit(12) exit(12)
}) })
@@ -110,10 +132,11 @@ macro_rules! make_config {
builder builder
} }
fn from_file(path: &str) -> Result<Self, Error> { async fn from_file() -> Result<Self, Error> {
let config_str = std::fs::read_to_string(path)?; let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
println!("[INFO] Using saved config from `{path}` for configuration.\n"); let config_bytes = operator.read(&CONFIG_FILENAME).await?;
serde_json::from_str(&config_str).map_err(Into::into) 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) { fn clear_non_editable(&mut self) {
@@ -579,7 +602,7 @@ make_config! {
authenticator_disable_time_drift: bool, true, def, false; 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. /// 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, "fido2-vault-credentials".to_string(); experimental_client_feature_flags: String, false, def, String::new();
/// Require new device emails |> When a user logs in an email is required to be sent. /// 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. /// If sending the email fails the login attempt will fail.
@@ -833,21 +856,25 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
} }
} }
// TODO: deal with deprecated flags so they can be removed from this list, cf. #4263 // Server (v2025.6.2): https://github.com/bitwarden/server/blob/d094be3267f2030bd0dc62106bc6871cf82682f5/src/Core/Constants.cs#L103
// Client (web-v2025.6.1): https://github.com/bitwarden/clients/blob/747c2fd6a1c348a57a76e4a7de8128466ffd3c01/libs/common/src/enums/feature-flag.enum.ts#L12
// Android (v2025.6.0): https://github.com/bitwarden/android/blob/b5b022caaad33390c31b3021b2c1205925b0e1a2/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt#L22
// iOS (v2025.6.0): https://github.com/bitwarden/ios/blob/ff06d9c6cc8da89f78f37f376495800201d7261a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
//
// NOTE: Move deprecated flags to the utils::parse_experimental_client_feature_flags() DEPRECATED_FLAGS const!
const KNOWN_FLAGS: &[&str] = &[ const KNOWN_FLAGS: &[&str] = &[
"autofill-overlay", // Autofill Team
"autofill-v2",
"browser-fileless-import",
"extension-refresh",
"fido2-vault-credentials",
"inline-menu-positioning-improvements", "inline-menu-positioning-improvements",
"ssh-key-vault-item", "inline-menu-totp",
"ssh-agent", "ssh-agent",
// Key Management Team
"ssh-key-vault-item",
// Tools
"export-attachments",
// Mobile Team
"anon-addy-self-host-alias", "anon-addy-self-host-alias",
"simple-login-self-host-alias", "simple-login-self-host-alias",
"mutual-tls", "mutual-tls",
"export-attachments",
"inline-menu-totp",
]; ];
let configured_flags = parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags); 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(); let invalid_flags: Vec<_> = configured_flags.keys().filter(|flag| !KNOWN_FLAGS.contains(&flag.as_str())).collect();
@@ -1134,11 +1161,103 @@ fn smtp_convert_deprecated_ssl_options(smtp_ssl: Option<bool>, smtp_explicit_tls
"starttls".to_string() "starttls".to_string()
} }
fn opendal_operator_for_path(path: &str) -> Result<opendal::Operator, Error> {
// Cache of previously built operators by path
static OPERATORS_BY_PATH: LazyLock<dashmap::DashMap<String, opendal::Operator>> =
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<opendal::Operator, Error> {
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<Option<reqsign::AwsCredential>> {
use aws_credential_types::provider::ProvideCredentials as _;
use tokio::sync::OnceCell;
static DEFAULT_CREDENTIAL_CHAIN: OnceCell<DefaultCredentialsChain> = 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,
}
impl Config { impl Config {
pub fn load() -> Result<Self, Error> { pub async fn load() -> Result<Self, Error> {
// Loading from env and file // Loading from env and file
let _env = ConfigBuilder::from_env(); let _env = ConfigBuilder::from_env();
let _usr = ConfigBuilder::from_file(&CONFIG_FILE).unwrap_or_default(); let _usr = ConfigBuilder::from_file().await.unwrap_or_default();
// Create merged config, config file overwrites env // Create merged config, config file overwrites env
let mut _overrides = Vec::new(); let mut _overrides = Vec::new();
@@ -1162,7 +1281,7 @@ impl Config {
}) })
} }
pub fn update_config(&self, other: ConfigBuilder, ignore_non_editable: bool) -> Result<(), Error> { pub async fn update_config(&self, other: ConfigBuilder, ignore_non_editable: bool) -> Result<(), Error> {
// Remove default values // Remove default values
//let builder = other.remove(&self.inner.read().unwrap()._env); //let builder = other.remove(&self.inner.read().unwrap()._env);
@@ -1194,20 +1313,19 @@ impl Config {
} }
//Save to file //Save to file
use std::{fs::File, io::Write}; let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
let mut file = File::create(&*CONFIG_FILE)?; operator.write(&CONFIG_FILENAME, config_str).await?;
file.write_all(config_str.as_bytes())?;
Ok(()) Ok(())
} }
fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> { async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> {
let builder = { let builder = {
let usr = &self.inner.read().unwrap()._usr; let usr = &self.inner.read().unwrap()._usr;
let mut _overrides = Vec::new(); let mut _overrides = Vec::new();
usr.merge(&other, false, &mut _overrides) usr.merge(&other, false, &mut _overrides)
}; };
self.update_config(builder, false) self.update_config(builder, false).await
} }
/// Tests whether an email's domain is allowed. A domain is allowed if it /// Tests whether an email's domain is allowed. A domain is allowed if it
@@ -1249,8 +1367,9 @@ impl Config {
} }
} }
pub fn delete_user_config(&self) -> Result<(), Error> { pub async fn delete_user_config(&self) -> Result<(), Error> {
std::fs::remove_file(&*CONFIG_FILE)?; let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
operator.delete(&CONFIG_FILENAME).await?;
// Empty user config // Empty user config
let usr = ConfigBuilder::default(); let usr = ConfigBuilder::default();
@@ -1280,7 +1399,7 @@ impl Config {
inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail) inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail)
} }
pub fn get_duo_akey(&self) -> String { pub async fn get_duo_akey(&self) -> String {
if let Some(akey) = self._duo_akey() { if let Some(akey) = self._duo_akey() {
akey akey
} else { } else {
@@ -1291,7 +1410,7 @@ impl Config {
_duo_akey: Some(akey_s.clone()), _duo_akey: Some(akey_s.clone()),
..Default::default() ..Default::default()
}; };
self.update_config_partial(builder).ok(); self.update_config_partial(builder).await.ok();
akey_s akey_s
} }
@@ -1304,6 +1423,23 @@ impl Config {
token.is_some() && !token.unwrap().trim().is_empty() token.is_some() && !token.unwrap().trim().is_empty()
} }
pub fn opendal_operator_for_path_type(&self, path_type: PathType) -> Result<opendal::Operator, Error> {
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<T: serde::ser::Serialize>(&self, name: &str, data: &T) -> Result<String, Error> { pub fn render_template<T: serde::ser::Serialize>(&self, name: &str, data: &T) -> Result<String, Error> {
if self.reload_templates() { if self.reload_templates() {
warn!("RELOADING TEMPLATES"); warn!("RELOADING TEMPLATES");
@@ -1373,6 +1509,7 @@ where
reg!("email/email_footer_text"); reg!("email/email_footer_text");
reg!("email/admin_reset_password", ".html"); reg!("email/admin_reset_password", ".html");
reg!("email/change_email_existing", ".html");
reg!("email/change_email", ".html"); reg!("email/change_email", ".html");
reg!("email/delete_account", ".html"); reg!("email/delete_account", ".html");
reg!("email/emergency_access_invite_accepted", ".html"); reg!("email/emergency_access_invite_accepted", ".html");
+30 -24
View File
@@ -1,11 +1,11 @@
use std::io::ErrorKind; use std::time::Duration;
use bigdecimal::{BigDecimal, ToPrimitive}; use bigdecimal::{BigDecimal, ToPrimitive};
use derive_more::{AsRef, Deref, Display}; use derive_more::{AsRef, Deref, Display};
use serde_json::Value; use serde_json::Value;
use super::{CipherId, OrganizationId, UserId}; use super::{CipherId, OrganizationId, UserId};
use crate::CONFIG; use crate::{config::PathType, CONFIG};
use macros::IdFromParam; use macros::IdFromParam;
db_object! { db_object! {
@@ -41,24 +41,30 @@ impl Attachment {
} }
pub fn get_file_path(&self) -> String { pub fn get_file_path(&self) -> String {
format!("{}/{}/{}", CONFIG.attachments_folder(), self.cipher_uuid, self.id) format!("{}/{}", self.cipher_uuid, self.id)
} }
pub fn get_url(&self, host: &str) -> String { pub async fn get_url(&self, host: &str) -> Result<String, crate::Error> {
let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone())); let operator = CONFIG.opendal_operator_for_path_type(PathType::Attachments)?;
format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id)
if operator.info().scheme() == 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_secs(5 * 60)).await?.uri().to_string())
}
} }
pub fn to_json(&self, host: &str) -> Value { pub async fn to_json(&self, host: &str) -> Result<Value, crate::Error> {
json!({ Ok(json!({
"id": self.id, "id": self.id,
"url": self.get_url(host), "url": self.get_url(host).await?,
"fileName": self.file_name, "fileName": self.file_name,
"size": self.file_size.to_string(), "size": self.file_size.to_string(),
"sizeName": crate::util::get_display_size(self.file_size), "sizeName": crate::util::get_display_size(self.file_size),
"key": self.akey, "key": self.akey,
"object": "attachment" "object": "attachment"
}) }))
} }
} }
@@ -104,26 +110,26 @@ impl Attachment {
pub async fn delete(&self, conn: &mut DbConn) -> EmptyResult { pub async fn delete(&self, conn: &mut DbConn) -> EmptyResult {
db_run! { conn: { db_run! { conn: {
let _: () = crate::util::retry( crate::util::retry(
|| diesel::delete(attachments::table.filter(attachments::id.eq(&self.id))).execute(conn), || diesel::delete(attachments::table.filter(attachments::id.eq(&self.id))).execute(conn),
10, 10,
) )
.map_res("Error deleting attachment")?; .map(|_| ())
.map_res("Error deleting attachment")
}}?;
let file_path = &self.get_file_path(); let operator = CONFIG.opendal_operator_for_path_type(PathType::Attachments)?;
let file_path = self.get_file_path();
match std::fs::remove_file(file_path) { if let Err(e) = operator.delete(&file_path).await {
// Ignore "file not found" errors. This can happen when the if e.kind() == opendal::ErrorKind::NotFound {
// upstream caller has already cleaned up the file as part of debug!("File '{file_path}' already deleted.");
// its own error handling. } else {
Err(e) if e.kind() == ErrorKind::NotFound => { return Err(e.into());
debug!("File '{file_path}' already deleted.");
Ok(())
}
Err(e) => Err(e.into()),
_ => Ok(()),
} }
}} }
Ok(())
} }
pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &mut DbConn) -> EmptyResult { pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &mut DbConn) -> EmptyResult {
+1 -1
View File
@@ -16,7 +16,7 @@ db_object! {
pub organization_uuid: Option<OrganizationId>, pub organization_uuid: Option<OrganizationId>,
pub request_device_identifier: DeviceId, pub request_device_identifier: DeviceId,
pub device_type: i32, // https://github.com/bitwarden/server/blob/master/src/Core/Enums/DeviceType.cs pub device_type: i32, // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs
pub request_ip: String, pub request_ip: String,
pub response_device_id: Option<DeviceId>, pub response_device_id: Option<DeviceId>,
+35 -14
View File
@@ -141,18 +141,28 @@ impl Cipher {
cipher_sync_data: Option<&CipherSyncData>, cipher_sync_data: Option<&CipherSyncData>,
sync_type: CipherSyncType, sync_type: CipherSyncType,
conn: &mut DbConn, conn: &mut DbConn,
) -> Value { ) -> Result<Value, crate::Error> {
use crate::util::{format_date, validate_and_format_date}; use crate::util::{format_date, validate_and_format_date};
let mut attachments_json: Value = Value::Null; let mut attachments_json: Value = Value::Null;
if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid) { if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid) {
attachments_json = attachments.iter().map(|c| c.to_json(host)).collect(); 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);
}
} }
} else { } else {
let attachments = Attachment::find_by_cipher(&self.uuid, conn).await; let attachments = Attachment::find_by_cipher(&self.uuid, conn).await;
if !attachments.is_empty() { if !attachments.is_empty() {
attachments_json = attachments.iter().map(|c| c.to_json(host)).collect() 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);
} }
} }
@@ -318,7 +328,7 @@ impl Cipher {
// supports the "cipherDetails" type, though it seems like the // supports the "cipherDetails" type, though it seems like the
// Bitwarden clients will ignore extra fields. // Bitwarden clients will ignore extra fields.
// //
// Ref: https://github.com/bitwarden/server/blob/master/src/Core/Models/Api/Response/CipherResponseModel.cs // Ref: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Vault/Models/Response/CipherResponseModel.cs#L14
let mut json_object = json!({ let mut json_object = json!({
"object": "cipherDetails", "object": "cipherDetails",
"id": self.uuid, "id": self.uuid,
@@ -372,6 +382,11 @@ impl Cipher {
// the "Read Only" or "Hide Passwords" restrictions for the user. // the "Read Only" or "Hide Passwords" restrictions for the user.
json_object["edit"] = json!(!read_only); json_object["edit"] = json!(!read_only);
json_object["viewPassword"] = json!(!hide_passwords); 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 { let key = match self.atype {
@@ -384,7 +399,7 @@ impl Cipher {
}; };
json_object[key] = type_data_json; json_object[key] = type_data_json;
json_object Ok(json_object)
} }
pub async fn update_users_revision(&self, conn: &mut DbConn) -> Vec<UserId> { pub async fn update_users_revision(&self, conn: &mut DbConn) -> Vec<UserId> {
@@ -594,22 +609,23 @@ impl Cipher {
let mut rows: Vec<(bool, bool, bool)> = Vec::new(); let mut rows: Vec<(bool, bool, bool)> = Vec::new();
if let Some(collections) = cipher_sync_data.cipher_collections.get(&self.uuid) { if let Some(collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
for collection in collections { for collection in collections {
//User permissions // User permissions
if let Some(cu) = cipher_sync_data.user_collections.get(collection) { if let Some(cu) = cipher_sync_data.user_collections.get(collection) {
rows.push((cu.read_only, cu.hide_passwords, cu.manage)); 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) {
//Group permissions
if let Some(cg) = cipher_sync_data.user_collections_groups.get(collection) {
rows.push((cg.read_only, cg.hide_passwords, cg.manage)); rows.push((cg.read_only, cg.hide_passwords, cg.manage));
} }
} }
} }
rows rows
} else { } else {
let mut access_flags = self.get_user_collections_access_flags(user_uuid, conn).await; let user_permissions = self.get_user_collections_access_flags(user_uuid, conn).await;
access_flags.append(&mut self.get_group_collections_access_flags(user_uuid, conn).await); if !user_permissions.is_empty() {
access_flags user_permissions
} else {
self.get_group_collections_access_flags(user_uuid, conn).await
}
}; };
if rows.is_empty() { if rows.is_empty() {
@@ -618,6 +634,9 @@ impl Cipher {
} }
// A cipher can be in multiple collections with inconsistent access flags. // 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 // 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-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 // read/write access. For a flag to be in effect for a cipher, upstream
@@ -626,13 +645,15 @@ impl Cipher {
// and `hide_passwords` columns. This could ideally be done as part of the // 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 // query, but Diesel doesn't support a min() or bool_and() function on
// booleans and this behavior isn't portable anyway. // 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 read_only = true;
let mut hide_passwords = true; let mut hide_passwords = true;
let mut manage = false; let mut manage = false;
for (ro, hp, mn) in rows.iter() { for (ro, hp, mn) in rows.iter() {
read_only &= ro; read_only &= ro;
hide_passwords &= hp; hide_passwords &= hp;
manage &= mn; manage |= mn;
} }
Some((read_only, hide_passwords, manage)) Some((read_only, hide_passwords, manage))
+5 -3
View File
@@ -97,13 +97,13 @@ impl Collection {
( (
cu.read_only, cu.read_only,
cu.hide_passwords, cu.hide_passwords,
cu.manage || (is_manager && !cu.read_only && !cu.hide_passwords), is_manager && (cu.manage || (!cu.read_only && !cu.hide_passwords)),
) )
} else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) { } else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) {
( (
cg.read_only, cg.read_only,
cg.hide_passwords, cg.hide_passwords,
cg.manage || (is_manager && !cg.read_only && !cg.hide_passwords), is_manager && (cg.manage || (!cg.read_only && !cg.hide_passwords)),
) )
} else { } else {
(false, false, false) (false, false, false)
@@ -114,7 +114,9 @@ impl Collection {
} else { } else {
match Membership::find_confirmed_by_user_and_org(user_uuid, &self.org_uuid, conn).await { 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.has_full_access() => (false, false, m.atype >= MembershipType::Manager),
Some(_) if self.is_manageable_by_user(user_uuid, conn).await => (false, false, true), Some(m) if m.atype == MembershipType::Manager && self.is_manageable_by_user(user_uuid, conn).await => {
(false, false, true)
}
Some(m) => { Some(m) => {
let is_manager = m.atype == MembershipType::Manager; let is_manager = m.atype == MembershipType::Manager;
let read_only = !self.is_writable_by_user(user_uuid, conn).await; let read_only = !self.is_writable_by_user(user_uuid, conn).await;
+29 -12
View File
@@ -3,8 +3,12 @@ use derive_more::{Display, From};
use serde_json::Value; use serde_json::Value;
use super::{AuthRequest, UserId}; use super::{AuthRequest, UserId};
use crate::{crypto, util::format_date, CONFIG}; use crate::{
use macros::IdFromParam; crypto,
util::{format_date, get_uuid},
CONFIG,
};
use macros::{IdFromParam, UuidFromParam};
db_object! { db_object! {
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -19,8 +23,8 @@ db_object! {
pub user_uuid: UserId, pub user_uuid: UserId,
pub name: String, pub name: String,
pub atype: i32, // https://github.com/bitwarden/server/blob/dcc199bcce4aa2d5621f6fab80f1b49d8b143418/src/Core/Enums/DeviceType.cs pub atype: i32, // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs
pub push_uuid: Option<String>, pub push_uuid: Option<PushId>,
pub push_token: Option<String>, pub push_token: Option<String>,
pub refresh_token: String, pub refresh_token: String,
@@ -42,7 +46,7 @@ impl Device {
name, name,
atype, atype,
push_uuid: None, push_uuid: Some(PushId(get_uuid())),
push_token: None, push_token: None,
refresh_token: String::new(), refresh_token: String::new(),
twofactor_remember: None, twofactor_remember: None,
@@ -54,7 +58,7 @@ impl Device {
"id": self.uuid, "id": self.uuid,
"name": self.name, "name": self.name,
"type": self.atype, "type": self.atype,
"identifier": self.push_uuid, "identifier": self.uuid,
"creationDate": format_date(&self.created_at), "creationDate": format_date(&self.created_at),
"isTrusted": false, "isTrusted": false,
"object":"device" "object":"device"
@@ -73,7 +77,12 @@ impl Device {
self.twofactor_remember = None; self.twofactor_remember = None;
} }
pub fn refresh_tokens(&mut self, user: &super::User, scope: Vec<String>) -> (String, i64) { pub fn refresh_tokens(
&mut self,
user: &super::User,
scope: Vec<String>,
client_id: Option<String>,
) -> (String, i64) {
// If there is no refresh token, we create one // If there is no refresh token, we create one
if self.refresh_token.is_empty() { if self.refresh_token.is_empty() {
use data_encoding::BASE64URL; use data_encoding::BASE64URL;
@@ -84,6 +93,11 @@ impl Device {
let time_now = Utc::now(); let time_now = Utc::now();
self.updated_at = time_now.naive_utc(); self.updated_at = time_now.naive_utc();
// Generate a random push_uuid so if it doesn't already have one
if self.push_uuid.is_none() {
self.push_uuid = Some(PushId(get_uuid()));
}
// --- // ---
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large // 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 // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
@@ -121,6 +135,8 @@ impl Device {
// orgmanager, // orgmanager,
sstamp: user.security_stamp.clone(), sstamp: user.security_stamp.clone(),
device: self.uuid.clone(), device: self.uuid.clone(),
devicetype: DeviceType::from_i32(self.atype).to_string(),
client_id: client_id.unwrap_or("undefined".to_string()),
scope, scope,
amr: vec!["Application".into()], amr: vec!["Application".into()],
}; };
@@ -132,10 +148,6 @@ impl Device {
matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios) matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios)
} }
pub fn is_registered(&self) -> bool {
self.push_uuid.is_some()
}
pub fn is_cli(&self) -> bool { pub fn is_cli(&self) -> bool {
matches!(DeviceType::from_i32(self.atype), DeviceType::WindowsCLI | DeviceType::MacOsCLI | DeviceType::LinuxCLI) matches!(DeviceType::from_i32(self.atype), DeviceType::WindowsCLI | DeviceType::MacOsCLI | DeviceType::LinuxCLI)
} }
@@ -156,10 +168,12 @@ impl DeviceWithAuthRequest {
"id": self.device.uuid, "id": self.device.uuid,
"name": self.device.name, "name": self.device.name,
"type": self.device.atype, "type": self.device.atype,
"identifier": self.device.push_uuid, "identifier": self.device.uuid,
"creationDate": format_date(&self.device.created_at), "creationDate": format_date(&self.device.created_at),
"devicePendingAuthRequest": auth_request, "devicePendingAuthRequest": auth_request,
"isTrusted": false, "isTrusted": false,
"encryptedPublicKey": null,
"encryptedUserKey": null,
"object": "device", "object": "device",
}) })
} }
@@ -395,3 +409,6 @@ impl DeviceType {
Clone, Debug, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam, Clone, Debug, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam,
)] )]
pub struct DeviceId(String); pub struct DeviceId(String);
#[derive(Clone, Debug, DieselNewType, Display, From, FromForm, Serialize, Deserialize, UuidFromParam)]
pub struct PushId(pub String);
+2
View File
@@ -78,6 +78,7 @@ impl EmergencyAccess {
"grantorId": grantor_user.uuid, "grantorId": grantor_user.uuid,
"email": grantor_user.email, "email": grantor_user.email,
"name": grantor_user.name, "name": grantor_user.name,
"avatarColor": grantor_user.avatar_color,
"object": "emergencyAccessGrantorDetails", "object": "emergencyAccessGrantorDetails",
}) })
} }
@@ -106,6 +107,7 @@ impl EmergencyAccess {
"granteeId": grantee_user.uuid, "granteeId": grantee_user.uuid,
"email": grantee_user.email, "email": grantee_user.email,
"name": grantee_user.name, "name": grantee_user.name,
"avatarColor": grantee_user.avatar_color,
"object": "emergencyAccessGranteeDetails", "object": "emergencyAccessGranteeDetails",
})) }))
} }
+9 -10
View File
@@ -8,9 +8,9 @@ use crate::{api::EmptyResult, db::DbConn, error::MapResult, CONFIG};
// https://bitwarden.com/help/event-logs/ // https://bitwarden.com/help/event-logs/
db_object! { db_object! {
// Upstream: https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Core/Services/Implementations/EventService.cs // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs
// Upstream: https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Api/Models/Public/Response/EventResponseModel.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/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Sql/dbo/Tables/Event.sql // Upstream SQL: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Sql/dbo/Tables/Event.sql
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = event)] #[diesel(table_name = event)]
#[diesel(treat_none_as_null = true)] #[diesel(treat_none_as_null = true)]
@@ -25,7 +25,7 @@ db_object! {
pub group_uuid: Option<GroupId>, pub group_uuid: Option<GroupId>,
pub org_user_uuid: Option<MembershipId>, pub org_user_uuid: Option<MembershipId>,
pub act_user_uuid: Option<UserId>, pub act_user_uuid: Option<UserId>,
// Upstream enum: https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Core/Enums/DeviceType.cs // Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs
pub device_type: Option<i32>, pub device_type: Option<i32>,
pub ip_address: Option<String>, pub ip_address: Option<String>,
pub event_date: NaiveDateTime, pub event_date: NaiveDateTime,
@@ -36,7 +36,7 @@ db_object! {
} }
} }
// Upstream enum: https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Core/Enums/EventType.cs // Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/EventType.cs
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone)]
pub enum EventType { pub enum EventType {
// User // User
@@ -72,7 +72,6 @@ pub enum EventType {
CipherSoftDeleted = 1115, CipherSoftDeleted = 1115,
CipherRestored = 1116, CipherRestored = 1116,
CipherClientToggledCardNumberVisible = 1117, CipherClientToggledCardNumberVisible = 1117,
CipherClientToggledTOTPSeedVisible = 1118,
// Collection // Collection
CollectionCreated = 1300, CollectionCreated = 1300,
@@ -88,7 +87,7 @@ pub enum EventType {
OrganizationUserInvited = 1500, OrganizationUserInvited = 1500,
OrganizationUserConfirmed = 1501, OrganizationUserConfirmed = 1501,
OrganizationUserUpdated = 1502, OrganizationUserUpdated = 1502,
OrganizationUserRemoved = 1503, OrganizationUserRemoved = 1503, // Organization user data was deleted
OrganizationUserUpdatedGroups = 1504, OrganizationUserUpdatedGroups = 1504,
// OrganizationUserUnlinkedSso = 1505, // Not supported // OrganizationUserUnlinkedSso = 1505, // Not supported
OrganizationUserResetPasswordEnroll = 1506, OrganizationUserResetPasswordEnroll = 1506,
@@ -100,8 +99,8 @@ pub enum EventType {
OrganizationUserRestored = 1512, OrganizationUserRestored = 1512,
OrganizationUserApprovedAuthRequest = 1513, OrganizationUserApprovedAuthRequest = 1513,
OrganizationUserRejectedAuthRequest = 1514, OrganizationUserRejectedAuthRequest = 1514,
OrganizationUserDeleted = 1515, OrganizationUserDeleted = 1515, // Both user and organization user data were deleted
OrganizationUserLeft = 1516, OrganizationUserLeft = 1516, // User voluntarily left the organization
// Organization // Organization
OrganizationUpdated = 1600, OrganizationUpdated = 1600,
@@ -188,7 +187,7 @@ impl Event {
} }
/// Database methods /// Database methods
/// https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Core/Services/Implementations/EventService.cs /// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs
impl Event { impl Event {
pub const PAGE_SIZE: i64 = 30; pub const PAGE_SIZE: i64 = 30;
-5
View File
@@ -68,16 +68,11 @@ impl Group {
} }
pub fn to_json(&self) -> Value { pub fn to_json(&self) -> Value {
use crate::util::format_date;
json!({ json!({
"id": self.uuid, "id": self.uuid,
"organizationId": self.organizations_uuid, "organizationId": self.organizations_uuid,
"name": self.name, "name": self.name,
"accessAll": self.access_all,
"externalId": self.external_id, "externalId": self.external_id,
"creationDate": format_date(&self.creation_date),
"revisionDate": format_date(&self.revision_date),
"object": "group" "object": "group"
}) })
} }
+1 -1
View File
@@ -20,7 +20,7 @@ pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId}; pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::cipher::{Cipher, CipherId, RepromptType};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType}; pub use self::device::{Device, DeviceId, DeviceType, PushId};
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType}; pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
pub use self::event::{Event, EventType}; pub use self::event::{Event, EventType};
pub use self::favorite::Favorite; pub use self::favorite::Favorite;
+16 -6
View File
@@ -21,7 +21,7 @@ db_object! {
} }
} }
// https://github.com/bitwarden/server/blob/abfdf6f5cb0f1f1504dbaaaa0e04ce9cb60faf19/src/Core/AdminConsole/Enums/PolicyType.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs
#[derive(Copy, Clone, Eq, PartialEq, num_derive::FromPrimitive)] #[derive(Copy, Clone, Eq, PartialEq, num_derive::FromPrimitive)]
pub enum OrgPolicyType { pub enum OrgPolicyType {
TwoFactorAuthentication = 0, TwoFactorAuthentication = 0,
@@ -41,7 +41,7 @@ pub enum OrgPolicyType {
RemoveUnlockWithPin = 14, RemoveUnlockWithPin = 14,
} }
// https://github.com/bitwarden/server/blob/5cbdee137921a19b1f722920f0fa3cd45af2ef0f/src/Core/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SendOptionsPolicyData { pub struct SendOptionsPolicyData {
@@ -49,7 +49,7 @@ pub struct SendOptionsPolicyData {
pub disable_hide_email: bool, pub disable_hide_email: bool,
} }
// https://github.com/bitwarden/server/blob/5cbdee137921a19b1f722920f0fa3cd45af2ef0f/src/Core/Models/Data/Organizations/Policies/ResetPasswordDataModel.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/ResetPasswordDataModel.cs
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ResetPasswordDataModel { pub struct ResetPasswordDataModel {
@@ -83,14 +83,24 @@ impl OrgPolicy {
pub fn to_json(&self) -> Value { pub fn to_json(&self) -> Value {
let data_json: Value = serde_json::from_str(&self.data).unwrap_or(Value::Null); let data_json: Value = serde_json::from_str(&self.data).unwrap_or(Value::Null);
json!({ let mut policy = json!({
"id": self.uuid, "id": self.uuid,
"organizationId": self.org_uuid, "organizationId": self.org_uuid,
"type": self.atype, "type": self.atype,
"data": data_json, "data": data_json,
"enabled": self.enabled, "enabled": self.enabled,
"object": "policy", "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
} }
} }
@@ -201,7 +211,7 @@ impl OrgPolicy {
pub async fn find_accepted_and_confirmed_by_user_and_active_policy( pub async fn find_accepted_and_confirmed_by_user_and_active_policy(
user_uuid: &UserId, user_uuid: &UserId,
policy_type: OrgPolicyType, policy_type: OrgPolicyType,
conn: &mut DbConn, conn: &DbConn,
) -> Vec<Self> { ) -> Vec<Self> {
db_run! { conn: { db_run! { conn: {
org_policies::table org_policies::table
+24 -6
View File
@@ -56,7 +56,7 @@ db_object! {
} }
} }
// https://github.com/bitwarden/server/blob/b86a04cef9f1e1b82cf18e49fc94e017c641130c/src/Core/Enums/OrganizationUserStatusType.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs
#[derive(PartialEq)] #[derive(PartialEq)]
pub enum MembershipStatus { pub enum MembershipStatus {
Revoked = -1, Revoked = -1,
@@ -177,7 +177,7 @@ impl Organization {
public_key, public_key,
} }
} }
// https://github.com/bitwarden/server/blob/13d1e74d6960cf0d042620b72d85bf583a4236f7/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs
pub fn to_json(&self) -> Value { pub fn to_json(&self) -> Value {
json!({ json!({
"id": self.uuid, "id": self.uuid,
@@ -203,7 +203,6 @@ impl Organization {
"useResetPassword": CONFIG.mail_enabled(), "useResetPassword": CONFIG.mail_enabled(),
"allowAdminAccessToAllCollectionItems": true, "allowAdminAccessToAllCollectionItems": true,
"limitCollectionCreation": true, "limitCollectionCreation": true,
"limitCollectionCreationDeletion": true,
"limitCollectionDeletion": true, "limitCollectionDeletion": true,
"businessName": self.name, "businessName": self.name,
@@ -424,7 +423,7 @@ impl Membership {
"manageScim": false // Not supported (Not AGPLv3 Licensed) "manageScim": false // Not supported (Not AGPLv3 Licensed)
}); });
// https://github.com/bitwarden/server/blob/13d1e74d6960cf0d042620b72d85bf583a4236f7/src/Api/Models/Response/ProfileOrganizationResponseModel.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs
json!({ json!({
"id": self.org_uuid, "id": self.org_uuid,
"identifier": null, // Not supported "identifier": null, // Not supported
@@ -451,6 +450,8 @@ impl Membership {
"usePasswordManager": true, "usePasswordManager": true,
"useCustomPermissions": true, "useCustomPermissions": true,
"useActivateAutofillPolicy": false, "useActivateAutofillPolicy": false,
"useAdminSponsoredFamilies": false,
"useRiskInsights": false, // Not supported (Not AGPLv3 Licensed)
"organizationUserId": self.uuid, "organizationUserId": self.uuid,
"providerId": null, "providerId": null,
@@ -458,7 +459,6 @@ impl Membership {
"providerType": null, "providerType": null,
"familySponsorshipFriendlyName": null, "familySponsorshipFriendlyName": null,
"familySponsorshipAvailable": false, "familySponsorshipAvailable": false,
"planProductType": 3,
"productTierType": 3, // Enterprise tier "productTierType": 3, // Enterprise tier
"keyConnectorEnabled": false, "keyConnectorEnabled": false,
"keyConnectorUrl": null, "keyConnectorUrl": null,
@@ -467,10 +467,11 @@ impl Membership {
"familySponsorshipToDelete": null, "familySponsorshipToDelete": null,
"accessSecretsManager": false, "accessSecretsManager": false,
"limitCollectionCreation": self.atype < MembershipType::Manager, // If less then a manager return true, to limit collection creations "limitCollectionCreation": self.atype < MembershipType::Manager, // If less then a manager return true, to limit collection creations
"limitCollectionCreationDeletion": true,
"limitCollectionDeletion": true, "limitCollectionDeletion": true,
"limitItemDeletion": false,
"allowAdminAccessToAllCollectionItems": true, "allowAdminAccessToAllCollectionItems": true,
"userIsManagedByOrganization": false, // Means not managed via the Members UI, like SSO "userIsManagedByOrganization": false, // Means not managed via the Members UI, like SSO
"userIsClaimedByOrganization": false, // The new key instead of the obsolete userIsManagedByOrganization
"permissions": permissions, "permissions": permissions,
@@ -616,6 +617,8 @@ impl Membership {
"permissions": permissions, "permissions": permissions,
"ssoBound": false, // Not supported "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 "usesKeyConnector": false, // Not supported
"accessSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "accessSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
@@ -863,6 +866,21 @@ impl Membership {
}} }}
} }
// 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: &mut DbConn) -> Vec<Self> {
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::<MembershipDb>(conn)
.unwrap_or_default().from_db()
}}
}
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &mut DbConn) -> i64 { pub async fn count_by_org(org_uuid: &OrganizationId, conn: &mut DbConn) -> i64 {
db_run! { conn: { db_run! { conn: {
users_organizations::table users_organizations::table
+3 -2
View File
@@ -1,7 +1,7 @@
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use serde_json::Value; use serde_json::Value;
use crate::util::LowerCase; use crate::{config::PathType, util::LowerCase, CONFIG};
use super::{OrganizationId, User, UserId}; use super::{OrganizationId, User, UserId};
use id::SendId; use id::SendId;
@@ -226,7 +226,8 @@ impl Send {
self.update_users_revision(conn).await; self.update_users_revision(conn).await;
if self.atype == SendType::File as i32 { if self.atype == SendType::File as i32 {
std::fs::remove_dir_all(std::path::Path::new(&crate::CONFIG.sends_folder()).join(&self.uuid)).ok(); let operator = CONFIG.opendal_operator_for_path_type(PathType::Sends)?;
operator.remove_all(&self.uuid).await.ok();
} }
db_run! { conn: { db_run! { conn: {
-1
View File
@@ -249,7 +249,6 @@ impl User {
"emailVerified": !CONFIG.mail_enabled() || self.verified_at.is_some(), "emailVerified": !CONFIG.mail_enabled() || self.verified_at.is_some(),
"premium": true, "premium": true,
"premiumFromOrganization": false, "premiumFromOrganization": false,
"masterPasswordHint": self.password_hint,
"culture": "en-US", "culture": "en-US",
"twoFactorEnabled": twofactor_enabled, "twoFactorEnabled": twofactor_enabled,
"key": self.akey, "key": self.akey,
+29 -2
View File
@@ -46,6 +46,7 @@ use jsonwebtoken::errors::Error as JwtErr;
use lettre::address::AddressError as AddrErr; use lettre::address::AddressError as AddrErr;
use lettre::error::Error as LettreErr; use lettre::error::Error as LettreErr;
use lettre::transport::smtp::Error as SmtpErr; use lettre::transport::smtp::Error as SmtpErr;
use opendal::Error as OpenDALErr;
use openssl::error::ErrorStack as SSLErr; use openssl::error::ErrorStack as SSLErr;
use regex::Error as RegexErr; use regex::Error as RegexErr;
use reqwest::Error as ReqErr; use reqwest::Error as ReqErr;
@@ -59,6 +60,8 @@ use yubico::yubicoerror::YubicoError as YubiErr;
#[derive(Serialize)] #[derive(Serialize)]
pub struct Empty {} pub struct Empty {}
pub struct Compact {}
// Error struct // Error struct
// Contains a String error message, meant for the user and an enum variant, with an error of different types. // Contains a String error message, meant for the user and an enum variant, with an error of different types.
// //
@@ -69,6 +72,7 @@ make_error! {
Empty(Empty): _no_source, _serialize, Empty(Empty): _no_source, _serialize,
// Used to represent err! calls // Used to represent err! calls
Simple(String): _no_source, _api_error, Simple(String): _no_source, _api_error,
Compact(Compact): _no_source, _api_error_small,
// Used in our custom http client to handle non-global IPs and blocked domains // Used in our custom http client to handle non-global IPs and blocked domains
CustomHttpClient(CustomHttpClientError): _has_source, _api_error, CustomHttpClient(CustomHttpClientError): _has_source, _api_error,
@@ -95,6 +99,8 @@ make_error! {
DieselCon(DieselConErr): _has_source, _api_error, DieselCon(DieselConErr): _has_source, _api_error,
Webauthn(WebauthnErr): _has_source, _api_error, Webauthn(WebauthnErr): _has_source, _api_error,
OpenDAL(OpenDALErr): _has_source, _api_error,
} }
impl std::fmt::Debug for Error { impl std::fmt::Debug for Error {
@@ -132,6 +138,12 @@ impl Error {
self self
} }
#[must_use]
pub fn with_kind(mut self, kind: ErrorKind) -> Self {
self.error = kind;
self
}
#[must_use] #[must_use]
pub const fn with_code(mut self, code: u16) -> Self { pub const fn with_code(mut self, code: u16) -> Self {
self.error_code = code; self.error_code = code;
@@ -200,6 +212,18 @@ fn _api_error(_: &impl std::any::Any, msg: &str) -> String {
_serialize(&json, "") _serialize(&json, "")
} }
fn _api_error_small(_: &impl std::any::Any, msg: &str) -> String {
let json = json!({
"message": msg,
"validationErrors": null,
"exceptionMessage": null,
"exceptionStackTrace": null,
"innerExceptionMessage": null,
"object": "error"
});
_serialize(&json, "")
}
// //
// Rocket responder impl // Rocket responder impl
// //
@@ -212,8 +236,7 @@ use rocket::response::{self, Responder, Response};
impl Responder<'_, 'static> for Error { impl Responder<'_, 'static> for Error {
fn respond_to(self, _: &Request<'_>) -> response::Result<'static> { fn respond_to(self, _: &Request<'_>) -> response::Result<'static> {
match self.error { match self.error {
ErrorKind::Empty(_) => {} // Don't print the error in this situation ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation
ErrorKind::Simple(_) => {} // Don't print the error in this situation
_ => error!(target: "error", "{self:#?}"), _ => error!(target: "error", "{self:#?}"),
}; };
@@ -228,6 +251,10 @@ impl Responder<'_, 'static> for Error {
// //
#[macro_export] #[macro_export]
macro_rules! err { macro_rules! err {
($kind:ident, $msg:expr) => {{
error!("{}", $msg);
return Err($crate::error::Error::new($msg, $msg).with_kind($crate::error::ErrorKind::$kind($crate::error::$kind {})));
}};
($msg:expr) => {{ ($msg:expr) => {{
error!("{}", $msg); error!("{}", $msg);
return Err($crate::error::Error::new($msg, $msg)); return Err($crate::error::Error::new($msg, $msg));
+58
View File
@@ -244,3 +244,61 @@ impl Resolve for CustomDnsResolver {
}) })
} }
} }
#[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(),
})
}
}
}
+14
View File
@@ -570,6 +570,20 @@ pub async fn send_change_email(address: &str, token: &str) -> EmptyResult {
send_email(address, &subject, body_html, body_text).await 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_test(address: &str) -> EmptyResult { pub async fn send_test(address: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
"email/smtp_test", "email/smtp_test",
+20 -5
View File
@@ -61,7 +61,7 @@ mod util;
use crate::api::core::two_factor::duo_oidc::purge_duo_contexts; use crate::api::core::two_factor::duo_oidc::purge_duo_contexts;
use crate::api::purge_auth_requests; use crate::api::purge_auth_requests;
use crate::api::{WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}; use crate::api::{WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS};
pub use config::CONFIG; pub use config::{PathType, CONFIG};
pub use error::{Error, MapResult}; pub use error::{Error, MapResult};
use rocket::data::{Limits, ToByteUnit}; use rocket::data::{Limits, ToByteUnit};
use std::sync::{atomic::Ordering, Arc}; use std::sync::{atomic::Ordering, Arc};
@@ -75,16 +75,13 @@ async fn main() -> Result<(), Error> {
let level = init_logging()?; let level = init_logging()?;
check_data_folder().await; check_data_folder().await;
auth::initialize_keys().unwrap_or_else(|e| { auth::initialize_keys().await.unwrap_or_else(|e| {
error!("Error creating private key '{}'\n{e:?}\nExiting Vaultwarden!", CONFIG.private_rsa_key()); error!("Error creating private key '{}'\n{e:?}\nExiting Vaultwarden!", CONFIG.private_rsa_key());
exit(1); exit(1);
}); });
check_web_vault(); check_web_vault();
create_dir(&CONFIG.icon_cache_folder(), "icon cache");
create_dir(&CONFIG.tmp_folder(), "tmp folder"); 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; let pool = create_db_pool().await;
schedule_jobs(pool.clone()); schedule_jobs(pool.clone());
@@ -464,6 +461,24 @@ fn create_dir(path: &str, description: &str) {
async fn check_data_folder() { async fn check_data_folder() {
let data_folder = &CONFIG.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); let path = Path::new(data_folder);
if !path.exists() { if !path.exists() {
error!("Data folder '{data_folder}' doesn't exist."); error!("Data folder '{data_folder}' doesn't exist.");
+6 -2
View File
@@ -38,8 +38,8 @@ img {
max-width: 130px; max-width: 130px;
} }
#users-table .vw-actions, #orgs-table .vw-actions { #users-table .vw-actions, #orgs-table .vw-actions {
min-width: 135px; min-width: 155px;
max-width: 140px; max-width: 160px;
} }
#users-table .vw-org-cell { #users-table .vw-org-cell {
max-height: 120px; max-height: 120px;
@@ -54,3 +54,7 @@ img {
.vw-copy-toast { .vw-copy-toast {
width: 15rem; width: 15rem;
} }
.abbr-badge {
cursor: help;
}
+13 -10
View File
@@ -29,7 +29,7 @@ function isValidIp(ip) {
return ipv4Regex.test(ip) || ipv6Regex.test(ip); return ipv4Regex.test(ip) || ipv6Regex.test(ip);
} }
function checkVersions(platform, installed, latest, commit=null) { function checkVersions(platform, installed, latest, commit=null, pre_release=false) {
if (installed === "-" || latest === "-") { if (installed === "-" || latest === "-") {
document.getElementById(`${platform}-failed`).classList.remove("d-none"); document.getElementById(`${platform}-failed`).classList.remove("d-none");
return; return;
@@ -37,10 +37,12 @@ function checkVersions(platform, installed, latest, commit=null) {
// Only check basic versions, no commit revisions // Only check basic versions, no commit revisions
if (commit === null || installed.indexOf("-") === -1) { if (commit === null || installed.indexOf("-") === -1) {
if (installed !== latest) { if (platform === "web" && pre_release === true) {
document.getElementById(`${platform}-warning`).classList.remove("d-none"); document.getElementById(`${platform}-prerelease`).classList.remove("d-none");
} else { } else if (installed == latest) {
document.getElementById(`${platform}-success`).classList.remove("d-none"); document.getElementById(`${platform}-success`).classList.remove("d-none");
} else {
document.getElementById(`${platform}-warning`).classList.remove("d-none");
} }
} else { } else {
// Check if this is a branched version. // Check if this is a branched version.
@@ -86,7 +88,7 @@ async function generateSupportString(event, dj) {
supportString += `* Running within a container: ${dj.running_within_container} (Base: ${dj.container_base_image})\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 type: ${dj.db_type}\n`;
supportString += `* Database version: ${dj.db_version}\n`; supportString += `* Database version: ${dj.db_version}\n`;
supportString += `* Environment settings overridden!: ${dj.overrides !== ""}\n`; supportString += `* Uses config.json: ${dj.overrides !== ""}\n`;
supportString += `* Uses a reverse proxy: ${dj.ip_header_exists}\n`; supportString += `* Uses a reverse proxy: ${dj.ip_header_exists}\n`;
if (dj.ip_header_exists) { if (dj.ip_header_exists) {
supportString += `* IP Header check: ${dj.ip_header_match} (${dj.ip_header_name})\n`; supportString += `* IP Header check: ${dj.ip_header_match} (${dj.ip_header_name})\n`;
@@ -94,6 +96,9 @@ async function generateSupportString(event, dj) {
supportString += `* Internet access: ${dj.has_http_access}\n`; supportString += `* Internet access: ${dj.has_http_access}\n`;
supportString += `* Internet access via a proxy: ${dj.uses_proxy}\n`; supportString += `* Internet access via a proxy: ${dj.uses_proxy}\n`;
supportString += `* DNS Check: ${dnsCheck}\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 += `* Browser/Server Time Check: ${timeCheck}\n`;
supportString += `* Server/NTP Time Check: ${ntpTimeCheck}\n`; supportString += `* Server/NTP Time Check: ${ntpTimeCheck}\n`;
supportString += `* Domain Configuration Check: ${domainCheck}\n`; supportString += `* Domain Configuration Check: ${domainCheck}\n`;
@@ -203,11 +208,9 @@ function initVersionCheck(dj) {
} }
checkVersions("server", serverInstalled, serverLatest, serverLatestCommit); checkVersions("server", serverInstalled, serverLatest, serverLatestCommit);
if (!dj.running_within_container) { const webInstalled = dj.web_vault_version;
const webInstalled = dj.web_vault_version; const webLatest = dj.latest_web_build;
const webLatest = dj.latest_web_build; checkVersions("web", webInstalled, webLatest, null, dj.web_vault_pre_release);
checkVersions("web", webInstalled, webLatest);
}
} }
function checkDns(dns_resolved) { function checkDns(dns_resolved) {
+10 -6
View File
@@ -1,5 +1,5 @@
/*! /*!
* Bootstrap v5.3.4 (https://getbootstrap.com/) * Bootstrap v5.3.7 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) * Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/ */
@@ -647,7 +647,7 @@
* Constants * Constants
*/ */
const VERSION = '5.3.4'; const VERSION = '5.3.7';
/** /**
* Class definition * Class definition
@@ -673,6 +673,8 @@
this[propertyName] = null; this[propertyName] = null;
} }
} }
// Private
_queueCallback(callback, element, isAnimated = true) { _queueCallback(callback, element, isAnimated = true) {
executeAfterTransition(callback, element, isAnimated); executeAfterTransition(callback, element, isAnimated);
} }
@@ -1604,11 +1606,11 @@
this._element.style[dimension] = ''; this._element.style[dimension] = '';
this._queueCallback(complete, this._element, true); this._queueCallback(complete, this._element, true);
} }
// Private
_isShown(element = this._element) { _isShown(element = this._element) {
return element.classList.contains(CLASS_NAME_SHOW$7); return element.classList.contains(CLASS_NAME_SHOW$7);
} }
// Private
_configAfterMerge(config) { _configAfterMerge(config) {
config.toggle = Boolean(config.toggle); // Coerce string values config.toggle = Boolean(config.toggle); // Coerce string values
config.parent = getElement(config.parent); config.parent = getElement(config.parent);
@@ -3688,6 +3690,9 @@
this._element.setAttribute('aria-expanded', 'false'); this._element.setAttribute('aria-expanded', 'false');
Manipulator.removeDataAttribute(this._menu, 'popper'); Manipulator.removeDataAttribute(this._menu, 'popper');
EventHandler.trigger(this._element, EVENT_HIDDEN$5, relatedTarget); EventHandler.trigger(this._element, EVENT_HIDDEN$5, relatedTarget);
// Explicitly return focus to the trigger element
this._element.focus();
} }
_getConfig(config) { _getConfig(config) {
config = super._getConfig(config); config = super._getConfig(config);
@@ -4800,7 +4805,6 @@
* *
* Shout-out to Angular https://github.com/angular/angular/blob/15.2.8/packages/core/src/sanitization/url_sanitizer.ts#L38 * 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 SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i;
const allowedAttribute = (attribute, allowedAttributeList) => { const allowedAttribute = (attribute, allowedAttributeList) => {
const attributeName = attribute.nodeName.toLowerCase(); const attributeName = attribute.nodeName.toLowerCase();
@@ -5344,6 +5348,7 @@
if (trigger === 'click') { if (trigger === 'click') {
EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK$1), this._config.selector, event => { EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK$1), this._config.selector, event => {
const context = this._initializeOnDelegatedTarget(event); const context = this._initializeOnDelegatedTarget(event);
context._activeTrigger[TRIGGER_CLICK] = !(context._isShown() && context._activeTrigger[TRIGGER_CLICK]);
context.toggle(); context.toggle();
}); });
} else if (trigger !== TRIGGER_MANUAL) { } else if (trigger !== TRIGGER_MANUAL) {
@@ -6209,7 +6214,6 @@
} }
// Private // Private
_maybeScheduleHide() { _maybeScheduleHide() {
if (!this._config.autohide) { if (!this._config.autohide) {
return; return;
+13 -32
View File
@@ -1,6 +1,6 @@
@charset "UTF-8"; @charset "UTF-8";
/*! /*!
* Bootstrap v5.3.4 (https://getbootstrap.com/) * Bootstrap v5.3.7 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors * Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/ */
@@ -2156,10 +2156,6 @@ progress {
display: block; display: block;
padding: 0; padding: 0;
} }
.form-control::-moz-placeholder {
color: var(--bs-secondary-color);
opacity: 1;
}
.form-control::placeholder { .form-control::placeholder {
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
opacity: 1; opacity: 1;
@@ -2629,17 +2625,10 @@ textarea.form-control-lg {
.form-floating > .form-control-plaintext { .form-floating > .form-control-plaintext {
padding: 1rem 0.75rem; 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::placeholder,
.form-floating > .form-control-plaintext::placeholder { .form-floating > .form-control-plaintext::placeholder {
color: transparent; color: transparent;
} }
.form-floating > .form-control:not(:-moz-placeholder), .form-floating > .form-control-plaintext:not(:-moz-placeholder) {
padding-top: 1.625rem;
padding-bottom: 0.625rem;
}
.form-floating > .form-control:focus, .form-floating > .form-control:not(:placeholder-shown), .form-floating > .form-control:focus, .form-floating > .form-control:not(:placeholder-shown),
.form-floating > .form-control-plaintext:focus, .form-floating > .form-control-plaintext:focus,
.form-floating > .form-control-plaintext:not(:placeholder-shown) { .form-floating > .form-control-plaintext:not(:placeholder-shown) {
@@ -2656,9 +2645,6 @@ textarea.form-control-lg {
padding-bottom: 0.625rem; padding-bottom: 0.625rem;
padding-left: 0.75rem; padding-left: 0.75rem;
} }
.form-floating > .form-control:not(:-moz-placeholder) ~ label {
transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);
}
.form-floating > .form-control:focus ~ label, .form-floating > .form-control:focus ~ label,
.form-floating > .form-control:not(:placeholder-shown) ~ label, .form-floating > .form-control:not(:placeholder-shown) ~ label,
.form-floating > .form-control-plaintext ~ label, .form-floating > .form-control-plaintext ~ label,
@@ -2668,15 +2654,6 @@ textarea.form-control-lg {
.form-floating > .form-control:-webkit-autofill ~ label { .form-floating > .form-control:-webkit-autofill ~ label {
transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);
} }
.form-floating > textarea:not(:-moz-placeholder) ~ 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 > textarea:focus ~ label::after, .form-floating > textarea:focus ~ label::after,
.form-floating > textarea:not(:placeholder-shown) ~ label::after { .form-floating > textarea:not(:placeholder-shown) ~ label::after {
position: absolute; position: absolute;
@@ -4540,24 +4517,24 @@ textarea.form-control-lg {
border-top-right-radius: 0; border-top-right-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
} }
.card-group > .card:not(:last-child) .card-img-top, .card-group > .card:not(:last-child) > .card-img-top,
.card-group > .card:not(:last-child) .card-header { .card-group > .card:not(:last-child) > .card-header {
border-top-right-radius: 0; border-top-right-radius: 0;
} }
.card-group > .card:not(:last-child) .card-img-bottom, .card-group > .card:not(:last-child) > .card-img-bottom,
.card-group > .card:not(:last-child) .card-footer { .card-group > .card:not(:last-child) > .card-footer {
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
} }
.card-group > .card:not(:first-child) { .card-group > .card:not(:first-child) {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
} }
.card-group > .card:not(:first-child) .card-img-top, .card-group > .card:not(:first-child) > .card-img-top,
.card-group > .card:not(:first-child) .card-header { .card-group > .card:not(:first-child) > .card-header {
border-top-left-radius: 0; border-top-left-radius: 0;
} }
.card-group > .card:not(:first-child) .card-img-bottom, .card-group > .card:not(:first-child) > .card-img-bottom,
.card-group > .card:not(:first-child) .card-footer { .card-group > .card:not(:first-child) > .card-footer {
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
} }
} }
@@ -7179,6 +7156,10 @@ textarea.form-control-lg {
.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption) { .visually-hidden-focusable:not(:focus):not(:focus-within):not(caption) {
position: absolute !important; position: absolute !important;
} }
.visually-hidden *,
.visually-hidden-focusable:not(:focus):not(:focus-within) * {
overflow: hidden !important;
}
.stretched-link::after { .stretched-link::after {
position: absolute; position: absolute;
+113 -19
View File
@@ -4,10 +4,10 @@
* *
* To rebuild or modify this file with the latest versions of the included * To rebuild or modify this file with the latest versions of the included
* software please visit: * software please visit:
* https://datatables.net/download/#bs5/dt-2.2.2 * https://datatables.net/download/#bs5/dt-2.3.2
* *
* Included libraries: * Included libraries:
* DataTables 2.2.2 * DataTables 2.3.2
*/ */
:root { :root {
@@ -17,17 +17,18 @@
--dt-row-stripe: 0, 0, 0; --dt-row-stripe: 0, 0, 0;
--dt-row-hover: 0, 0, 0; --dt-row-hover: 0, 0, 0;
--dt-column-ordering: 0, 0, 0; --dt-column-ordering: 0, 0, 0;
--dt-header-align-items: center;
--dt-html-background: white; --dt-html-background: white;
} }
:root.dark { :root.dark {
--dt-html-background: rgb(33, 37, 41); --dt-html-background: rgb(33, 37, 41);
} }
table.dataTable td.dt-control { table.dataTable tbody td.dt-control {
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
} }
table.dataTable td.dt-control:before { table.dataTable tbody td.dt-control:before {
display: inline-block; display: inline-block;
box-sizing: border-box; box-sizing: border-box;
content: ""; content: "";
@@ -36,7 +37,7 @@ table.dataTable td.dt-control:before {
border-bottom: 5px solid transparent; border-bottom: 5px solid transparent;
border-right: 0px solid transparent; border-right: 0px solid transparent;
} }
table.dataTable tr.dt-hasChild td.dt-control:before { table.dataTable tbody tr.dt-hasChild td.dt-control:before {
border-top: 10px solid rgba(0, 0, 0, 0.5); border-top: 10px solid rgba(0, 0, 0, 0.5);
border-left: 5px solid transparent; border-left: 5px solid transparent;
border-bottom: 0px solid transparent; border-bottom: 0px solid transparent;
@@ -104,24 +105,14 @@ table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after {
content: "\25BC"; content: "\25BC";
content: "\25BC"/""; content: "\25BC"/"";
} }
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;
padding-right: 30px;
}
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 > 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-asc span.dt-column-order,
table.dataTable thead > tr > td.dt-orderable-desc 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-asc span.dt-column-order,
table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order { table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order {
position: absolute; position: relative;
right: 12px;
top: 0;
bottom: 0;
width: 12px; width: 12px;
height: 20px;
} }
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 > 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:before,
@@ -163,6 +154,40 @@ table.dataTable thead > tr > td:active {
outline: none; 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 span.dt-column-title,
table.dataTable thead > tr > th div.dt-column-footer span.dt-column-title,
table.dataTable thead > tr > td div.dt-column-header span.dt-column-title,
table.dataTable thead > tr > td div.dt-column-footer span.dt-column-title,
table.dataTable tfoot > tr > th div.dt-column-header span.dt-column-title,
table.dataTable tfoot > tr > th div.dt-column-footer span.dt-column-title,
table.dataTable tfoot > tr > td div.dt-column-header span.dt-column-title,
table.dataTable tfoot > tr > td div.dt-column-footer span.dt-column-title {
flex-grow: 1;
}
table.dataTable thead > tr > th div.dt-column-header span.dt-column-title:empty,
table.dataTable thead > tr > th div.dt-column-footer span.dt-column-title:empty,
table.dataTable thead > tr > td div.dt-column-header span.dt-column-title:empty,
table.dataTable thead > tr > td div.dt-column-footer span.dt-column-title:empty,
table.dataTable tfoot > tr > th div.dt-column-header span.dt-column-title:empty,
table.dataTable tfoot > tr > th div.dt-column-footer span.dt-column-title:empty,
table.dataTable tfoot > tr > td div.dt-column-header span.dt-column-title:empty,
table.dataTable tfoot > tr > td div.dt-column-footer span.dt-column-title:empty {
display: none;
}
div.dt-scroll-body > table.dataTable > thead > tr > th, div.dt-scroll-body > table.dataTable > thead > tr > th,
div.dt-scroll-body > table.dataTable > thead > tr > td { div.dt-scroll-body > table.dataTable > thead > tr > td {
overflow: hidden; overflow: hidden;
@@ -258,10 +283,25 @@ table.dataTable td.dt-type-numeric,
table.dataTable td.dt-type-date { table.dataTable td.dt-type-date {
text-align: right; 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 th.dt-left,
table.dataTable td.dt-left { table.dataTable td.dt-left {
text-align: 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 th.dt-center,
table.dataTable td.dt-center { table.dataTable td.dt-center {
text-align: center; text-align: center;
@@ -270,10 +310,22 @@ table.dataTable th.dt-right,
table.dataTable td.dt-right { table.dataTable td.dt-right {
text-align: 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 th.dt-justify,
table.dataTable td.dt-justify { table.dataTable td.dt-justify {
text-align: 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 th.dt-nowrap,
table.dataTable td.dt-nowrap { table.dataTable td.dt-nowrap {
white-space: nowrap; white-space: nowrap;
@@ -295,6 +347,16 @@ table.dataTable tfoot th.dt-head-left,
table.dataTable tfoot td.dt-head-left { table.dataTable tfoot td.dt-head-left {
text-align: 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 th.dt-head-center,
table.dataTable thead td.dt-head-center, table.dataTable thead td.dt-head-center,
table.dataTable tfoot th.dt-head-center, table.dataTable tfoot th.dt-head-center,
@@ -307,12 +369,32 @@ table.dataTable tfoot th.dt-head-right,
table.dataTable tfoot td.dt-head-right { table.dataTable tfoot td.dt-head-right {
text-align: 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 th.dt-head-justify,
table.dataTable thead td.dt-head-justify, table.dataTable thead td.dt-head-justify,
table.dataTable tfoot th.dt-head-justify, table.dataTable tfoot th.dt-head-justify,
table.dataTable tfoot td.dt-head-justify { table.dataTable tfoot td.dt-head-justify {
text-align: 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 th.dt-head-nowrap,
table.dataTable thead td.dt-head-nowrap, table.dataTable thead td.dt-head-nowrap,
table.dataTable tfoot th.dt-head-nowrap, table.dataTable tfoot th.dt-head-nowrap,
@@ -340,6 +422,10 @@ table.dataTable tbody td.dt-body-nowrap {
white-space: nowrap; white-space: nowrap;
} }
:root {
--dt-header-align-items: flex-end;
}
/*! Bootstrap 5 integration for DataTables /*! Bootstrap 5 integration for DataTables
* *
* ©2020 SpryMedia Ltd, all rights reserved. * ©2020 SpryMedia Ltd, all rights reserved.
@@ -410,6 +496,9 @@ div.dt-container div.dt-layout-table > div {
margin-left: 0; margin-left: 0;
} }
} }
div.dt-container {
position: relative;
}
div.dt-container div.dt-length label { div.dt-container div.dt-length label {
font-weight: normal; font-weight: normal;
text-align: left; text-align: left;
@@ -498,14 +587,19 @@ 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-orderable-desc,
table.dataTable.table-sm > thead > tr td.dt-ordering-asc, table.dataTable.table-sm > thead > tr td.dt-ordering-asc,
table.dataTable.table-sm > thead > tr td.dt-ordering-desc { table.dataTable.table-sm > thead > tr td.dt-ordering-desc {
padding-right: 20px; padding-right: 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 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-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-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-asc span.dt-column-order,
table.dataTable.table-sm > thead > tr td.dt-ordering-desc span.dt-column-order { table.dataTable.table-sm > thead > tr td.dt-ordering-desc span.dt-column-order {
right: 5px; right: 0.25rem;
}
table.dataTable.table-sm > thead > tr th.dt-type-date span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-type-numeric span.dt-column-order,
table.dataTable.table-sm > thead > tr td.dt-type-date span.dt-column-order,
table.dataTable.table-sm > thead > tr td.dt-type-numeric span.dt-column-order {
left: 0.25rem;
} }
div.dt-scroll-head table.table-bordered { div.dt-scroll-head table.table-bordered {
+340 -110
View File
@@ -4,13 +4,13 @@
* *
* To rebuild or modify this file with the latest versions of the included * To rebuild or modify this file with the latest versions of the included
* software please visit: * software please visit:
* https://datatables.net/download/#bs5/dt-2.2.2 * https://datatables.net/download/#bs5/dt-2.3.2
* *
* Included libraries: * Included libraries:
* DataTables 2.2.2 * DataTables 2.3.2
*/ */
/*! DataTables 2.2.2 /*! DataTables 2.3.2
* © SpryMedia Ltd - datatables.net/license * © SpryMedia Ltd - datatables.net/license
*/ */
@@ -101,15 +101,19 @@
var defaults = DataTable.defaults; var defaults = DataTable.defaults;
var $this = $(this); var $this = $(this);
// Sanity check
/* Sanity check */
if ( this.nodeName.toLowerCase() != 'table' ) if ( this.nodeName.toLowerCase() != 'table' )
{ {
_fnLog( null, 0, 'Non-table node initialisation ('+this.nodeName+')', 2 ); _fnLog( null, 0, 'Non-table node initialisation ('+this.nodeName+')', 2 );
return; return;
} }
$(this).trigger( 'options.dt', oInit ); // Special case for options
if (oInit.on && oInit.on.options) {
_fnListener($this, 'options', oInit.on.options);
}
$this.trigger( 'options.dt', oInit );
/* Backwards compatibility for the defaults */ /* Backwards compatibility for the defaults */
_fnCompatOpts( defaults ); _fnCompatOpts( defaults );
@@ -120,7 +124,7 @@
_fnCamelToHungarian( defaults.column, defaults.column, true ); _fnCamelToHungarian( defaults.column, defaults.column, true );
/* Setting up the initialisation object */ /* Setting up the initialisation object */
_fnCamelToHungarian( defaults, $.extend( oInit, $this.data() ), true ); _fnCamelToHungarian( defaults, $.extend( oInit, _fnEscapeObject($this.data()) ), true );
@@ -248,6 +252,9 @@
"caption", "caption",
"layout", "layout",
"orderDescReverse", "orderDescReverse",
"orderIndicators",
"orderHandler",
"titleRow",
"typeDetect", "typeDetect",
[ "iCookieDuration", "iStateDuration" ], // backwards compat [ "iCookieDuration", "iStateDuration" ], // backwards compat
[ "oSearch", "oPreviousSearch" ], [ "oSearch", "oPreviousSearch" ],
@@ -276,6 +283,13 @@
oSettings.rowIdFn = _fnGetObjectDataFn( oInit.rowId ); 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 */ /* Browser support detection */
_fnBrowserDetect( oSettings ); _fnBrowserDetect( oSettings );
@@ -336,7 +350,7 @@
/* HTML5 attribute detection - build an mData object automatically if the /* HTML5 attribute detection - build an mData object automatically if the
* attributes are found * attributes are found
*/ */
var rowOne = $this.children('tbody').find('tr').eq(0); var rowOne = $this.children('tbody').find('tr:first-child').eq(0);
if ( rowOne.length ) { if ( rowOne.length ) {
var a = function ( cell, name ) { var a = function ( cell, name ) {
@@ -494,6 +508,13 @@
* @namespace * @namespace
*/ */
DataTable.ext = _ext = { DataTable.ext = _ext = {
/**
* DataTables build type (expanded by the download builder)
*
* @type string
*/
builder: "bs5/dt-2.3.2",
/** /**
* Buttons. For use with the Buttons extension for DataTables. This is * Buttons. For use with the Buttons extension for DataTables. This is
* defined here so other extensions can define buttons regardless of load * defined here so other extensions can define buttons regardless of load
@@ -505,6 +526,14 @@
buttons: {}, buttons: {},
/**
* ColumnControl buttons and content
*
* @type object
*/
ccContent: {},
/** /**
* Element class names * Element class names
* *
@@ -514,14 +543,6 @@
classes: {}, classes: {},
/**
* DataTables build type (expanded by the download builder)
*
* @type string
*/
builder: "bs5/dt-2.2.2",
/** /**
* Error reporting. * Error reporting.
* *
@@ -533,6 +554,11 @@
*/ */
errMode: "alert", 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 * Legacy so v1 plug-ins don't throw js errors on load
@@ -1887,6 +1913,26 @@
init.scrollX = init.scrollX ? '100%' : ''; init.scrollX = init.scrollX ? '100%' : '';
} }
// Objects for ordering
if ( typeof init.bSort === 'object' ) {
init.orderIndicators = init.bSort.indicators !== undefined ? init.bSort.indicators : true;
init.orderHandler = init.bSort.handler !== undefined ? init.bSort.handler : true;
init.bSort = true;
}
else if (init.bSort === false) {
init.orderIndicators = false;
init.orderHandler = false;
}
else if (init.bSort === true) {
init.orderIndicators = true;
init.orderHandler = true;
}
// Which cells are the title cells?
if (typeof init.bSortCellsTop === 'boolean') {
init.titleRow = init.bSortCellsTop;
}
// Column search objects are in an array, so it needs to be converted // Column search objects are in an array, so it needs to be converted
// element by element // element by element
var searchCols = init.aoSearchCols; var searchCols = init.aoSearchCols;
@@ -3264,7 +3310,7 @@
* @param {*} settings DataTables settings * @param {*} settings DataTables settings
* @param {*} source Source layout array * @param {*} source Source layout array
* @param {*} incColumns What columns should be included * @param {*} incColumns What columns should be included
* @returns Layout array * @returns Layout array in column index order
*/ */
function _fnHeaderLayout( settings, source, incColumns ) function _fnHeaderLayout( settings, source, incColumns )
{ {
@@ -3548,7 +3594,9 @@
_fnDraw( settings ); _fnDraw( settings );
settings._drawHold = false; settings.api.one('draw', function () {
settings._drawHold = false;
});
} }
@@ -3560,10 +3608,9 @@
var zero = oLang.sZeroRecords; var zero = oLang.sZeroRecords;
var dataSrc = _fnDataSource( settings ); var dataSrc = _fnDataSource( settings );
if ( // Make use of the fact that settings.json is only set once the initial data has
(settings.iDraw < 1 && dataSrc === 'ssp') || // been loaded. Show loading when that isn't the case
(settings.iDraw <= 1 && dataSrc === 'ajax') if ((dataSrc === 'ssp' || dataSrc === 'ajax') && ! settings.json) {
) {
zero = oLang.sLoadingRecords; zero = oLang.sLoadingRecords;
} }
else if ( oLang.sEmptyTable && settings.fnRecordsTotal() === 0 ) else if ( oLang.sEmptyTable && settings.fnRecordsTotal() === 0 )
@@ -3933,6 +3980,7 @@
var rows = $(thead).children('tr'); var rows = $(thead).children('tr');
var row, cell; var row, cell;
var i, k, l, iLen, shifted, column, colspan, rowspan; var i, k, l, iLen, shifted, column, colspan, rowspan;
var titleRow = settings.titleRow;
var isHeader = thead && thead.nodeName.toLowerCase() === 'thead'; var isHeader = thead && thead.nodeName.toLowerCase() === 'thead';
var layout = []; var layout = [];
var unique; var unique;
@@ -3961,6 +4009,7 @@
cell.nodeName.toUpperCase() == 'TH' cell.nodeName.toUpperCase() == 'TH'
) { ) {
var cols = []; var cols = [];
var jqCell = $(cell);
// Get the col and rowspan attributes from the DOM and sanitise them // Get the col and rowspan attributes from the DOM and sanitise them
colspan = cell.getAttribute('colspan') * 1; colspan = cell.getAttribute('colspan') * 1;
@@ -3981,7 +4030,7 @@
if ( write ) { if ( write ) {
if (unique) { if (unique) {
// Allow column options to be set from HTML attributes // Allow column options to be set from HTML attributes
_fnColumnOptions( settings, shifted, $(cell).data() ); _fnColumnOptions( settings, shifted, _fnEscapeObject(jqCell.data()) );
// Get the width for the column. This can be defined from the // Get the width for the column. This can be defined from the
// width attribute, style attribute or `columns.width` option // width attribute, style attribute or `columns.width` option
@@ -3998,7 +4047,14 @@
// Column title handling - can be user set, or read from the DOM // Column title handling - can be user set, or read from the DOM
// This happens before the render, so the original is still in place // This happens before the render, so the original is still in place
if ( columnDef.sTitle !== null && ! columnDef.autoTitle ) { if ( columnDef.sTitle !== null && ! columnDef.autoTitle ) {
cell.innerHTML = columnDef.sTitle; if (
(titleRow === true && i === 0) || // top row
(titleRow === false && i === rows.length -1) || // bottom row
(titleRow === i) || // specific row
(titleRow === null)
) {
cell.innerHTML = columnDef.sTitle;
}
} }
if (! columnDef.sTitle && unique) { if (! columnDef.sTitle && unique) {
@@ -4016,12 +4072,12 @@
// Fall back to the aria-label attribute on the table header if no ariaTitle is // Fall back to the aria-label attribute on the table header if no ariaTitle is
// provided. // provided.
if (! columnDef.ariaTitle) { if (! columnDef.ariaTitle) {
columnDef.ariaTitle = $(cell).attr("aria-label") || columnDef.sTitle; columnDef.ariaTitle = jqCell.attr("aria-label") || columnDef.sTitle;
} }
// Column specific class names // Column specific class names
if ( columnDef.className ) { if ( columnDef.className ) {
$(cell).addClass( columnDef.className ); jqCell.addClass( columnDef.className );
} }
} }
@@ -4033,11 +4089,28 @@
.appendTo(cell); .appendTo(cell);
} }
if ( isHeader && $('span.dt-column-order', cell).length === 0) { if (
settings.orderIndicators &&
isHeader &&
jqCell.filter(':not([data-dt-order=disable])').length !== 0 &&
jqCell.parent(':not([data-dt-order=disable])').length !== 0 &&
$('span.dt-column-order', cell).length === 0
) {
$('<span>') $('<span>')
.addClass('dt-column-order') .addClass('dt-column-order')
.appendTo(cell); .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 ( $('span.dt-column-' + headerFooter, cell).length === 0) {
$('<div>')
.addClass('dt-column-' + headerFooter)
.append(cell.childNodes)
.appendTo(cell);
}
} }
// If there is col / rowspan, copy the information into the layout grid // If there is col / rowspan, copy the information into the layout grid
@@ -4188,6 +4261,11 @@
// Allow plug-ins and external processes to modify the 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 ( typeof ajax === 'function' ) if ( typeof ajax === 'function' )
{ {
// Is a function - let the caller define what needs to be done // Is a function - let the caller define what needs to be done
@@ -4198,7 +4276,7 @@
// to the object for the callback. // to the object for the callback.
var empty = {}; var empty = {};
DataTable.util.set(ajax.dataSrc)(empty, []); _fnAjaxDataSrc(oSettings, empty, []);
callback(empty); callback(empty);
} }
else { else {
@@ -5688,24 +5766,30 @@
function _fnSortInit( settings ) { function _fnSortInit( settings ) {
var target = settings.nTHead; var target = settings.nTHead;
var headerRows = target.querySelectorAll('tr'); var headerRows = target.querySelectorAll('tr');
var legacyTop = settings.bSortCellsTop; var titleRow = settings.titleRow;
var notSelector = ':not([data-dt-order="disable"]):not([data-dt-order="icon-only"])'; var notSelector = ':not([data-dt-order="disable"]):not([data-dt-order="icon-only"])';
// Legacy support for `orderCellsTop` // Legacy support for `orderCellsTop`
if (legacyTop === true) { if (titleRow === true) {
target = headerRows[0]; target = headerRows[0];
} }
else if (legacyTop === false) { else if (titleRow === false) {
target = headerRows[ headerRows.length - 1 ]; target = headerRows[ headerRows.length - 1 ];
} }
else if (titleRow !== null) {
target = headerRows[titleRow];
}
// else - all rows
_fnSortAttachListener( if (settings.orderHandler) {
settings, _fnSortAttachListener(
target, settings,
target === settings.nTHead target,
? 'tr'+notSelector+' th'+notSelector+', tr'+notSelector+' td'+notSelector target === settings.nTHead
: 'th'+notSelector+', td'+notSelector ? 'tr'+notSelector+' th'+notSelector+', tr'+notSelector+' td'+notSelector
); : 'th'+notSelector+', td'+notSelector
);
}
// Need to resolve the user input array into our internal structure // Need to resolve the user input array into our internal structure
var order = []; var order = [];
@@ -5720,7 +5804,11 @@
var run = false; var run = false;
var columns = column === undefined var columns = column === undefined
? _fnColumnsFromHeader( e.target ) ? _fnColumnsFromHeader( e.target )
: [column]; : typeof column === 'function'
? column()
: Array.isArray(column)
? column
: [column];
if ( columns.length ) { if ( columns.length ) {
for ( var i=0, ien=columns.length ; i<ien ; i++ ) { for ( var i=0, ien=columns.length ; i<ien ; i++ ) {
@@ -6343,16 +6431,19 @@
// A column name was stored and should be used for restore // A column name was stored and should be used for restore
if (typeof col[0] === 'string') { if (typeof col[0] === 'string') {
// Find the name from the current list of column names
var idx = currentNames.indexOf(col[0]); var idx = currentNames.indexOf(col[0]);
// Find the name from the current list of column names, or fallback to index 0 if (idx < 0) {
set[0] = idx >= 0 // If the column was not found ignore it and continue
? idx return;
: 0; }
set[0] = idx;
} }
else if (set[0] >= columns.length) { else if (set[0] >= columns.length) {
// If a column name, but it is out of bounds, set to 0 // If the column index is out of bounds ignore it and continue
set[0] = 0; return;
} }
settings.aaSorting.push(set); settings.aaSorting.push(set);
@@ -6765,6 +6856,36 @@
} }
} }
/**
* Add one or more listeners to the table
*
* @param {*} that JQ for the table
* @param {*} name Event name
* @param {*} src Listener(s)
*/
function _fnListener(that, name, src) {
if (!Array.isArray(src)) {
src = [src];
}
for (i=0 ; i<src.length ; i++) {
that.on(name + '.dt', src[i]);
}
}
/**
* Escape HTML entities in strings, in an object
*/
function _fnEscapeObject(obj) {
if (DataTable.ext.escape.attributes) {
$.each(obj, function (key, val) {
obj[key] = _escapeHtml(val);
})
}
return obj;
}
/** /**
@@ -7421,12 +7542,24 @@
['footer', 'aoFooter'], ['footer', 'aoFooter'],
].forEach(function (item) { ].forEach(function (item) {
_api_register( 'table().' + item[0] + '.structure()' , function (selector) { _api_register( 'table().' + item[0] + '.structure()' , function (selector) {
var indexes = this.columns(selector).indexes().flatten(); var indexes = this.columns(selector).indexes().flatten().toArray();
var ctx = this.context[0]; var ctx = this.context[0];
var structure = _fnHeaderLayout(ctx, ctx[item[1]], indexes);
return _fnHeaderLayout(ctx, ctx[item[1]], indexes);
} ); // The structure is in column index order - but from this method we want the return to be
}) // in the columns() selector API order. In order to do that we need to map from one form
// to the other
var orderedIndexes = indexes.slice().sort(function (a, b) {
return a - b;
});
return structure.map(function (row) {
return indexes.map(function (colIdx) {
return row[orderedIndexes.indexOf(colIdx)];
});
});
});
});
_api_registerPlural( 'tables().containers()', 'table().container()' , function () { _api_registerPlural( 'tables().containers()', 'table().container()' , function () {
@@ -7775,7 +7908,7 @@
{ {
var var
out = [], res, out = [], res,
a, i, ien, j, jen, i, ien,
selectorType = typeof selector; selectorType = typeof selector;
// Can't just check for isArray here, as an API or jQuery instance might be // Can't just check for isArray here, as an API or jQuery instance might be
@@ -7785,22 +7918,15 @@
} }
for ( i=0, ien=selector.length ; i<ien ; i++ ) { for ( i=0, ien=selector.length ; i<ien ; i++ ) {
// Only split on simple strings - complex expressions will be jQuery selectors res = selectFn( typeof selector[i] === 'string' ? selector[i].trim() : selector[i] );
a = selector[i] && selector[i].split && ! selector[i].match(/[[(:]/) ?
selector[i].split(',') :
[ selector[i] ];
for ( j=0, jen=a.length ; j<jen ; j++ ) { // Remove empty items
res = selectFn( typeof a[j] === 'string' ? (a[j]).trim() : a[j] ); res = res.filter( function (item) {
return item !== null && item !== undefined;
});
// Remove empty items if ( res && res.length ) {
res = res.filter( function (item) { out = out.concat( res );
return item !== null && item !== undefined;
});
if ( res && res.length ) {
out = out.concat( res );
}
} }
} }
@@ -7829,6 +7955,7 @@
} }
return $.extend( { return $.extend( {
columnOrder: 'implied',
search: 'none', search: 'none',
order: 'current', order: 'current',
page: 'all' page: 'all'
@@ -8590,23 +8717,60 @@
var __column_header = function ( settings, column, row ) { var __column_header = function ( settings, column, row ) {
var header = settings.aoHeader; var header = settings.aoHeader;
var target = row !== undefined var titleRow = settings.titleRow;
? row var target = null;
: settings.bSortCellsTop // legacy support
? 0 if (row !== undefined) {
: header.length - 1; target = row;
}
else if (titleRow === true) { // legacy orderCellsTop support
target = 0;
}
else if (titleRow === false) {
target = header.length - 1;
}
else if (titleRow !== null) {
target = titleRow;
}
else {
// Automatic - find the _last_ unique cell from the top that is not empty (last for
// backwards compatibility)
for (var i=0 ; i<header.length ; i++) {
if (header[i][column].unique && $('span.dt-column-title', header[i][column].cell).text()) {
target = i;
}
}
if (target === null) {
target = 0;
}
}
return header[target][column].cell; return header[target][column].cell;
}; };
var __column_header_cells = function (header) {
var out = [];
for (var i=0 ; i<header.length ; i++) {
for (var j=0 ; j<header[i].length ; j++) {
var cell = header[i][j].cell;
if (!out.includes(cell)) {
out.push(cell);
}
}
}
return out;
}
var __column_selector = function ( settings, selector, opts ) var __column_selector = function ( settings, selector, opts )
{ {
var var
columns = settings.aoColumns, columns = settings.aoColumns,
names = _pluck( columns, 'sName' ), names, titles,
titles = _pluck( columns, 'sTitle' ), nodes = __column_header_cells(settings.aoHeader);
cells = DataTable.util.get('[].[].cell')(settings.aoHeader),
nodes = _unique( _flatten([], cells) );
var run = function ( s ) { var run = function ( s ) {
var selInt = _intVal( s ); var selInt = _intVal( s );
@@ -8678,12 +8842,21 @@
} ); } );
case 'name': 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 // match by name. `names` is column index complete and in order
return names.map( function (name, i) { return names.map( function (name, i) {
return name === match[1] ? i : null; return name === match[1] ? i : null;
} ); } );
case 'title': case 'title':
if (!titles) {
titles = _pluck( columns, 'sTitle' );
}
// match by column title // match by column title
return titles.map( function (title, i) { return titles.map( function (title, i) {
return title === match[1] ? i : null; return title === match[1] ? i : null;
@@ -8722,7 +8895,11 @@
[]; [];
}; };
return _selector_run( 'column', selector, run, settings, opts ); 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
}; };
@@ -8846,6 +9023,12 @@
}, 1 ); }, 1 );
} ); } );
_api_registerPlural( 'columns().names()', 'column().name()', function () {
return this.iterator( 'column', function ( settings, column ) {
return settings.aoColumns[column].sName;
}, 1 );
} );
_api_registerPlural( 'columns().nodes()', 'column().nodes()', function () { _api_registerPlural( 'columns().nodes()', 'column().nodes()', function () {
return this.iterator( 'column-rows', function ( settings, column, i, j, rows ) { return this.iterator( 'column-rows', function ( settings, column, i, j, rows ) {
return _pluck_order( settings.aoData, rows, 'anCells', column ) ; return _pluck_order( settings.aoData, rows, 'anCells', column ) ;
@@ -9272,7 +9455,10 @@
// otherwise a 2D array was passed in // otherwise a 2D array was passed in
return this.iterator( 'table', function ( settings ) { return this.iterator( 'table', function ( settings ) {
settings.aaSorting = Array.isArray(order) ? order.slice() : order; var resolved = [];
_fnSortResolve(settings, resolved, order);
settings.aaSorting = resolved;
} ); } );
} ); } );
@@ -9398,7 +9584,7 @@
var fixed = settings.searchFixed; var fixed = settings.searchFixed;
if (! name) { if (! name) {
return Object.keys(fixed) return Object.keys(fixed);
} }
else if (search === undefined) { else if (search === undefined) {
return fixed[name]; return fixed[name];
@@ -9465,10 +9651,10 @@
var fixed = settings.aoColumns[colIdx].searchFixed; var fixed = settings.aoColumns[colIdx].searchFixed;
if (! name) { if (! name) {
return Object.keys(fixed) return Object.keys(fixed);
} }
else if (search === undefined) { else if (search === undefined) {
return fixed[name]; return fixed[name] || null;
} }
else if (search === null) { else if (search === null) {
delete fixed[name]; delete fixed[name];
@@ -9920,14 +10106,9 @@
jqTable.append( tfoot ); jqTable.append( tfoot );
} }
// Clean up the header // Clean up the header / footer
$(thead).find('span.dt-column-order').remove(); cleanHeader(thead, 'header');
$(thead).find('span.dt-column-title').each(function () { cleanHeader(tfoot, 'footer');
var title = $(this).html();
$(this).parent().append(title);
$(this).remove();
});
settings.colgroup.remove(); settings.colgroup.remove();
settings.aaSorting = []; settings.aaSorting = [];
@@ -9949,7 +10130,6 @@
orderClasses.isDesc orderClasses.isDesc
) )
.css('width', '') .css('width', '')
.removeAttr('data-dt-column')
.removeAttr('aria-sort'); .removeAttr('aria-sort');
// Add the TR elements back into the table in their original order // Add the TR elements back into the table in their original order
@@ -10030,6 +10210,19 @@
: resolved; : resolved;
} ); } );
// Needed for header and footer, so pulled into its own function
function cleanHeader(node, className) {
$(node).find('span.dt-column-order').remove();
$(node).find('span.dt-column-title').each(function () {
var title = $(this).html();
$(this).parent().parent().append(title);
$(this).remove();
});
$(node).find('div.dt-column-' + className).remove();
$('th, td', node).removeAttr('data-dt-column');
}
/** /**
* Version string for plug-ins to check compatibility. Allowed format is * Version string for plug-ins to check compatibility. Allowed format is
* `a.b.c-d` where: a:int, b:int, c:int, d:string(dev|beta|alpha). `d` is used * `a.b.c-d` where: a:int, b:int, c:int, d:string(dev|beta|alpha). `d` is used
@@ -10038,7 +10231,7 @@
* @type string * @type string
* @default Version number * @default Version number
*/ */
DataTable.version = "2.2.2"; DataTable.version = "2.3.2";
/** /**
* Private data store, containing all of the settings objects that are * Private data store, containing all of the settings objects that are
@@ -10645,6 +10838,10 @@
"bSortCellsTop": null, "bSortCellsTop": null,
/** Specify which row is the title row in the header. Replacement for bSortCellsTop */
titleRow: null,
/** /**
* Enable or disable the addition of the classes `sorting\_1`, `sorting\_2` and * Enable or disable the addition of the classes `sorting\_1`, `sorting\_2` and
* `sorting\_3` to the columns which are currently being sorted on. This is * `sorting\_3` to the columns which are currently being sorted on. This is
@@ -10922,6 +11119,13 @@
1: "entry" 1: "entry"
}, },
/**
* Page length options
*/
lengthLabels: {
'-1': 'All'
},
/** /**
* This string is shown in preference to `zeroRecords` when the table is * This string is shown in preference to `zeroRecords` when the table is
* empty of data (regardless of filtering). Note that this is an optional * empty of data (regardless of filtering). Note that this is an optional
@@ -11192,7 +11396,10 @@
/** /**
* For server-side processing - use the data from the DOM for the first draw * For server-side processing - use the data from the DOM for the first draw
*/ */
iDeferLoading: null iDeferLoading: null,
/** Event listeners */
on: null
}; };
_fnHungarianMap( DataTable.defaults ); _fnHungarianMap( DataTable.defaults );
@@ -12019,10 +12226,7 @@
/** /**
* Indicate that if multiple rows are in the header and there is more than * Indicate that if multiple rows are in the header and there is more than
* one unique cell per column, if the top one (true) or bottom one (false) * one unique cell per column. Replaced by titleRow
* should be used for sorting / title by DataTables.
* Note that this parameter will be set by the initialisation routine. To
* set a default use {@link DataTable.defaults}.
*/ */
"bSortCellsTop": null, "bSortCellsTop": null,
@@ -12147,7 +12351,19 @@
resizeObserver: null, resizeObserver: null,
/** Keep a record of the last size of the container, so we can skip duplicates */ /** Keep a record of the last size of the container, so we can skip duplicates */
containerWidth: -1 containerWidth: -1,
/** Reverse the initial order of the data set on desc ordering */
orderDescReverse: null,
/** Show / hide ordering indicators in headers */
orderIndicators: true,
/** Default ordering listener */
orderHandler: true,
/** Title row indicator */
titleRow: null
}; };
/** /**
@@ -12977,7 +13193,7 @@
cell.addClass(classes.order.none); cell.addClass(classes.order.none);
} }
var legacyTop = settings.bSortCellsTop; var titleRow = settings.titleRow;
var headerRows = cell.closest('thead').find('tr'); var headerRows = cell.closest('thead').find('tr');
var rowIdx = cell.parent().index(); var rowIdx = cell.parent().index();
@@ -12987,11 +13203,10 @@
cell.attr('data-dt-order') === 'disable' || cell.attr('data-dt-order') === 'disable' ||
cell.parent().attr('data-dt-order') === 'disable' || cell.parent().attr('data-dt-order') === 'disable' ||
// Legacy support for `orderCellsTop`. If it is set, then cells // titleRow support, for defining a specific row in the header
// which are not in the top or bottom row of the header (depending (titleRow === true && rowIdx !== 0) ||
// on the value) do not get the sorting classes applied to them (titleRow === false && rowIdx !== headerRows.length - 1) ||
(legacyTop === true && rowIdx !== 0) || (typeof titleRow === 'number' && rowIdx !== titleRow)
(legacyTop === false && rowIdx !== headerRows.length - 1)
) { ) {
return; return;
} }
@@ -13001,7 +13216,7 @@
// `DT` namespace will allow the event to be removed automatically // `DT` namespace will allow the event to be removed automatically
// on destroy, while the `dt` namespaced event is the one we are // on destroy, while the `dt` namespaced event is the one we are
// listening for // listening for
$(settings.nTable).on( 'order.dt.DT column-visibility.dt.DT', function ( e, ctx ) { $(settings.nTable).on( 'order.dt.DT column-visibility.dt.DT', function ( e, ctx, column ) {
if ( settings !== ctx ) { // need to check this this is the host if ( settings !== ctx ) { // need to check this this is the host
return; // table, not a nested one return; // table, not a nested one
} }
@@ -13012,6 +13227,16 @@
return; return;
} }
var orderedColumns = _pluck(sorting, 'col');
// This handler is only needed on column visibility if the column is part of the
// ordering. If it isn't, then we can bail out to save performance. It could be a
// separate event handler, but this is a balance between code reuse / size and performance
// console.log(e, e.name, column, orderedColumns, orderedColumns.includes(column))
if (e.type === 'column-visibility' && ! orderedColumns.includes(column)) {
return;
}
var i; var i;
var orderClasses = classes.order; var orderClasses = classes.order;
var columns = ctx.api.columns( cell ); var columns = ctx.api.columns( cell );
@@ -13020,8 +13245,8 @@
var ariaType = ''; var ariaType = '';
var indexes = columns.indexes(); var indexes = columns.indexes();
var sortDirs = columns.orderable(true).flatten(); var sortDirs = columns.orderable(true).flatten();
var orderedColumns = _pluck(sorting, 'col');
var tabIndex = settings.iTabIndex; var tabIndex = settings.iTabIndex;
var canOrder = ctx.orderHandler && orderable;
cell cell
.removeClass( .removeClass(
@@ -13029,8 +13254,8 @@
orderClasses.isDesc orderClasses.isDesc
) )
.toggleClass( orderClasses.none, ! orderable ) .toggleClass( orderClasses.none, ! orderable )
.toggleClass( orderClasses.canAsc, orderable && sortDirs.includes('asc') ) .toggleClass( orderClasses.canAsc, canOrder && sortDirs.includes('asc') )
.toggleClass( orderClasses.canDesc, orderable && sortDirs.includes('desc') ); .toggleClass( orderClasses.canDesc, canOrder && sortDirs.includes('desc') );
// Determine if all of the columns that this cell covers are included in the // Determine if all of the columns that this cell covers are included in the
// current ordering // current ordering
@@ -13789,12 +14014,17 @@
} ); } );
for ( i=0 ; i<lengths.length ; i++ ) { for ( i=0 ; i<lengths.length ; i++ ) {
select[0][ i ] = new Option( // Attempt to look up the length from the i18n options
typeof language[i] === 'number' ? var label = settings.api.i18n('lengthLabels.' + lengths[i], null);
if (label === null) {
// If not present, fallback to old style
label = typeof language[i] === 'number' ?
settings.fnFormatNumber( language[i] ) : settings.fnFormatNumber( language[i] ) :
language[i], language[i];
lengths[i] }
);
select[0][ i ] = new Option(label, lengths[i]);
} }
// add for and id to label and input // add for and id to label and input
+36 -32
View File
@@ -7,35 +7,34 @@
<div class="col-md"> <div class="col-md">
<dl class="row"> <dl class="row">
<dt class="col-sm-5">Server Installed <dt class="col-sm-5">Server Installed
<span class="badge bg-success d-none" id="server-success" title="Latest version is installed.">Ok</span> <span class="badge bg-success d-none abbr-badge" id="server-success" title="Latest version is installed.">Ok</span>
<span class="badge bg-warning text-dark d-none" id="server-warning" title="There seems to be an update available.">Update</span> <span class="badge bg-warning text-dark d-none abbr-badge" id="server-warning" title="There seems to be an update available.">Update</span>
<span class="badge bg-info text-dark d-none" id="server-branch" title="This is a branched version.">Branched</span> <span class="badge bg-info text-dark d-none abbr-badge" id="server-branch" title="This is a branched version.">Branched</span>
</dt> </dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
<span id="server-installed">{{page_data.current_release}}</span> <span id="server-installed">{{page_data.current_release}}</span>
</dd> </dd>
<dt class="col-sm-5">Server Latest <dt class="col-sm-5">Server Latest
<span class="badge bg-secondary d-none" id="server-failed" title="Unable to determine latest version.">Unknown</span> <span class="badge bg-secondary d-none abbr-badge" id="server-failed" title="Unable to determine latest version.">Unknown</span>
</dt> </dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
<span id="server-latest">{{page_data.latest_release}}<span id="server-latest-commit" class="d-none">-{{page_data.latest_commit}}</span></span> <span id="server-latest">{{page_data.latest_release}}<span id="server-latest-commit" class="d-none">-{{page_data.latest_commit}}</span></span>
</dd> </dd>
{{#if page_data.web_vault_enabled}} {{#if page_data.web_vault_enabled}}
<dt class="col-sm-5">Web Installed <dt class="col-sm-5">Web Installed
<span class="badge bg-success d-none" id="web-success" title="Latest version is installed.">Ok</span> <span class="badge bg-success d-none abbr-badge" id="web-success" title="Latest version is installed.">Ok</span>
<span class="badge bg-warning text-dark d-none" id="web-warning" title="There seems to be an update available.">Update</span> <span class="badge bg-warning text-dark d-none abbr-badge" id="web-warning" title="There seems to be an update available.">Update</span>
<span class="badge bg-info text-dark d-none abbr-badge" id="web-prerelease" title="You seem to be using a pre-release version.">Pre-Release</span>
</dt> </dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
<span id="web-installed">{{page_data.web_vault_version}}</span> <span id="web-installed">{{page_data.web_vault_version}}</span>
</dd> </dd>
{{#unless page_data.running_within_container}}
<dt class="col-sm-5">Web Latest <dt class="col-sm-5">Web Latest
<span class="badge bg-secondary d-none" id="web-failed" title="Unable to determine latest version.">Unknown</span> <span class="badge bg-secondary d-none abbr-badge" id="web-failed" title="Unable to determine latest version.">Unknown</span>
</dt> </dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
<span id="web-latest">{{page_data.latest_web_build}}</span> <span id="web-latest">{{page_data.latest_web_build}}</span>
</dd> </dd>
{{/unless}}
{{/if}} {{/if}}
{{#unless page_data.web_vault_enabled}} {{#unless page_data.web_vault_enabled}}
<dt class="col-sm-5">Web Installed</dt> <dt class="col-sm-5">Web Installed</dt>
@@ -68,10 +67,11 @@
<span class="d-block"><b>No</b></span> <span class="d-block"><b>No</b></span>
{{/unless}} {{/unless}}
</dd> </dd>
<dt class="col-sm-5">Environment settings overridden</dt> <dt class="col-sm-5">Uses config.json</dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
{{#if page_data.overrides}} {{#if page_data.overrides}}
<span class="d-block" title="The following settings are overridden: {{page_data.overrides}}"><b>Yes</b></span> <span class="d-inline"><b>Yes</b></span>
<span class="badge bg-info text-dark abbr-badge" title="Environment variables are overwritten by a config.json.&#013;&#010;{{page_data.overrides}}">Details</span>
{{/if}} {{/if}}
{{#unless page_data.overrides}} {{#unless page_data.overrides}}
<span class="d-block"><b>No</b></span> <span class="d-block"><b>No</b></span>
@@ -90,10 +90,10 @@
{{#if page_data.ip_header_exists}} {{#if page_data.ip_header_exists}}
<dt class="col-sm-5">IP header <dt class="col-sm-5">IP header
{{#if page_data.ip_header_match}} {{#if page_data.ip_header_match}}
<span class="badge bg-success" title="IP_HEADER config seems to be valid.">Match</span> <span class="badge bg-success abbr-badge" title="IP_HEADER config seems to be valid.">Match</span>
{{/if}} {{/if}}
{{#unless page_data.ip_header_match}} {{#unless page_data.ip_header_match}}
<span class="badge bg-danger" title="IP_HEADER config seems to be invalid. IP's in the log could be invalid. Please fix.">No Match</span> <span class="badge bg-danger abbr-badge" title="IP_HEADER config seems to be invalid. IP's in the log could be invalid. Please fix.">No Match</span>
{{/unless}} {{/unless}}
</dt> </dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
@@ -109,10 +109,10 @@
{{!-- End if IP Header Exists --}} {{!-- End if IP Header Exists --}}
<dt class="col-sm-5">Internet access <dt class="col-sm-5">Internet access
{{#if page_data.has_http_access}} {{#if page_data.has_http_access}}
<span class="badge bg-success" title="We have internet access!">Ok</span> <span class="badge bg-success abbr-badge" title="We have internet access!">Ok</span>
{{/if}} {{/if}}
{{#unless page_data.has_http_access}} {{#unless page_data.has_http_access}}
<span class="badge bg-danger" title="There seems to be no internet access. Please fix.">Error</span> <span class="badge bg-danger abbr-badge" title="There seems to be no internet access. Please fix.">Error</span>
{{/unless}} {{/unless}}
</dt> </dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
@@ -134,8 +134,8 @@
</dd> </dd>
<dt class="col-sm-5">Websocket enabled <dt class="col-sm-5">Websocket enabled
{{#if page_data.enable_websocket}} {{#if page_data.enable_websocket}}
<span class="badge bg-success d-none" id="websocket-success" title="Websocket connection is working.">Ok</span> <span class="badge bg-success d-none abbr-badge" id="websocket-success" title="Websocket connection is working.">Ok</span>
<span class="badge bg-danger d-none" id="websocket-error" title="Websocket connection error, validate your reverse proxy configuration!">Error</span> <span class="badge bg-danger d-none abbr-badge" id="websocket-error" title="Websocket connection error, validate your reverse proxy configuration!">Error</span>
{{/if}} {{/if}}
</dt> </dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
@@ -148,23 +148,27 @@
</dd> </dd>
<dt class="col-sm-5">DNS (github.com) <dt class="col-sm-5">DNS (github.com)
<span class="badge bg-success d-none" id="dns-success" title="DNS Resolving works!">Ok</span> <span class="badge bg-success d-none abbr-badge" id="dns-success" title="DNS Resolving works!">Ok</span>
<span class="badge bg-danger d-none" id="dns-warning" title="DNS Resolving failed. Please fix.">Error</span> <span class="badge bg-danger d-none abbr-badge" id="dns-warning" title="DNS Resolving failed. Please fix.">Error</span>
</dt> </dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
<span id="dns-resolved">{{page_data.dns_resolved}}</span> <span id="dns-resolved">{{page_data.dns_resolved}}</span>
</dd> </dd>
<dt class="col-sm-5">Date & Time (Local)</dt> <dt class="col-sm-5">Date & Time (Local)
{{#if page_data.tz_env}}
<span class="badge bg-success abbr-badge" title="Configured TZ environment variable">{{page_data.tz_env}}</span>
{{/if}}
</dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
<span><b>Server:</b> {{page_data.server_time_local}}</span> <span><b>Server:</b> {{page_data.server_time_local}}</span>
</dd> </dd>
<dt class="col-sm-5">Date & Time (UTC) <dt class="col-sm-5">Date & Time (UTC)
<span class="badge bg-success d-none" id="time-success" title="Server and browser times are within 15 seconds of each other.">Server/Browser Ok</span> <span class="badge bg-success d-none abbr-badge" id="time-success" title="Server and browser times are within 15 seconds of each other.">Server/Browser Ok</span>
<span class="badge bg-danger d-none" id="time-warning" title="Server and browser times are more than 15 seconds apart.">Server/Browser Error</span> <span class="badge bg-danger d-none abbr-badge" id="time-warning" title="Server and browser times are more than 15 seconds apart.">Server/Browser Error</span>
<span class="badge bg-success d-none" id="ntp-server-success" title="Server and NTP times are within 15 seconds of each other.">Server NTP Ok</span> <span class="badge bg-success d-none abbr-badge" id="ntp-server-success" title="Server and NTP times are within 15 seconds of each other.">Server NTP Ok</span>
<span class="badge bg-danger d-none" id="ntp-server-warning" title="Server and NTP times are more than 15 seconds apart.">Server NTP Error</span> <span class="badge bg-danger d-none abbr-badge" id="ntp-server-warning" title="Server and NTP times are more than 15 seconds apart.">Server NTP Error</span>
<span class="badge bg-success d-none" id="ntp-browser-success" title="Browser and NTP times are within 15 seconds of each other.">Browser NTP Ok</span> <span class="badge bg-success d-none abbr-badge" id="ntp-browser-success" title="Browser and NTP times are within 15 seconds of each other.">Browser NTP Ok</span>
<span class="badge bg-danger d-none" id="ntp-browser-warning" title="Browser and NTP times are more than 15 seconds apart.">Browser NTP Error</span> <span class="badge bg-danger d-none abbr-badge" id="ntp-browser-warning" title="Browser and NTP times are more than 15 seconds apart.">Browser NTP Error</span>
</dt> </dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
<span id="ntp-time" class="d-block"><b>NTP:</b> <span id="ntp-server-string">{{page_data.ntp_time}}</span></span> <span id="ntp-time" class="d-block"><b>NTP:</b> <span id="ntp-server-string">{{page_data.ntp_time}}</span></span>
@@ -173,10 +177,10 @@
</dd> </dd>
<dt class="col-sm-5">Domain configuration <dt class="col-sm-5">Domain configuration
<span class="badge bg-success d-none" id="domain-success" title="The domain variable matches the browser location and seems to be configured correctly.">Match</span> <span class="badge bg-success d-none abbr-badge" id="domain-success" title="The domain variable matches the browser location and seems to be configured correctly.">Match</span>
<span class="badge bg-danger d-none" id="domain-warning" title="The domain variable does not match the browser location.&#013;&#010;The domain variable does not seem to be configured correctly.&#013;&#010;Some features may not work as expected!">No Match</span> <span class="badge bg-danger d-none abbr-badge" id="domain-warning" title="The domain variable does not match the browser location.&#013;&#010;The domain variable does not seem to be configured correctly.&#013;&#010;Some features may not work as expected!">No Match</span>
<span class="badge bg-success d-none" id="https-success" title="Configured to use HTTPS">HTTPS</span> <span class="badge bg-success d-none abbr-badge" id="https-success" title="Configured to use HTTPS">HTTPS</span>
<span class="badge bg-danger d-none" id="https-warning" title="Not configured to use HTTPS.&#013;&#010;Some features may not work as expected!">No HTTPS</span> <span class="badge bg-danger d-none abbr-badge" id="https-warning" title="Not configured to use HTTPS.&#013;&#010;Some features may not work as expected!">No HTTPS</span>
</dt> </dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
<span id="domain-server" class="d-block"><b>Server:</b> <span id="domain-server-string">{{page_data.admin_url}}</span></span> <span id="domain-server" class="d-block"><b>Server:</b> <span id="domain-server-string">{{page_data.admin_url}}</span></span>
@@ -184,8 +188,8 @@
</dd> </dd>
<dt class="col-sm-5">HTTP Response validation <dt class="col-sm-5">HTTP Response validation
<span class="badge bg-success d-none" id="http-response-success" title="All headers and HTTP request responses seem to be ok.">Ok</span> <span class="badge bg-success d-none abbr-badge" id="http-response-success" title="All headers and HTTP request responses seem to be ok.">Ok</span>
<span class="badge bg-danger d-none" id="http-response-warning" title="Some headers or HTTP request responses return invalid data!">Error</span> <span class="badge bg-danger d-none abbr-badge" id="http-response-warning" title="Some headers or HTTP request responses return invalid data!">Error</span>
</dt> </dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
<span id="http-response-errors" class="d-block"></span> <span id="http-response-errors" class="d-block"></span>
+1 -1
View File
@@ -43,7 +43,7 @@
<span class="d-block"><strong>Groups:</strong> {{group_count}}</span> <span class="d-block"><strong>Groups:</strong> {{group_count}}</span>
<span class="d-block"><strong>Events:</strong> {{event_count}}</span> <span class="d-block"><strong>Events:</strong> {{event_count}}</span>
</td> </td>
<td class="text-end px-0 small"> <td class="text-end px-1 small">
<button type="button" class="btn btn-sm btn-link p-0 border-0 float-right" vw-delete-organization data-vw-org-uuid="{{id}}" data-vw-org-name="{{name}}" data-vw-billing-email="{{billingEmail}}">Delete Organization</button><br> <button type="button" class="btn btn-sm btn-link p-0 border-0 float-right" vw-delete-organization data-vw-org-uuid="{{id}}" data-vw-org-name="{{name}}" data-vw-billing-email="{{billingEmail}}">Delete Organization</button><br>
</td> </td>
</tr> </tr>
+1 -1
View File
@@ -60,7 +60,7 @@
{{/each}} {{/each}}
</div> </div>
</td> </td>
<td class="text-end px-0 small"> <td class="text-end px-1 small">
<span data-vw-user-uuid="{{id}}" data-vw-user-email="{{email}}"> <span data-vw-user-uuid="{{id}}" data-vw-user-email="{{email}}">
{{#if twoFactorEnabled}} {{#if twoFactorEnabled}}
<button type="button" class="btn btn-sm btn-link p-0 border-0 float-right" vw-remove2fa>Remove all 2FA</button><br> <button type="button" class="btn btn-sm btn-link p-0 border-0 float-right" vw-remove2fa>Remove all 2FA</button><br>
@@ -0,0 +1,6 @@
Your Email Change
<!---------------->
A user ({{ acting_address }}) recently tried to change their account to use this email address ({{ existing_address }}). An account already exists with this email ({{ existing_address }}).
If you did not try to change an email address, contact your administrator.
{{> email/email_footer_text }}
@@ -0,0 +1,16 @@
Your Email Change
<!---------------->
{{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
A user ({{ acting_address }}) recently tried to change their account to use this email address ({{ existing_address }}). An account already exists with this email ({{ existing_address }}).
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
If you did not try to change an email address, contact your administrator.
</td>
</tr>
</table>
{{> email/email_footer }}
@@ -1,7 +1,7 @@
Removed from {{{org_name}}} Your access to {{{org_name}}} has been revoked.
<!----------------> <!---------------->
You have been removed from organization *{{org_name}}* because your account does not have Two-step Login enabled. Your user account has been removed from the *{{org_name}}* organization because you do not have two-step login configured.
Before you can re-join this organization you need to set up two-step login on your user account.
You can enable two-step login in your account settings.
You can enable Two-step Login in your account settings.
{{> email/email_footer_text }} {{> email/email_footer_text }}
@@ -1,15 +1,16 @@
Removed from {{{org_name}}} Your access to {{{org_name}}} has been revoked.
<!----------------> <!---------------->
{{> email/email_header }} {{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> <table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center"> <td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
You have been removed from organization <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{org_name}}</b> because your account does not have Two-step Login enabled. Your user account has been removed from the <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{org_name}}</b> organization because you do not have two-step login configured.<br>
Before you can re-join this organization you need to set up two-step login on your user account.
</td> </td>
</tr> </tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> <tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center"> <td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
You can enable Two-step Login in your account settings. You can enable two-step login in your account settings.
</td> </td>
</tr> </tr>
</table> </table>
+44 -6
View File
@@ -20,16 +20,50 @@ a[href$="/settings/sponsored-families"] {
@extend %vw-hide; @extend %vw-hide;
} }
/* Hide the sso `Email` input field */
.vw-email-sso {
@extend %vw-hide;
}
/* Hide the `Enterprise Single Sign-On` button on the login page */ /* Hide the `Enterprise Single Sign-On` button on the login page */
app-root form.ng-untouched button.\!tw-text-primary-600:nth-child(4) { {{#if (webver ">=2025.5.1")}}
.vw-sso-login {
@extend %vw-hide;
}
{{else}}
app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(4) {
@extend %vw-hide;
}
{{/if}}
/* Hide the `Log in with passkey` settings */
app-change-password app-webauthn-login-settings {
@extend %vw-hide; @extend %vw-hide;
} }
/* Hide Log in with passkey on the login page */ /* Hide Log in with passkey on the login page */
app-root form.ng-untouched a[routerlink="/login-with-passkey"] { {{#if (webver ">=2025.5.1")}}
.vw-passkey-login {
@extend %vw-hide; @extend %vw-hide;
} }
{{else}}
app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(3) {
@extend %vw-hide;
}
{{/if}}
/* Hide the or text followed by the two buttons hidden above */ /* Hide the or text followed by the two buttons hidden above */
app-root form.ng-untouched > div:nth-child(1) > div:nth-child(3) > div:nth-child(2) { {{#if (webver ">=2025.5.1")}}
.vw-or-text {
@extend %vw-hide;
}
{{else}}
app-root ng-component > form > div:nth-child(1) > div:nth-child(3) > div:nth-child(2) {
@extend %vw-hide;
}
{{/if}}
/* Hide the `Other` button on the login page */
.vw-other-login {
@extend %vw-hide; @extend %vw-hide;
} }
@@ -82,19 +116,23 @@ bit-nav-logo bit-nav-item .bwi-shield {
{{#if signup_disabled}} {{#if signup_disabled}}
/* From web vault 2025.1.2 and onwards, the signup button is hidden /* From web vault 2025.1.2 and onwards, the signup button is hidden
when signups are disabled as the web vault checks the /api/config endpoint. when signups are disabled as the web vault checks the /api/config endpoint.
Note that the clients tend to aggressively cache this endpoint, so it might Note that the clients tend to cache this endpoint for about 1 hour, so it might
take a while for the change to take effect. To avoid the button appearing take a while for the change to take effect. To avoid the button appearing
when it shouldn't, we'll keep this style in place for a couple of versions */ when it shouldn't, we'll keep this style in place for a couple of versions */
{{#if webver "<2025.3.0"}}
/* Hide the register link on the login screen */ /* Hide the register link on the login screen */
{{#if (webver "<2025.3.0")}}
app-login form div + div + div + div + hr, app-login form div + div + div + div + hr,
app-login form div + div + div + div + hr + p { app-login form div + div + div + div + hr + p {
@extend %vw-hide; @extend %vw-hide;
} }
{{else}}
app-root a[routerlink="/signup"] {
@extend %vw-hide;
}
{{/if}} {{/if}}
{{/if}} {{/if}}
{{#unless mail_enabled}} {{#unless mail_2fa_enabled}}
/* Hide `Email` 2FA if mail is not enabled */ /* Hide `Email` 2FA if mail is not enabled */
.providers-2fa-1 { .providers-2fa-1 {
@extend %vw-hide; @extend %vw-hide;
+87 -47
View File
@@ -16,7 +16,7 @@ use tokio::{
time::{sleep, Duration}, time::{sleep, Duration},
}; };
use crate::CONFIG; use crate::{config::PathType, CONFIG};
pub struct AppHeaders(); pub struct AppHeaders();
@@ -61,9 +61,11 @@ impl Fairing for AppHeaders {
// The `Cross-Origin-Resource-Policy` header should not be set on images or on the `icon_external` route. // The `Cross-Origin-Resource-Policy` header should not be set on images or on the `icon_external` route.
// Otherwise some clients, like the Bitwarden Desktop, will fail to download the icons // Otherwise some clients, like the Bitwarden Desktop, will fail to download the icons
let mut is_image = true;
if !(res.headers().get_one("Content-Type").is_some_and(|v| v.starts_with("image/")) if !(res.headers().get_one("Content-Type").is_some_and(|v| v.starts_with("image/"))
|| req.route().is_some_and(|v| v.name.as_deref() == Some("icon_external"))) || req.route().is_some_and(|v| v.name.as_deref() == Some("icon_external")))
{ {
is_image = false;
res.set_raw_header("Cross-Origin-Resource-Policy", "same-origin"); res.set_raw_header("Cross-Origin-Resource-Policy", "same-origin");
} }
@@ -71,49 +73,56 @@ impl Fairing for AppHeaders {
// This can cause issues when some MFA requests needs to open a popup or page within the clients like WebAuthn, or Duo. // This can cause issues when some MFA requests needs to open a popup or page within the clients like WebAuthn, or Duo.
// This is the same behavior as upstream Bitwarden. // This is the same behavior as upstream Bitwarden.
if !req_uri_path.ends_with("connector.html") { if !req_uri_path.ends_with("connector.html") {
// # Frame Ancestors: let csp = if is_image {
// Chrome Web Store: https://chrome.google.com/webstore/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb // Prevent scripts, frames, objects, etc., from loading with images, mainly for SVG images, since these could contain JavaScript and other unsafe items.
// Edge Add-ons: https://microsoftedge.microsoft.com/addons/detail/bitwarden-free-password/jbkfoedolllekgbhcbcoahefnbanhhlh?hl=en-US // Even though we sanitize SVG images before storing and viewing them, it's better to prevent allowing these elements.
// Firefox Browser Add-ons: https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/ String::from("default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'none'; frame-src 'none'; object-src 'none")
// # img/child/frame src: } else {
// Have I Been Pwned to allow those calls to work. // # Frame Ancestors:
// # Connect src: // Chrome Web Store: https://chrome.google.com/webstore/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb
// Leaked Passwords check: api.pwnedpasswords.com // Edge Add-ons: https://microsoftedge.microsoft.com/addons/detail/bitwarden-free-password/jbkfoedolllekgbhcbcoahefnbanhhlh?hl=en-US
// 2FA/MFA Site check: api.2fa.directory // Firefox Browser Add-ons: https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/
// # Mail Relay: https://bitwarden.com/blog/add-privacy-and-security-using-email-aliases-with-bitwarden/ // # img/child/frame src:
// app.simplelogin.io, app.addy.io, api.fastmail.com, quack.duckduckgo.com // Have I Been Pwned to allow those calls to work.
let csp = format!( // # Connect src:
"default-src 'none'; \ // Leaked Passwords check: api.pwnedpasswords.com
font-src 'self'; \ // 2FA/MFA Site check: api.2fa.directory
manifest-src 'self'; \ // # Mail Relay: https://bitwarden.com/blog/add-privacy-and-security-using-email-aliases-with-bitwarden/
base-uri 'self'; \ // app.simplelogin.io, app.addy.io, api.fastmail.com, api.forwardemail.net
form-action 'self'; \ format!(
object-src 'self' blob:; \ "default-src 'none'; \
script-src 'self' 'wasm-unsafe-eval'; \ font-src 'self'; \
style-src 'self' 'unsafe-inline'; \ manifest-src 'self'; \
child-src 'self' https://*.duosecurity.com https://*.duofederal.com; \ base-uri 'self'; \
frame-src 'self' https://*.duosecurity.com https://*.duofederal.com; \ form-action 'self'; \
frame-ancestors 'self' \ object-src 'self' blob:; \
chrome-extension://nngceckbapebfimnlniiiahkandclblb \ script-src 'self' 'wasm-unsafe-eval'; \
chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh \ style-src 'self' 'unsafe-inline'; \
moz-extension://* \ child-src 'self' https://*.duosecurity.com https://*.duofederal.com; \
{allowed_iframe_ancestors}; \ frame-src 'self' https://*.duosecurity.com https://*.duofederal.com; \
img-src 'self' data: \ frame-ancestors 'self' \
https://haveibeenpwned.com \ chrome-extension://nngceckbapebfimnlniiiahkandclblb \
{icon_service_csp}; \ chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh \
connect-src 'self' \ moz-extension://* \
https://api.pwnedpasswords.com \ {allowed_iframe_ancestors}; \
https://api.2fa.directory \ img-src 'self' data: \
https://app.simplelogin.io/api/ \ https://haveibeenpwned.com \
https://app.addy.io/api/ \ {icon_service_csp}; \
https://api.fastmail.com/ \ connect-src 'self' \
https://api.forwardemail.net \ https://api.pwnedpasswords.com \
{allowed_connect_src};\ https://api.2fa.directory \
", https://app.simplelogin.io/api/ \
icon_service_csp = CONFIG._icon_service_csp(), https://app.addy.io/api/ \
allowed_iframe_ancestors = CONFIG.allowed_iframe_ancestors(), https://api.fastmail.com/ \
allowed_connect_src = CONFIG.allowed_connect_src(), https://api.forwardemail.net \
); {allowed_connect_src};\
",
icon_service_csp = CONFIG._icon_service_csp(),
allowed_iframe_ancestors = CONFIG.allowed_iframe_ancestors(),
allowed_connect_src = CONFIG.allowed_connect_src(),
)
};
res.set_raw_header("Content-Security-Policy", csp); res.set_raw_header("Content-Security-Policy", csp);
res.set_raw_header("X-Frame-Options", "SAMEORIGIN"); res.set_raw_header("X-Frame-Options", "SAMEORIGIN");
} else { } else {
@@ -752,9 +761,20 @@ pub fn convert_json_key_lcase_first(src_json: Value) -> Value {
/// Parses the experimental client feature flags string into a HashMap. /// Parses the experimental client feature flags string into a HashMap.
pub fn parse_experimental_client_feature_flags(experimental_client_feature_flags: &str) -> HashMap<String, bool> { pub fn parse_experimental_client_feature_flags(experimental_client_feature_flags: &str) -> HashMap<String, bool> {
let feature_states = experimental_client_feature_flags.split(',').map(|f| (f.trim().to_owned(), true)).collect(); // These flags could still be configured, but are deprecated and not used anymore
// To prevent old installations from starting filter these out and not error out
feature_states const DEPRECATED_FLAGS: &[&str] =
&["autofill-overlay", "autofill-v2", "browser-fileless-import", "extension-refresh", "fido2-vault-credentials"];
experimental_client_feature_flags
.split(',')
.filter_map(|f| {
let flag = f.trim();
if !flag.is_empty() && !DEPRECATED_FLAGS.contains(&flag) {
return Some((flag.to_owned(), true));
}
None
})
.collect()
} }
/// TODO: This is extracted from IpAddr::is_global, which is unstable: /// TODO: This is extracted from IpAddr::is_global, which is unstable:
@@ -816,6 +836,26 @@ pub fn is_global(ip: std::net::IpAddr) -> bool {
ip.is_global() ip.is_global()
} }
/// Saves a Rocket temporary file to the OpenDAL Operator at the given path.
pub async fn save_temp_file(
path_type: PathType,
path: &str,
temp_file: rocket::fs::TempFile<'_>,
overwrite: bool,
) -> Result<(), crate::Error> {
use futures::AsyncWriteExt as _;
use tokio_util::compat::TokioAsyncReadCompatExt as _;
let operator = CONFIG.opendal_operator_for_path_type(path_type)?;
let mut read_stream = temp_file.open().await?.compat();
let mut writer = operator.writer_with(path).if_not_exists(!overwrite).await?.into_futures_async_write();
futures::io::copy(&mut read_stream, &mut writer).await?;
writer.close().await?;
Ok(())
}
/// These are some tests to check that the implementations match /// These are some tests to check that the implementations match
/// The IPv4 can be all checked in 30 seconds or so and they are correct as of nightly 2023-07-17 /// The IPv4 can be all checked in 30 seconds or so and they are correct as of nightly 2023-07-17
/// The IPV6 can't be checked in a reasonable time, so we check over a hundred billion random ones, so far correct /// The IPV6 can't be checked in a reasonable time, so we check over a hundred billion random ones, so far correct