mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2026-06-05 10:10:18 +03:00
Compare commits
123 Commits
9017ca265a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d626ea81ab | |||
| 1ba2c6a26c | |||
| 22f5e0496c | |||
| 70f9dfbe8b | |||
| 54895ad4be | |||
| a057c7deae | |||
| 2f85b62d2f | |||
| 9bc14e6e77 | |||
| cdf711bb30 | |||
| f21a3adae2 | |||
| 07aa377af7 | |||
| 14258caec9 | |||
| cb46fcb948 | |||
| b89648a136 | |||
| c3bd1eb565 | |||
| 8c3c969938 | |||
| 38a6850b8d | |||
| d297e274a3 | |||
| a354e57659 | |||
| 5cc7360816 | |||
| 62748100f0 | |||
| fcbdebd6d7 | |||
| 454b8e2a35 | |||
| 7883da554e | |||
| fd2b6528a9 | |||
| cc57e60886 | |||
| e5681258f0 | |||
| 7cf0c5d67e | |||
| b04ed75f9f | |||
| 0ed8ab68f7 | |||
| dfebee57ec | |||
| bfe420a018 | |||
| e7e4b9a86d | |||
| bb549986e6 | |||
| 39954af96a | |||
| a6b43651ca | |||
| 3f28b583db | |||
| d4f67429d6 | |||
| fc43737868 | |||
| 43df0fb7f4 | |||
| d29cd29f55 | |||
| 2811df2953 | |||
| 8f0e99b875 | |||
| f07a91141a | |||
| 787822854c | |||
| f62a7a66c8 | |||
| 3a1378f469 | |||
| dde63e209e | |||
| 235cf88231 | |||
| c0a78dd55a | |||
| 711bb53d3d | |||
| 650defac75 | |||
| 2b3736802d | |||
| 9c7df6412c | |||
| 065c1f2cd5 | |||
| 1a1d7f578a | |||
| 2b16a05e54 | |||
| c6e9948984 | |||
| ecdb18fcde | |||
| df25d316d6 | |||
| 747286dccd | |||
| e60105411b | |||
| 937857a0bc | |||
| ba55191676 | |||
| c555f7d198 | |||
| 74819b95bd | |||
| da2af3d362 | |||
| 1583fe4af3 | |||
| 36f0620fd1 | |||
| 3cd2d4afe7 | |||
| d09c45bb63 | |||
| feecfb20da | |||
| 347279a12c | |||
| 7f65a254b3 | |||
| cc80f689ed | |||
| 4737192853 | |||
| 0c6817cb4e | |||
| 25a71d913f | |||
| b2cd556f3e | |||
| 4352fffeec | |||
| 8d08697cf8 | |||
| 9f1df42259 | |||
| 1e1f9957cd | |||
| bf37657c08 | |||
| 3e2cef7e8b | |||
| 2af9d21158 | |||
| c4f6c4e63b | |||
| eb2a56aea1 | |||
| a4907f3539 | |||
| 8801b47d80 | |||
| 1ae9dc4119 | |||
| 02377eeac8 | |||
| d9c75508c2 | |||
| 0ab7784b06 | |||
| 5c91058ba0 | |||
| 229b58fe4e | |||
| 061d320c7f | |||
| 2c73c6c2f2 | |||
| b920caf285 | |||
| 57bdab1550 | |||
| b77c01b8bb | |||
| 9cca120fb3 | |||
| 4ad8baf7be | |||
| 8f689d8795 | |||
| 2d91a9460b | |||
| e81e6a5060 | |||
| 76d0856bbe | |||
| f0e79fd391 | |||
| 5981705375 | |||
| 07569a06da | |||
| cb2f5741ac | |||
| c9d527d84f | |||
| 7c7f4f5d4f | |||
| aad1f19b45 | |||
| 35e1a306f3 | |||
| 7f7b412220 | |||
| bb41f64c0a | |||
| 319d982113 | |||
| 95a0c667e4 | |||
| b519832086 | |||
| 2ee40d6105 | |||
| 0182567a62 | |||
| f9751a0a1d |
+29
-16
@@ -50,10 +50,11 @@
|
||||
#########################
|
||||
|
||||
## Database URL
|
||||
## When using SQLite, this is the path to the DB file, and it defaults to
|
||||
## %DATA_FOLDER%/db.sqlite3. If DATA_FOLDER is set to an external location, this
|
||||
## must be set to a local sqlite3 file path.
|
||||
# DATABASE_URL=data/db.sqlite3
|
||||
## When using SQLite, this should use the sqlite:// scheme followed by the path
|
||||
## to the DB file. It defaults to sqlite://%DATA_FOLDER%/db.sqlite3.
|
||||
## Bare paths without the sqlite:// scheme are supported for backwards compatibility,
|
||||
## but only if the database file already exists.
|
||||
# DATABASE_URL=sqlite://data/db.sqlite3
|
||||
## When using MySQL, specify an appropriate connection URI.
|
||||
## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html
|
||||
# DATABASE_URL=mysql://user:password@host[:port]/database_name
|
||||
@@ -183,9 +184,9 @@
|
||||
## Defaults to every minute. Set blank to disable this job.
|
||||
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
|
||||
#
|
||||
## Cron schedule of the job that cleans sso nonce from incomplete flow
|
||||
## Cron schedule of the job that cleans sso auth from incomplete flow
|
||||
## Defaults to daily (20 minutes after midnight). Set blank to disable this job.
|
||||
# PURGE_INCOMPLETE_SSO_NONCE="0 20 0 * * *"
|
||||
# PURGE_INCOMPLETE_SSO_AUTH="0 20 0 * * *"
|
||||
|
||||
########################
|
||||
### General settings ###
|
||||
@@ -348,7 +349,7 @@
|
||||
## Default: 2592000 (30 days)
|
||||
# ICON_CACHE_TTL=2592000
|
||||
## Cache time-to-live for icons which weren't available, in seconds (0 is "forever")
|
||||
## Default: 2592000 (3 days)
|
||||
## Default: 259200 (3 days)
|
||||
# ICON_CACHE_NEGTTL=259200
|
||||
|
||||
## Icon download timeout
|
||||
@@ -372,15 +373,22 @@
|
||||
## 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:
|
||||
## - "inline-menu-positioning-improvements": Enable the use of inline menu password generator and identity suggestions in the browser extension.
|
||||
## - "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-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)
|
||||
## - "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)
|
||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=fido2-vault-credentials
|
||||
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
|
||||
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
|
||||
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
||||
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
|
||||
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
|
||||
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
||||
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
||||
## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
|
||||
## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
|
||||
## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
|
||||
## - "pm-30529-webauthn-related-origins":
|
||||
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
||||
|
||||
## 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!!
|
||||
@@ -471,6 +479,11 @@
|
||||
## Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy.
|
||||
# ENFORCE_SINGLE_ORG_WITH_RESET_PW_POLICY=false
|
||||
|
||||
## Prefer IPv6 (AAAA) resolving
|
||||
## This settings configures the DNS resolver to resolve IPv6 first, and if not available try IPv4
|
||||
## This could be useful in IPv6 only environments.
|
||||
# DNS_PREFER_IPV6=false
|
||||
|
||||
#####################################
|
||||
### SSO settings (OpenID Connect) ###
|
||||
#####################################
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
# Ignore vendored scripts in GitHub stats
|
||||
src/static/scripts/* linguist-vendored
|
||||
|
||||
|
||||
+22
-24
@@ -1,6 +1,10 @@
|
||||
name: Build
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
@@ -30,6 +34,10 @@ on:
|
||||
- "docker/DockerSettings.yaml"
|
||||
- "macros/**"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Test ${{ matrix.channel }}
|
||||
@@ -54,7 +62,7 @@ jobs:
|
||||
|
||||
# Checkout the repo
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -63,7 +71,6 @@ jobs:
|
||||
# Determine rust-toolchain version
|
||||
- name: Init Variables
|
||||
id: toolchain
|
||||
shell: bash
|
||||
env:
|
||||
CHANNEL: ${{ matrix.channel }}
|
||||
run: |
|
||||
@@ -78,32 +85,23 @@ jobs:
|
||||
# End Determine rust-toolchain version
|
||||
|
||||
|
||||
# Only install the clippy and rustfmt components on the default rust-toolchain
|
||||
- name: "Install rust-toolchain version"
|
||||
uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # master @ Sep 16, 2025, 8:37 PM GMT+2
|
||||
if: ${{ matrix.channel == 'rust-toolchain' }}
|
||||
with:
|
||||
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
|
||||
components: clippy, rustfmt
|
||||
# End Uses the rust-toolchain file to determine version
|
||||
|
||||
|
||||
# Install the any other channel to be used for which we do not execute clippy and rustfmt
|
||||
- name: "Install MSRV version"
|
||||
uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # master @ Sep 16, 2025, 8:37 PM GMT+2
|
||||
if: ${{ matrix.channel != 'rust-toolchain' }}
|
||||
with:
|
||||
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
|
||||
# End Install the MSRV channel to be used
|
||||
|
||||
# Set the current matrix toolchain version as default
|
||||
- name: "Set toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default"
|
||||
- name: "Install toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default"
|
||||
env:
|
||||
CHANNEL: ${{ matrix.channel }}
|
||||
RUST_TOOLCHAIN: ${{steps.toolchain.outputs.RUST_TOOLCHAIN}}
|
||||
run: |
|
||||
# Remove the rust-toolchain.toml
|
||||
rm rust-toolchain.toml
|
||||
# Set the default
|
||||
|
||||
# Install the correct toolchain version
|
||||
rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal --no-self-update
|
||||
|
||||
# If this matrix is the `rust-toolchain` flow, also install rustfmt and clippy
|
||||
if [[ "${CHANNEL}" == 'rust-toolchain' ]]; then
|
||||
rustup component add --toolchain "${RUST_TOOLCHAIN}" rustfmt clippy
|
||||
fi
|
||||
|
||||
# Set as the default toolchain
|
||||
rustup default "${RUST_TOOLCHAIN}"
|
||||
|
||||
# Show environment
|
||||
@@ -115,7 +113,7 @@ jobs:
|
||||
|
||||
# Enable Rust Caching
|
||||
- name: Rust Caching
|
||||
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
||||
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
name: Check templates
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
docker-templates:
|
||||
name: Validate docker templates
|
||||
@@ -12,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# End Checkout the repo
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
name: Hadolint
|
||||
|
||||
on: [ push, pull_request ]
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
hadolint:
|
||||
@@ -13,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
# Start Docker Buildx
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
# https://github.com/moby/buildkit/issues/3969
|
||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||
with:
|
||||
@@ -25,7 +32,6 @@ jobs:
|
||||
|
||||
# Download hadolint - https://github.com/hadolint/hadolint/releases
|
||||
- name: Download hadolint
|
||||
shell: bash
|
||||
run: |
|
||||
sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \
|
||||
sudo chmod +x /usr/local/bin/hadolint
|
||||
@@ -34,20 +40,18 @@ jobs:
|
||||
# End Download hadolint
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# End Checkout the repo
|
||||
|
||||
# Test Dockerfiles with hadolint
|
||||
- name: Run hadolint
|
||||
shell: bash
|
||||
run: hadolint docker/Dockerfile.{debian,alpine}
|
||||
# End Test Dockerfiles with hadolint
|
||||
|
||||
# Test Dockerfiles with docker build checks
|
||||
- name: Run docker build check
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Checking docker/Dockerfile.debian"
|
||||
docker build --check . -f docker/Dockerfile.debian
|
||||
|
||||
+228
-145
@@ -1,6 +1,12 @@
|
||||
name: Release
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
# Apply concurrency control only on the upstream repo
|
||||
group: ${{ github.repository == 'dani-garcia/vaultwarden' && format('{0}-{1}', github.workflow, github.ref) || github.run_id }}
|
||||
# Don't cancel other runs when creating a tag
|
||||
cancel-in-progress: ${{ github.ref_type == 'branch' }}
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -10,56 +16,55 @@ on:
|
||||
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
|
||||
- '[1-2].[0-9]+.[0-9]+'
|
||||
|
||||
concurrency:
|
||||
# Apply concurrency control only on the upstream repo
|
||||
group: ${{ github.repository == 'dani-garcia/vaultwarden' && format('{0}-{1}', github.workflow, github.ref) || github.run_id }}
|
||||
# Don't cancel other runs when creating a tag
|
||||
cancel-in-progress: ${{ github.ref_type == 'branch' }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
# A "release" environment must be created in the repository settings
|
||||
# (Settings > Environments > New environment) with the following
|
||||
# variables and secrets configured as needed.
|
||||
#
|
||||
# Variables (only set the ones for registries you want to push to):
|
||||
# DOCKERHUB_REPO: 'index.docker.io/<user>/<repo>'
|
||||
# QUAY_REPO: 'quay.io/<user>/<repo>'
|
||||
# GHCR_REPO: 'ghcr.io/<user>/<repo>'
|
||||
#
|
||||
# Secrets (only required when the corresponding *_REPO variable is set):
|
||||
# DOCKERHUB_REPO => DOCKERHUB_USERNAME, DOCKERHUB_TOKEN
|
||||
# QUAY_REPO => QUAY_USERNAME, QUAY_TOKEN
|
||||
# GITHUB_TOKEN is provided automatically
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
name: Build Vaultwarden containers
|
||||
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
||||
environment:
|
||||
name: release
|
||||
deployment: false
|
||||
permissions:
|
||||
packages: write # Needed to upload packages and artifacts
|
||||
contents: read
|
||||
attestations: write # Needed to generate an artifact attestation for a build
|
||||
id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ contains(matrix.arch, 'arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 120
|
||||
# Start a local docker registry to extract the compiled binaries to upload as artifacts and attest them
|
||||
services:
|
||||
registry:
|
||||
image: registry@sha256:1fc7de654f2ac1247f0b67e8a459e273b0993be7d2beda1f3f56fbf1001ed3e7 # v3.0.0
|
||||
ports:
|
||||
- 5000:5000
|
||||
env:
|
||||
SOURCE_COMMIT: ${{ github.sha }}
|
||||
SOURCE_REPOSITORY_URL: "https://github.com/${{ github.repository }}"
|
||||
# The *_REPO variables need to be configured as repository variables
|
||||
# Append `/settings/variables/actions` to your repo url
|
||||
# DOCKERHUB_REPO needs to be 'index.docker.io/<user>/<repo>'
|
||||
# Check for Docker hub credentials in secrets
|
||||
HAVE_DOCKERHUB_LOGIN: ${{ vars.DOCKERHUB_REPO != '' && secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
|
||||
# GHCR_REPO needs to be 'ghcr.io/<user>/<repo>'
|
||||
# Check for Github credentials in secrets
|
||||
HAVE_GHCR_LOGIN: ${{ vars.GHCR_REPO != '' && github.repository_owner != '' && secrets.GITHUB_TOKEN != '' }}
|
||||
# QUAY_REPO needs to be 'quay.io/<user>/<repo>'
|
||||
# Check for Quay.io credentials in secrets
|
||||
HAVE_QUAY_LOGIN: ${{ vars.QUAY_REPO != '' && secrets.QUAY_USERNAME != '' && secrets.QUAY_TOKEN != '' }}
|
||||
strategy:
|
||||
matrix:
|
||||
arch: ["amd64", "arm64", "arm/v7", "arm/v6"]
|
||||
base_image: ["debian","alpine"]
|
||||
|
||||
steps:
|
||||
- name: Initialize QEMU binfmt support
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
with:
|
||||
platforms: "arm64,arm"
|
||||
|
||||
# Start Docker Buildx
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
# https://github.com/moby/buildkit/issues/3969
|
||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||
with:
|
||||
@@ -72,25 +77,24 @@ jobs:
|
||||
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# We need fetch-depth of 0 so we also get all the tag metadata
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
# Determine Base Tags and Source Version
|
||||
- name: Determine Base Tags and Source Version
|
||||
shell: bash
|
||||
# Normalize the architecture string for use in paths and cache keys
|
||||
- name: Normalize architecture string
|
||||
env:
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
MATRIX_ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
# Check which main tag we are going to build determined by ref_type
|
||||
if [[ "${REF_TYPE}" == "tag" ]]; then
|
||||
echo "BASE_TAGS=latest,${GITHUB_REF#refs/*/}" | tee -a "${GITHUB_ENV}"
|
||||
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
||||
echo "BASE_TAGS=testing" | tee -a "${GITHUB_ENV}"
|
||||
fi
|
||||
# Replace slashes with nothing to create a safe string for paths/cache keys
|
||||
NORMALIZED_ARCH="${MATRIX_ARCH//\/}"
|
||||
echo "NORMALIZED_ARCH=${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
# Determine Source Version
|
||||
- name: Determine Source Version
|
||||
run: |
|
||||
# Get the Source Version for this release
|
||||
GIT_EXACT_TAG="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null || true)"
|
||||
if [[ -n "${GIT_EXACT_TAG}" ]]; then
|
||||
@@ -99,19 +103,17 @@ jobs:
|
||||
GIT_LAST_TAG="$(git describe --tags --abbrev=0)"
|
||||
echo "SOURCE_VERSION=${GIT_LAST_TAG}-${SOURCE_COMMIT:0:8}" | tee -a "${GITHUB_ENV}"
|
||||
fi
|
||||
# End Determine Base Tags
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
|
||||
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||
|
||||
- name: Add registry for DockerHub
|
||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
|
||||
shell: bash
|
||||
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||
env:
|
||||
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
|
||||
run: |
|
||||
@@ -119,16 +121,15 @@ jobs:
|
||||
|
||||
# Login to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
|
||||
if: ${{ vars.GHCR_REPO != '' }}
|
||||
|
||||
- name: Add registry for ghcr.io
|
||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
|
||||
shell: bash
|
||||
if: ${{ vars.GHCR_REPO != '' }}
|
||||
env:
|
||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||
run: |
|
||||
@@ -136,64 +137,74 @@ jobs:
|
||||
|
||||
# Login to Quay.io
|
||||
- name: Login to Quay.io
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_TOKEN }}
|
||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
|
||||
if: ${{ vars.QUAY_REPO != '' }}
|
||||
|
||||
- name: Add registry for Quay.io
|
||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
|
||||
shell: bash
|
||||
if: ${{ vars.QUAY_REPO != '' }}
|
||||
env:
|
||||
QUAY_REPO: ${{ vars.QUAY_REPO }}
|
||||
run: |
|
||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
- name: Configure build cache from/to
|
||||
shell: bash
|
||||
env:
|
||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||
BASE_IMAGE: ${{ matrix.base_image }}
|
||||
NORMALIZED_ARCH: ${{ env.NORMALIZED_ARCH }}
|
||||
run: |
|
||||
#
|
||||
# Check if there is a GitHub Container Registry Login and use it for caching
|
||||
if [[ -n "${HAVE_GHCR_LOGIN}" ]]; then
|
||||
echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}" | tee -a "${GITHUB_ENV}"
|
||||
echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}"
|
||||
if [[ -n "${GHCR_REPO}" ]]; then
|
||||
echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}"
|
||||
echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}"
|
||||
else
|
||||
echo "BAKE_CACHE_FROM="
|
||||
echo "BAKE_CACHE_TO="
|
||||
fi
|
||||
#
|
||||
|
||||
- name: Add localhost registry
|
||||
shell: bash
|
||||
- name: Generate tags
|
||||
id: tags
|
||||
env:
|
||||
CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}"
|
||||
run: |
|
||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}localhost:5000/vaultwarden/server" | tee -a "${GITHUB_ENV}"
|
||||
# Convert comma-separated list to newline-separated set commands
|
||||
TAGS=$(echo "${CONTAINER_REGISTRIES}" | tr ',' '\n' | sed "s|.*|*.tags=&|")
|
||||
|
||||
# Output for use in next step
|
||||
{
|
||||
echo "TAGS<<EOF"
|
||||
echo "$TAGS"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Bake ${{ matrix.base_image }} containers
|
||||
id: bake_vw
|
||||
uses: docker/bake-action@3acf805d94d93a86cce4ca44798a76464a75b88c # v6.9.0
|
||||
uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0
|
||||
env:
|
||||
BASE_TAGS: "${{ env.BASE_TAGS }}"
|
||||
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||
SOURCE_VERSION: "${{ env.SOURCE_VERSION }}"
|
||||
SOURCE_REPOSITORY_URL: "${{ env.SOURCE_REPOSITORY_URL }}"
|
||||
CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}"
|
||||
with:
|
||||
pull: true
|
||||
push: true
|
||||
source: .
|
||||
files: docker/docker-bake.hcl
|
||||
targets: "${{ matrix.base_image }}-multi"
|
||||
set: |
|
||||
*.cache-from=${{ env.BAKE_CACHE_FROM }}
|
||||
*.cache-to=${{ env.BAKE_CACHE_TO }}
|
||||
*.platform=linux/${{ matrix.arch }}
|
||||
${{ env.TAGS }}
|
||||
*.output=type=local,dest=./output
|
||||
*.output=type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Extract digest SHA
|
||||
shell: bash
|
||||
env:
|
||||
BAKE_METADATA: ${{ steps.bake_vw.outputs.metadata }}
|
||||
BASE_IMAGE: ${{ matrix.base_image }}
|
||||
@@ -201,105 +212,177 @@ jobs:
|
||||
GET_DIGEST_SHA="$(jq -r --arg base "$BASE_IMAGE" '.[$base + "-multi"]."containerimage.digest"' <<< "${BAKE_METADATA}")"
|
||||
echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
- name: Export digest
|
||||
env:
|
||||
DIGEST_SHA: ${{ env.DIGEST_SHA }}
|
||||
RUNNER_TEMP: ${{ runner.temp }}
|
||||
run: |
|
||||
mkdir -p "${RUNNER_TEMP}"/digests
|
||||
digest="${DIGEST_SHA}"
|
||||
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Rename binaries to match target platform
|
||||
env:
|
||||
NORMALIZED_ARCH: ${{ env.NORMALIZED_ARCH }}
|
||||
run: |
|
||||
mv ./output/vaultwarden vaultwarden-"${NORMALIZED_ARCH}"
|
||||
|
||||
# Upload artifacts to Github Actions and Attest the binaries
|
||||
- name: Attest binaries
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||
|
||||
- name: Upload binaries as artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||
path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||
|
||||
merge-manifests:
|
||||
name: Merge manifests
|
||||
runs-on: ubuntu-latest
|
||||
needs: docker-build
|
||||
environment:
|
||||
name: release
|
||||
deployment: false
|
||||
permissions:
|
||||
packages: write # Needed to upload packages and artifacts
|
||||
attestations: write # Needed to generate an artifact attestation for a build
|
||||
id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate
|
||||
strategy:
|
||||
matrix:
|
||||
base_image: ["debian","alpine"]
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*-${{ matrix.base_image }}
|
||||
merge-multiple: true
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||
|
||||
- name: Add registry for DockerHub
|
||||
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||
env:
|
||||
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
|
||||
run: |
|
||||
echo "CONTAINER_REGISTRIES=${DOCKERHUB_REPO}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
# Login to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
if: ${{ vars.GHCR_REPO != '' }}
|
||||
|
||||
- name: Add registry for ghcr.io
|
||||
if: ${{ vars.GHCR_REPO != '' }}
|
||||
env:
|
||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||
run: |
|
||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${GHCR_REPO}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
# Login to Quay.io
|
||||
- name: Login to Quay.io
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_TOKEN }}
|
||||
if: ${{ vars.QUAY_REPO != '' }}
|
||||
|
||||
- name: Add registry for Quay.io
|
||||
if: ${{ vars.QUAY_REPO != '' }}
|
||||
env:
|
||||
QUAY_REPO: ${{ vars.QUAY_REPO }}
|
||||
run: |
|
||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
# Determine Base Tags
|
||||
- name: Determine Base Tags
|
||||
env:
|
||||
BASE_IMAGE_TAG: "${{ matrix.base_image != 'debian' && format('-{0}', matrix.base_image) || '' }}"
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
# Check which main tag we are going to build determined by ref_type
|
||||
if [[ "${REF_TYPE}" == "tag" ]]; then
|
||||
echo "BASE_TAGS=latest${BASE_IMAGE_TAG},${GITHUB_REF#refs/*/}${BASE_IMAGE_TAG}${BASE_IMAGE_TAG//-/,}" | tee -a "${GITHUB_ENV}"
|
||||
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
||||
echo "BASE_TAGS=testing${BASE_IMAGE_TAG}" | tee -a "${GITHUB_ENV}"
|
||||
fi
|
||||
|
||||
- name: Create manifest list, push it and extract digest SHA
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
BASE_TAGS: "${{ env.BASE_TAGS }}"
|
||||
CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}"
|
||||
run: |
|
||||
IFS=',' read -ra IMAGES <<< "${CONTAINER_REGISTRIES}"
|
||||
IFS=',' read -ra TAGS <<< "${BASE_TAGS}"
|
||||
|
||||
TAG_ARGS=()
|
||||
for img in "${IMAGES[@]}"; do
|
||||
for tag in "${TAGS[@]}"; do
|
||||
TAG_ARGS+=("-t" "${img}:${tag}")
|
||||
done
|
||||
done
|
||||
|
||||
echo "Creating manifest"
|
||||
if ! OUTPUT=$(docker buildx imagetools create \
|
||||
"${TAG_ARGS[@]}" \
|
||||
$(printf "${IMAGES[0]}@sha256:%s " *) 2>&1); then
|
||||
echo "Manifest creation failed"
|
||||
echo "${OUTPUT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Manifest created successfully"
|
||||
echo "${OUTPUT}"
|
||||
|
||||
# Extract digest SHA for subsequent steps
|
||||
GET_DIGEST_SHA="$(echo "${OUTPUT}" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)"
|
||||
echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
# Attest container images
|
||||
- name: Attest - docker.io - ${{ matrix.base_image }}
|
||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
|
||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
||||
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
push-to-registry: true
|
||||
|
||||
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
|
||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
||||
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ vars.GHCR_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
push-to-registry: true
|
||||
|
||||
- name: Attest - quay.io - ${{ matrix.base_image }}
|
||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
|
||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
||||
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ vars.QUAY_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
push-to-registry: true
|
||||
|
||||
|
||||
# Extract the Alpine binaries from the containers
|
||||
- name: Extract binaries
|
||||
shell: bash
|
||||
env:
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
BASE_IMAGE: ${{ matrix.base_image }}
|
||||
run: |
|
||||
# Check which main tag we are going to build determined by ref_type
|
||||
if [[ "${REF_TYPE}" == "tag" ]]; then
|
||||
EXTRACT_TAG="latest"
|
||||
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
||||
EXTRACT_TAG="testing"
|
||||
fi
|
||||
|
||||
# Check which base_image was used and append -alpine if needed
|
||||
if [[ "${BASE_IMAGE}" == "alpine" ]]; then
|
||||
EXTRACT_TAG="${EXTRACT_TAG}-alpine"
|
||||
fi
|
||||
|
||||
# After each extraction the image is removed.
|
||||
# This is needed because using different platforms doesn't trigger a new pull/download
|
||||
|
||||
# Extract amd64 binary
|
||||
docker create --name amd64 --platform=linux/amd64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
docker cp amd64:/vaultwarden vaultwarden-amd64-${BASE_IMAGE}
|
||||
docker rm --force amd64
|
||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
|
||||
# Extract arm64 binary
|
||||
docker create --name arm64 --platform=linux/arm64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
docker cp arm64:/vaultwarden vaultwarden-arm64-${BASE_IMAGE}
|
||||
docker rm --force arm64
|
||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
|
||||
# Extract armv7 binary
|
||||
docker create --name armv7 --platform=linux/arm/v7 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
docker cp armv7:/vaultwarden vaultwarden-armv7-${BASE_IMAGE}
|
||||
docker rm --force armv7
|
||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
|
||||
# Extract armv6 binary
|
||||
docker create --name armv6 --platform=linux/arm/v6 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
docker cp armv6:/vaultwarden vaultwarden-armv6-${BASE_IMAGE}
|
||||
docker rm --force armv6
|
||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
|
||||
# Upload artifacts to Github Actions and Attest the binaries
|
||||
- name: "Upload amd64 artifact ${{ matrix.base_image }}"
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-amd64-${{ matrix.base_image }}
|
||||
path: vaultwarden-amd64-${{ matrix.base_image }}
|
||||
|
||||
- name: "Upload arm64 artifact ${{ matrix.base_image }}"
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-arm64-${{ matrix.base_image }}
|
||||
path: vaultwarden-arm64-${{ matrix.base_image }}
|
||||
|
||||
- name: "Upload armv7 artifact ${{ matrix.base_image }}"
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv7-${{ matrix.base_image }}
|
||||
path: vaultwarden-armv7-${{ matrix.base_image }}
|
||||
|
||||
- name: "Upload armv6 artifact ${{ matrix.base_image }}"
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv6-${{ matrix.base_image }}
|
||||
path: vaultwarden-armv6-${{ matrix.base_image }}
|
||||
|
||||
- name: "Attest artifacts ${{ matrix.base_image }}"
|
||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
||||
with:
|
||||
subject-path: vaultwarden-*
|
||||
# End Upload artifacts to Github Actions
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Cleanup
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Trivy
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -29,12 +33,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2
|
||||
TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1
|
||||
@@ -46,6 +50,6 @@ jobs:
|
||||
severity: CRITICAL,HIGH
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
name: Code Spell Checking
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on: [ push, pull_request ]
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
typos:
|
||||
@@ -12,11 +16,11 @@ jobs:
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# End Checkout the repo
|
||||
|
||||
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@07d900b8fa1097806b8adb6391b0d3e0ac2fdea7 # v1.39.0
|
||||
uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef # v1.46.1
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
name: Security Analysis with zizmor
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,8 +11,6 @@ on:
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Run zizmor
|
||||
@@ -16,12 +19,12 @@ jobs:
|
||||
security-events: write # To write the security report
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@e673c3917a1aef3c65c972347ed84ccd013ecda4 # v0.2.0
|
||||
uses: zizmorcore/zizmor-action@b572f7b1a1c2d41efaab43d504f68d215c3cd727 # v0.5.4
|
||||
with:
|
||||
# intentionally not scanning the entire repository,
|
||||
# since it contains integration tests.
|
||||
|
||||
+55
-53
@@ -1,58 +1,60 @@
|
||||
---
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: mixed-line-ending
|
||||
args: ["--fix=no"]
|
||||
- id: end-of-file-fixer
|
||||
exclude: "(.*js$|.*css$)"
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
- id: check-symlinks
|
||||
- id: forbid-submodules
|
||||
- repo: local
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: mixed-line-ending
|
||||
args: [ "--fix=no" ]
|
||||
- id: end-of-file-fixer
|
||||
exclude: "(.*js$|.*css$)"
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
- id: check-symlinks
|
||||
- id: forbid-submodules
|
||||
|
||||
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
|
||||
- repo: https://github.com/crate-ci/typos
|
||||
rev: 5374cbf686e897b15713110e233094e2874de7ef # v1.46.1
|
||||
hooks:
|
||||
- id: fmt
|
||||
name: fmt
|
||||
description: Format files with cargo fmt.
|
||||
entry: cargo fmt
|
||||
language: system
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
args: ["--", "--check"]
|
||||
- id: cargo-test
|
||||
name: cargo test
|
||||
description: Test the package for errors.
|
||||
entry: cargo test
|
||||
language: system
|
||||
args: ["--features", "sqlite,mysql,postgresql", "--"]
|
||||
types_or: [rust, file]
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||
pass_filenames: false
|
||||
- id: cargo-clippy
|
||||
name: cargo clippy
|
||||
description: Lint Rust sources
|
||||
entry: cargo clippy
|
||||
language: system
|
||||
args: ["--features", "sqlite,mysql,postgresql", "--", "-D", "warnings"]
|
||||
types_or: [rust, file]
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||
pass_filenames: false
|
||||
- id: check-docker-templates
|
||||
name: check docker templates
|
||||
description: Check if the Docker templates are updated
|
||||
language: system
|
||||
entry: sh
|
||||
args:
|
||||
- "-c"
|
||||
- "cd docker && make"
|
||||
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
|
||||
- repo: https://github.com/crate-ci/typos
|
||||
rev: 07d900b8fa1097806b8adb6391b0d3e0ac2fdea7 # v1.39.0
|
||||
hooks:
|
||||
- id: typos
|
||||
- id: typos
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: fmt
|
||||
name: fmt
|
||||
description: Format files with cargo fmt.
|
||||
entry: cargo fmt
|
||||
language: system
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
args: [ "--", "--check" ]
|
||||
- id: cargo-test
|
||||
name: cargo test
|
||||
description: Test the package for errors.
|
||||
entry: cargo test
|
||||
language: system
|
||||
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
|
||||
types_or: [ rust, file ]
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||
pass_filenames: false
|
||||
- id: cargo-clippy
|
||||
name: cargo clippy
|
||||
description: Lint Rust sources
|
||||
entry: cargo clippy
|
||||
language: system
|
||||
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
|
||||
types_or: [ rust, file ]
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||
pass_filenames: false
|
||||
- id: check-docker-templates
|
||||
name: check docker templates
|
||||
description: Check if the Docker templates are updated
|
||||
language: system
|
||||
entry: sh
|
||||
args:
|
||||
- "-c"
|
||||
- "cd docker && make"
|
||||
|
||||
@@ -23,4 +23,6 @@ extend-ignore-re = [
|
||||
# https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86
|
||||
# https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45
|
||||
"AuthRequestResponseRecieved",
|
||||
# Ignore Punycode/IDN tests
|
||||
"xn--.+"
|
||||
]
|
||||
|
||||
Generated
+1322
-1205
File diff suppressed because it is too large
Load Diff
+153
-88
@@ -1,6 +1,6 @@
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
rust-version = "1.89.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.93.0"
|
||||
license = "AGPL-3.0-only"
|
||||
repository = "https://github.com/dani-garcia/vaultwarden"
|
||||
publish = false
|
||||
@@ -24,20 +24,31 @@ publish.workspace = true
|
||||
[features]
|
||||
default = [
|
||||
# "sqlite",
|
||||
# "sqlite_system",
|
||||
# "mysql",
|
||||
# "postgresql",
|
||||
]
|
||||
# Empty to keep compatibility, prefer to set USE_SYSLOG=true
|
||||
enable_syslog = []
|
||||
# Please enable at least one of these DB backends.
|
||||
mysql = ["diesel/mysql", "diesel_migrations/mysql"]
|
||||
postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
|
||||
sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "dep:libsqlite3-sys"]
|
||||
sqlite_system = ["diesel/sqlite", "diesel_migrations/sqlite"] # Dynamically link SQLite
|
||||
sqlite = ["sqlite_system", "libsqlite3-sys/bundled"] # Statically link SQLite into the binary instead of dynamically.
|
||||
# Enable to use a vendored and statically linked openssl
|
||||
vendored_openssl = ["openssl/vendored"]
|
||||
# Enable MiMalloc memory allocator to replace the default malloc
|
||||
# This can improve performance for Alpine builds
|
||||
enable_mimalloc = ["dep:mimalloc"]
|
||||
s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:anyhow", "dep:http", "dep:reqsign"]
|
||||
s3 = [
|
||||
"opendal/services-s3",
|
||||
"dep:aws-config",
|
||||
"dep:aws-credential-types",
|
||||
"dep:aws-smithy-runtime-api",
|
||||
"dep:http",
|
||||
"dep:reqsign-aws-v4",
|
||||
"dep:reqsign-core",
|
||||
]
|
||||
|
||||
# OIDC specific features
|
||||
oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"]
|
||||
@@ -55,9 +66,10 @@ syslog = "7.0.0"
|
||||
macros = { path = "./macros" }
|
||||
|
||||
# Logging
|
||||
log = "0.4.28"
|
||||
log = "0.4.29"
|
||||
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
||||
tracing = { version = "0.1.41", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work
|
||||
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
|
||||
tracing = { version = "0.1.44", features = ["log"] }
|
||||
|
||||
# A `dotenv` implementation for Rust
|
||||
dotenvy = { version = "0.15.7", default-features = false }
|
||||
@@ -65,143 +77,197 @@ dotenvy = { version = "0.15.7", default-features = false }
|
||||
# Numerical libraries
|
||||
num-traits = "0.2.19"
|
||||
num-derive = "0.4.2"
|
||||
bigdecimal = "0.4.9"
|
||||
bigdecimal = "0.4.10"
|
||||
|
||||
# Web framework
|
||||
rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false }
|
||||
rocket_ws = { version ="0.1.1" }
|
||||
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
|
||||
rocket_ws = { version = "0.1.1" }
|
||||
|
||||
# WebSockets libraries
|
||||
rmpv = "1.3.0" # MessagePack library
|
||||
rmpv = "1.3.1" # MessagePack library
|
||||
|
||||
# Concurrent HashMap used for WebSocket messaging and favicons
|
||||
dashmap = "6.1.0"
|
||||
|
||||
# Async futures
|
||||
futures = "0.3.31"
|
||||
tokio = { version = "1.48.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
|
||||
tokio-util = { version = "0.7.16", features = ["compat"]}
|
||||
futures = "0.3.32"
|
||||
tokio = { version = "1.52.3", features = [
|
||||
"fs",
|
||||
"io-util",
|
||||
"net",
|
||||
"parking_lot",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"time",
|
||||
] }
|
||||
tokio-util = { version = "0.7.18", features = ["compat"] }
|
||||
|
||||
# A generic serialization/deserialization framework
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
serde_json = "1.0.149"
|
||||
|
||||
# A safe, extensible ORM and Query builder
|
||||
diesel = { version = "2.3.3", features = ["chrono", "r2d2", "numeric"] }
|
||||
diesel_migrations = "2.3.0"
|
||||
diesel = { version = "2.3.9", features = ["chrono", "r2d2", "numeric"] }
|
||||
diesel_migrations = "2.3.2"
|
||||
|
||||
derive_more = { version = "2.0.1", features = ["from", "into", "as_ref", "deref", "display"] }
|
||||
derive_more = { version = "2.1.1", features = [
|
||||
"as_ref",
|
||||
"deref",
|
||||
"display",
|
||||
"from",
|
||||
"into",
|
||||
] }
|
||||
diesel-derive-newtype = "2.1.2"
|
||||
|
||||
# Bundled/Static SQLite
|
||||
libsqlite3-sys = { version = "0.35.0", features = ["bundled"], optional = true }
|
||||
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
|
||||
libsqlite3-sys = { version = "0.37.0", optional = true }
|
||||
|
||||
# Crypto-related libraries
|
||||
rand = "0.9.2"
|
||||
rand = "0.10.1"
|
||||
ring = "0.17.14"
|
||||
rustls = { version = "0.23.40", features = ["ring", "std"], default-features = false }
|
||||
subtle = "2.6.1"
|
||||
|
||||
# UUID generation
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
uuid = { version = "1.23.1", features = ["v4"] }
|
||||
|
||||
# Date and time libraries
|
||||
chrono = { version = "0.4.42", features = ["clock", "serde"], default-features = false }
|
||||
chrono = { version = "0.4.44", default-features = false, features = ["clock", "serde"] }
|
||||
chrono-tz = "0.10.4"
|
||||
time = "0.3.44"
|
||||
time = "0.3.47"
|
||||
|
||||
# Job scheduler
|
||||
job_scheduler_ng = "2.4.0"
|
||||
|
||||
# Data encoding library Hex/Base32/Base64
|
||||
data-encoding = "2.9.0"
|
||||
data-encoding = "2.11.0"
|
||||
|
||||
# JWT library
|
||||
jsonwebtoken = "9.3.1"
|
||||
jsonwebtoken = { version = "10.4.0", default-features = false, features = ["rust_crypto", "use_pem"] }
|
||||
|
||||
# TOTP library
|
||||
totp-lite = "2.0.1"
|
||||
|
||||
# Yubico Library
|
||||
yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"], default-features = false }
|
||||
yubico = { package = "yubico_ng", version = "0.15.0", default-features = false, features = ["online-tokio"] }
|
||||
|
||||
# WebAuthn libraries
|
||||
# danger-allow-state-serialisation is needed to save the state in the db
|
||||
# danger-credential-internals is needed to support U2F to Webauthn migration
|
||||
webauthn-rs = { version = "0.5.3", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||
webauthn-rs-proto = "0.5.3"
|
||||
webauthn-rs-core = "0.5.3"
|
||||
webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||
webauthn-rs-proto = "0.5.5"
|
||||
webauthn-rs-core = "0.5.5"
|
||||
|
||||
# Handling of URL's for WebAuthn and favicons
|
||||
url = "2.5.7"
|
||||
url = "2.5.8"
|
||||
|
||||
# Email libraries
|
||||
lettre = { version = "0.11.19", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
|
||||
lettre = { version = "0.11.22", default-features = false, features = [
|
||||
# Misc
|
||||
"tracing",
|
||||
"serde",
|
||||
"builder",
|
||||
"hostname",
|
||||
# TLS/Security
|
||||
"ring",
|
||||
"rustls-native-certs",
|
||||
"tokio1-rustls",
|
||||
# Transport
|
||||
"smtp-transport",
|
||||
"sendmail-transport",
|
||||
] }
|
||||
percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
|
||||
email_address = "0.2.9"
|
||||
|
||||
# HTML Template library
|
||||
handlebars = { version = "6.3.2", features = ["dir_source"] }
|
||||
handlebars = { version = "6.4.0", features = ["dir_source"] }
|
||||
|
||||
# HTTP client (Used for favicons, version check, DUO and HIBP API)
|
||||
reqwest = { version = "0.12.24", 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"
|
||||
reqwest = { version = "0.13.3", default-features = false, features = [
|
||||
# Misc
|
||||
"charset",
|
||||
"cookies",
|
||||
"http2",
|
||||
"json",
|
||||
"form",
|
||||
"rustls-no-provider",
|
||||
"stream",
|
||||
# Compression
|
||||
"brotli",
|
||||
"deflate",
|
||||
"gzip",
|
||||
"zstd",
|
||||
# Proxy
|
||||
"socks",
|
||||
"system-proxy",
|
||||
] }
|
||||
hickory-resolver = "0.26.1"
|
||||
|
||||
# Favicon extraction libraries
|
||||
html5gum = "0.8.0"
|
||||
regex = { version = "1.12.2", features = ["std", "perf", "unicode-perl"], default-features = false }
|
||||
html5gum = "0.8.3"
|
||||
regex = { version = "1.12.3", default-features = false, features = [
|
||||
"perf",
|
||||
"std",
|
||||
"unicode-perl",
|
||||
] }
|
||||
data-url = "0.3.2"
|
||||
bytes = "1.10.1"
|
||||
svg-hush = "0.9.5"
|
||||
bytes = "1.11.1"
|
||||
svg-hush = "0.9.6"
|
||||
|
||||
# Cache function results (Used for version check and favicon fetching)
|
||||
cached = { version = "0.56.0", features = ["async"] }
|
||||
cached = { version = "0.59.0", features = ["async"] }
|
||||
|
||||
# Used for custom short lived cookie jar during favicon extraction
|
||||
cookie = "0.18.1"
|
||||
cookie_store = "0.22.0"
|
||||
cookie_store = "0.22.1"
|
||||
|
||||
# Used by U2F, JWT and PostgreSQL
|
||||
openssl = "0.10.74"
|
||||
openssl = "0.10.79"
|
||||
|
||||
# CLI argument parsing
|
||||
pico-args = "0.5.0"
|
||||
|
||||
# Macro ident concatenation
|
||||
pastey = "0.1.1"
|
||||
governor = "0.10.1"
|
||||
pastey = "0.2.2"
|
||||
governor = "0.10.4"
|
||||
|
||||
# OIDC for SSO
|
||||
openidconnect = { version = "4.0.1", features = ["reqwest", "native-tls"] }
|
||||
mini-moka = "0.10.3"
|
||||
openidconnect = { version = "4.0.1", default-features = false }
|
||||
moka = { version = "0.12.15", features = ["future"] }
|
||||
|
||||
# Check client versions for specific features.
|
||||
semver = "1.0.27"
|
||||
semver = "1.0.28"
|
||||
|
||||
# Allow overriding the default memory allocator
|
||||
# Mainly used for the musl builds, since the default musl malloc is very slow
|
||||
mimalloc = { version = "0.1.48", features = ["secure"], default-features = false, optional = true }
|
||||
mimalloc = { version = "0.1.50", optional = true, default-features = false, features = ["secure"] }
|
||||
|
||||
which = "8.0.0"
|
||||
which = "8.0.2"
|
||||
|
||||
# Argon2 library with support for the PHC format
|
||||
argon2 = "0.5.3"
|
||||
|
||||
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
|
||||
rpassword = "7.4.0"
|
||||
rpassword = "7.5.2"
|
||||
|
||||
# Loading a dynamic CSS Stylesheet
|
||||
grass_compiler = { version = "0.13.4", default-features = false }
|
||||
|
||||
# File are accessed through Apache OpenDAL
|
||||
opendal = { version = "0.54.1", features = ["services-fs"], default-features = false }
|
||||
opendal = { version = "0.56.0", default-features = false, features = ["services-fs"] }
|
||||
|
||||
# For retrieving AWS credentials, including temporary SSO credentials
|
||||
anyhow = { version = "1.0.100", optional = true }
|
||||
aws-config = { version = "1.8.8", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
|
||||
aws-credential-types = { version = "1.2.8", optional = true }
|
||||
aws-smithy-runtime-api = { version = "1.9.2", optional = true }
|
||||
http = { version = "1.3.1", optional = true }
|
||||
reqsign = { version = "0.16.5", optional = true }
|
||||
aws-config = { version = "1.8.16", optional = true, default-features = false, features = [
|
||||
"behavior-version-latest",
|
||||
"credentials-process",
|
||||
"rt-tokio",
|
||||
"sso",
|
||||
] }
|
||||
aws-credential-types = { version = "1.2.14", optional = true }
|
||||
aws-smithy-runtime-api = { version = "1.12.0", optional = true }
|
||||
http = { version = "1.4.0", optional = true }
|
||||
reqsign-aws-v4 = { version = "3.0.0", optional = true }
|
||||
reqsign-core = { version = "3.0.0", optional = true }
|
||||
|
||||
# Strip debuginfo from the release builds
|
||||
# The debug symbols are to provide better panic traces
|
||||
@@ -261,75 +327,74 @@ unsafe_code = "forbid"
|
||||
non_ascii_idents = "forbid"
|
||||
|
||||
# Deny
|
||||
deprecated_in_future = "deny"
|
||||
warnings = "deny" # Explicitly deny all warnings since we deny all warnings in the end
|
||||
|
||||
# Deny lint groups
|
||||
deprecated_safe = { level = "deny", priority = -1 }
|
||||
future_incompatible = { level = "deny", priority = -1 }
|
||||
keyword_idents = { level = "deny", priority = -1 }
|
||||
let_underscore = { level = "deny", priority = -1 }
|
||||
nonstandard_style = { level = "deny", priority = -1 }
|
||||
noop_method_call = "deny"
|
||||
refining_impl_trait = { level = "deny", priority = -1 }
|
||||
rust_2018_idioms = { level = "deny", priority = -1 }
|
||||
rust_2021_compatibility = { level = "deny", priority = -1 }
|
||||
rust_2024_compatibility = { level = "deny", priority = -1 }
|
||||
unused = { level = "deny", priority = -1 }
|
||||
|
||||
# Deny individual lints
|
||||
closure_returning_async_block = "deny"
|
||||
deprecated_in_future = "deny"
|
||||
single_use_lifetimes = "deny"
|
||||
trivial_casts = "deny"
|
||||
trivial_numeric_casts = "deny"
|
||||
unused = { level = "deny", priority = -1 }
|
||||
unused_import_braces = "deny"
|
||||
unused_lifetimes = "deny"
|
||||
unused_qualifications = "deny"
|
||||
variant_size_differences = "deny"
|
||||
# Allow the following lints since these cause issues with Rust v1.84.0 or newer
|
||||
# Building Vaultwarden with Rust v1.85.0 with edition 2024 also works without issues
|
||||
edition_2024_expr_fragment_specifier = "allow" # Once changed to Rust 2024 this should be removed and macro's should be validated again
|
||||
if_let_rescope = "allow"
|
||||
tail_expr_drop_order = "allow"
|
||||
|
||||
# https://rust-lang.github.io/rust-clippy/stable/index.html
|
||||
[workspace.lints.clippy]
|
||||
# Warn
|
||||
# Warn only so you can still use these during development, but not in the final code
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
# Ignore/Allow
|
||||
result_large_err = "allow"
|
||||
|
||||
# Deny
|
||||
# Warn on these lint group (Some might be warn by default already though)
|
||||
# Will be denied during CI!
|
||||
complexity = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
perf = { level = "warn", priority = -1 }
|
||||
style = { level = "warn", priority = -1 }
|
||||
suspicious = { level = "warn", priority = -1 }
|
||||
|
||||
# Deny individual lints
|
||||
branches_sharing_code = "deny"
|
||||
case_sensitive_file_extension_comparisons = "deny"
|
||||
cast_lossless = "deny"
|
||||
clone_on_ref_ptr = "deny"
|
||||
equatable_if_let = "deny"
|
||||
excessive_precision = "deny"
|
||||
filter_map_next = "deny"
|
||||
float_cmp_const = "deny"
|
||||
implicit_clone = "deny"
|
||||
inefficient_to_string = "deny"
|
||||
iter_on_empty_collections = "deny"
|
||||
iter_on_single_items = "deny"
|
||||
linkedlist = "deny"
|
||||
macro_use_imports = "deny"
|
||||
manual_assert = "deny"
|
||||
manual_instant_elapsed = "deny"
|
||||
manual_string_new = "deny"
|
||||
match_wildcard_for_single_variants = "deny"
|
||||
mem_forget = "deny"
|
||||
needless_borrow = "deny"
|
||||
needless_collect = "deny"
|
||||
needless_continue = "deny"
|
||||
needless_lifetimes = "deny"
|
||||
option_option = "deny"
|
||||
redundant_clone = "deny"
|
||||
string_add_assign = "deny"
|
||||
unnecessary_join = "deny"
|
||||
unnecessary_self_imports = "deny"
|
||||
unnested_or_patterns = "deny"
|
||||
unused_async = "deny"
|
||||
unused_self = "deny"
|
||||
useless_let_if_seq = "deny"
|
||||
verbose_file_reads = "deny"
|
||||
zero_sized_map_values = "deny"
|
||||
str_to_string = "deny"
|
||||
|
||||
# Pedantic Opt-Outs
|
||||
inline_always = "allow" # We use this sparsely
|
||||
struct_field_names = "allow" # Noisy and some items are Bitwarden controlled
|
||||
large_futures = "allow" # Causes a fail in some Rocket macro's, since we experience no issues, allow it
|
||||
too_many_lines = "allow" # For now, allow this, good to enable in the future and see if we can refactor
|
||||
unnecessary_wraps = "allow" # Too much false positives because of Rocket integrations
|
||||
# We do not use these doc items
|
||||
doc_link_with_quotes = "allow"
|
||||
doc_markdown = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -59,8 +59,9 @@ A nearly complete implementation of the Bitwarden Client API is provided, includ
|
||||
## Usage
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 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).
|
||||
> The web-vault requires the use of HTTPS and a secure context for the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). <br>
|
||||
> That means it will only work if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS). <br>
|
||||
> We also suggest to use a [reverse proxy](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples).
|
||||
|
||||
The recommended way to install and use Vaultwarden is via our container images which are published to [ghcr.io](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden), [docker.io](https://hub.docker.com/r/vaultwarden/server) and [quay.io](https://quay.io/repository/vaultwarden/server).
|
||||
See [which container image to use](https://github.com/dani-garcia/vaultwarden/wiki/Which-container-image-to-use) for an explanation of the provided tags.
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
use std::{env, io::Error, process::Command};
|
||||
|
||||
fn main() {
|
||||
// This allow using #[cfg(sqlite)] instead of #[cfg(feature = "sqlite")], which helps when trying to add them through macros
|
||||
#[cfg(feature = "sqlite")]
|
||||
// These allow using e.g. #[cfg(mysql)] instead of #[cfg(feature = "mysql")], which helps when trying to add them through macros
|
||||
#[cfg(feature = "sqlite_system")] // The `sqlite` feature implies this one.
|
||||
println!("cargo:rustc-cfg=sqlite");
|
||||
#[cfg(feature = "mysql")]
|
||||
println!("cargo:rustc-cfg=mysql");
|
||||
#[cfg(feature = "postgresql")]
|
||||
println!("cargo:rustc-cfg=postgresql");
|
||||
#[cfg(feature = "s3")]
|
||||
println!("cargo:rustc-cfg=s3");
|
||||
|
||||
#[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))]
|
||||
#[cfg(not(any(feature = "sqlite_system", feature = "mysql", feature = "postgresql")))]
|
||||
compile_error!(
|
||||
"You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite"
|
||||
);
|
||||
|
||||
#[cfg(feature = "s3")]
|
||||
println!("cargo:rustc-cfg=s3");
|
||||
|
||||
// Use check-cfg to let cargo know which cfg's we define,
|
||||
// and avoid warnings when they are used in the code.
|
||||
println!("cargo::rustc-check-cfg=cfg(sqlite)");
|
||||
@@ -42,13 +41,12 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Result<String, std::io::Error> {
|
||||
fn run(args: &[&str]) -> Result<String, Error> {
|
||||
let out = Command::new(args[0]).args(&args[1..]).output()?;
|
||||
if !out.status.success() {
|
||||
use std::io::Error;
|
||||
return Err(Error::other("Command not successful"));
|
||||
}
|
||||
Ok(String::from_utf8(out.stdout).unwrap().trim().to_string())
|
||||
Ok(String::from_utf8(out.stdout).unwrap().trim().to_owned())
|
||||
}
|
||||
|
||||
/// This method reads info from Git, namely tags, branch, and revision
|
||||
@@ -58,7 +56,7 @@ fn run(args: &[&str]) -> Result<String, std::io::Error> {
|
||||
/// - `env!("GIT_BRANCH")`
|
||||
/// - `env!("GIT_REV")`
|
||||
/// - `env!("VW_VERSION")`
|
||||
fn version_from_git_info() -> Result<String, std::io::Error> {
|
||||
fn version_from_git_info() -> Result<String, Error> {
|
||||
// The exact tag for the current commit, can be empty when
|
||||
// the current commit doesn't have an associated tag
|
||||
let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"]).ok();
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
# see diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/db/schema.rs"
|
||||
file = "src/db/schema.rs"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
vault_version: "v2025.10.1"
|
||||
vault_image_digest: "sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa"
|
||||
# Cross Compile Docker Helper Scripts v1.8.0
|
||||
vault_version: "v2026.4.1"
|
||||
vault_image_digest: "sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe"
|
||||
# Cross Compile Docker Helper Scripts v1.9.0
|
||||
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
||||
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
||||
xx_image_digest: "sha256:add602d55daca18914838a78221f6bbe4284114b452c86a48f96d59aeb00f5c6"
|
||||
rust_version: 1.91.0 # Rust version to be used
|
||||
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
||||
rust_version: 1.95.0 # Rust version to be used
|
||||
debian_version: trixie # Debian release name to be used
|
||||
alpine_version: "3.22" # Alpine version to be used
|
||||
alpine_version: "3.23" # Alpine version to be used
|
||||
# For which platforms/architectures will we try to build images
|
||||
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
|
||||
# Determine the build images per OS/Arch
|
||||
@@ -17,7 +17,6 @@ build_stage_image:
|
||||
platform: "$BUILDPLATFORM"
|
||||
alpine:
|
||||
image: "build_${TARGETARCH}${TARGETVARIANT}"
|
||||
platform: "linux/amd64" # The Alpine build images only have linux/amd64 images
|
||||
arch_image:
|
||||
amd64: "ghcr.io/blackdex/rust-musl:x86_64-musl-stable-{{rust_version}}"
|
||||
arm64: "ghcr.io/blackdex/rust-musl:aarch64-musl-stable-{{rust_version}}"
|
||||
|
||||
+13
-14
@@ -19,27 +19,27 @@
|
||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||
# click the tag name to view the digest of the image it currently points to.
|
||||
# - From the command line:
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2025.10.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.10.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa]
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.4.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa
|
||||
# [docker.io/vaultwarden/web-vault:v2025.10.1]
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe
|
||||
# [docker.io/vaultwarden/web-vault:v2026.4.1]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa AS vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault
|
||||
|
||||
########################## 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 linux/arm64
|
||||
## 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.91.0 AS build_amd64
|
||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.91.0 AS build_arm64
|
||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.91.0 AS build_armv7
|
||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.91.0 AS build_armv6
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.95.0 AS build_amd64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.95.0 AS build_arm64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.95.0 AS build_armv7
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.95.0 AS build_armv6
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# hadolint ignore=DL3006
|
||||
FROM --platform=linux/amd64 build_${TARGETARCH}${TARGETVARIANT} AS build
|
||||
FROM --platform=$BUILDPLATFORM build_${TARGETARCH}${TARGETVARIANT} AS build
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG TARGETPLATFORM
|
||||
@@ -57,7 +57,6 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
# Debian Trixie uses libpq v17
|
||||
PQ_LIB_DIR="/usr/local/musl/pq17/lib"
|
||||
|
||||
|
||||
# Create CARGO_HOME folder and don't download rust docs
|
||||
RUN mkdir -pv "${CARGO_HOME}" && \
|
||||
rustup set profile minimal
|
||||
@@ -127,7 +126,7 @@ RUN source /env-cargo && \
|
||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||
#
|
||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.22
|
||||
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.23
|
||||
|
||||
ENV ROCKET_PROFILE="release" \
|
||||
ROCKET_ADDRESS=0.0.0.0 \
|
||||
|
||||
+20
-43
@@ -19,24 +19,24 @@
|
||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||
# click the tag name to view the digest of the image it currently points to.
|
||||
# - From the command line:
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2025.10.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.10.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa]
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.4.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa
|
||||
# [docker.io/vaultwarden/web-vault:v2025.10.1]
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe
|
||||
# [docker.io/vaultwarden/web-vault:v2026.4.1]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa AS vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault
|
||||
|
||||
########################## Cross Compile Docker Helper Scripts ##########################
|
||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||
## And these bash scripts do not have any significant difference if at all
|
||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:add602d55daca18914838a78221f6bbe4284114b452c86a48f96d59aeb00f5c6 AS xx
|
||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# hadolint ignore=DL3006
|
||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.91.0-slim-trixie AS build
|
||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.95.0-slim-trixie AS build
|
||||
COPY --from=xx / /
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
@@ -51,8 +51,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
TERM=xterm-256color \
|
||||
CARGO_HOME="/root/.cargo" \
|
||||
USER="root"
|
||||
|
||||
# Install clang to get `xx-cargo` working
|
||||
# Install clang && xx-c-essentials to get `xx-cargo` working
|
||||
# Install pkg-config to allow amd64 builds to find all libraries
|
||||
# Install git so build.rs can determine the correct version
|
||||
# Install the libc cross packages based upon the debian-arch
|
||||
@@ -60,19 +59,16 @@ RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
--no-install-recommends \
|
||||
clang \
|
||||
pkg-config \
|
||||
git \
|
||||
"libc6-$(xx-info debian-arch)-cross" \
|
||||
"libc6-dev-$(xx-info debian-arch)-cross" \
|
||||
"linux-libc-dev-$(xx-info debian-arch)-cross" && \
|
||||
git && \
|
||||
xx-apt-get install -y \
|
||||
--no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
libpq5 \
|
||||
libssl-dev \
|
||||
libmariadb-dev \
|
||||
zlib1g-dev && \
|
||||
pkg-config \
|
||||
zlib1g-dev \
|
||||
xx-c-essentials && \
|
||||
# Run xx-cargo early, since it sometimes seems to break when run at a later stage
|
||||
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
|
||||
|
||||
@@ -84,29 +80,6 @@ RUN mkdir -pv "${CARGO_HOME}" && \
|
||||
RUN USER=root cargo new --bin /app
|
||||
WORKDIR /app
|
||||
|
||||
# Environment variables for Cargo on Debian based builds
|
||||
ARG TARGET_PKG_CONFIG_PATH
|
||||
|
||||
RUN source /env-cargo && \
|
||||
if xx-info is-cross ; then \
|
||||
# We can't use xx-cargo since that uses clang, which doesn't work for our libraries.
|
||||
# Because of this we generate the needed environment variables here which we can load in the needed steps.
|
||||
echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
||||
echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
||||
echo "export CROSS_COMPILE=1" >> /env-cargo && \
|
||||
echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \
|
||||
# For some architectures `xx-info` returns a triple which doesn't matches the path on disk
|
||||
# In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg
|
||||
if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \
|
||||
echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \
|
||||
else \
|
||||
echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \
|
||||
fi && \
|
||||
echo "# End of env-cargo" >> /env-cargo ; \
|
||||
fi && \
|
||||
# Output the current contents of the file
|
||||
cat /env-cargo
|
||||
|
||||
RUN source /env-cargo && \
|
||||
rustup target add "${CARGO_TARGET}"
|
||||
|
||||
@@ -123,7 +96,9 @@ ARG DB=sqlite,mysql,postgresql
|
||||
# dummy project, except the target folder
|
||||
# This folder contains the compiled dependencies
|
||||
RUN source /env-cargo && \
|
||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||
# Workaround for xx related build issues
|
||||
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
||||
find . -not -path "./target*" -delete
|
||||
|
||||
# Copies the complete project
|
||||
@@ -138,7 +113,9 @@ RUN source /env-cargo && \
|
||||
# Also do this for build.rs to ensure the version is rechecked
|
||||
touch build.rs src/main.rs && \
|
||||
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||
# Workaround for xx related build issues
|
||||
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
||||
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
||||
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
||||
else \
|
||||
@@ -175,7 +152,7 @@ RUN mkdir /data && \
|
||||
--no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libmariadb-dev \
|
||||
libmariadb3 \
|
||||
libpq5 \
|
||||
openssl && \
|
||||
apt-get clean && \
|
||||
|
||||
+27
-42
@@ -19,14 +19,19 @@
|
||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||
# click the tag name to view the digest of the image it currently points to.
|
||||
# - From the command line:
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:{{ vault_version }}
|
||||
# $ docker image inspect --format "{{ '{{' }}.RepoDigests}}" docker.io/vaultwarden/web-vault:{{ vault_version }}
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}
|
||||
# $ docker image inspect --format "{{ '{{' }}.RepoDigests}}" docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}
|
||||
# [docker.io/vaultwarden/web-vault@{{ vault_image_digest }}]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }}
|
||||
# [docker.io/vaultwarden/web-vault:{{ vault_version }}]
|
||||
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
|
||||
#
|
||||
{% macro xx_cargo_config() -%}
|
||||
# Workaround for xx related build issues
|
||||
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}"
|
||||
{%- endmacro %}
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
|
||||
|
||||
{% if base == "debian" %}
|
||||
@@ -36,16 +41,16 @@ FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_diges
|
||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@{{ xx_image_digest }} AS xx
|
||||
{% elif base == "alpine" %}
|
||||
########################## ALPINE BUILD IMAGES ##########################
|
||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64
|
||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||
## And for Alpine we define all build images here, they will only be loaded when actually used
|
||||
{% for arch in build_stage_image[base].arch_image %}
|
||||
FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].arch_image[arch] }} AS build_{{ arch }}
|
||||
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS build_{{ arch }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# hadolint ignore=DL3006
|
||||
FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].image }} AS build
|
||||
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
|
||||
{% if base == "debian" %}
|
||||
COPY --from=xx / /
|
||||
{% endif %}
|
||||
@@ -66,11 +71,10 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
# Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16
|
||||
# Debian Trixie uses libpq v17
|
||||
PQ_LIB_DIR="/usr/local/musl/pq17/lib"
|
||||
{% endif %}
|
||||
{%- endif %}
|
||||
|
||||
{% if base == "debian" %}
|
||||
|
||||
# Install clang to get `xx-cargo` working
|
||||
# Install clang && xx-c-essentials to get `xx-cargo` working
|
||||
# Install pkg-config to allow amd64 builds to find all libraries
|
||||
# Install git so build.rs can determine the correct version
|
||||
# Install the libc cross packages based upon the debian-arch
|
||||
@@ -78,19 +82,16 @@ RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
--no-install-recommends \
|
||||
clang \
|
||||
pkg-config \
|
||||
git \
|
||||
"libc6-$(xx-info debian-arch)-cross" \
|
||||
"libc6-dev-$(xx-info debian-arch)-cross" \
|
||||
"linux-libc-dev-$(xx-info debian-arch)-cross" && \
|
||||
git && \
|
||||
xx-apt-get install -y \
|
||||
--no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
libpq5 \
|
||||
libssl-dev \
|
||||
libmariadb-dev \
|
||||
zlib1g-dev && \
|
||||
pkg-config \
|
||||
zlib1g-dev \
|
||||
xx-c-essentials && \
|
||||
# Run xx-cargo early, since it sometimes seems to break when run at a later stage
|
||||
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
|
||||
{% endif %}
|
||||
@@ -103,31 +104,7 @@ RUN mkdir -pv "${CARGO_HOME}" && \
|
||||
RUN USER=root cargo new --bin /app
|
||||
WORKDIR /app
|
||||
|
||||
{% if base == "debian" %}
|
||||
# Environment variables for Cargo on Debian based builds
|
||||
ARG TARGET_PKG_CONFIG_PATH
|
||||
|
||||
RUN source /env-cargo && \
|
||||
if xx-info is-cross ; then \
|
||||
# We can't use xx-cargo since that uses clang, which doesn't work for our libraries.
|
||||
# Because of this we generate the needed environment variables here which we can load in the needed steps.
|
||||
echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
||||
echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
||||
echo "export CROSS_COMPILE=1" >> /env-cargo && \
|
||||
echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \
|
||||
# For some architectures `xx-info` returns a triple which doesn't matches the path on disk
|
||||
# In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg
|
||||
if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \
|
||||
echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \
|
||||
else \
|
||||
echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \
|
||||
fi && \
|
||||
echo "# End of env-cargo" >> /env-cargo ; \
|
||||
fi && \
|
||||
# Output the current contents of the file
|
||||
cat /env-cargo
|
||||
|
||||
{% elif base == "alpine" %}
|
||||
{% if base == "alpine" %}
|
||||
# Environment variables for Cargo on Alpine based builds
|
||||
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
|
||||
# Output the current contents of the file
|
||||
@@ -155,7 +132,11 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
|
||||
# dummy project, except the target folder
|
||||
# This folder contains the compiled dependencies
|
||||
RUN source /env-cargo && \
|
||||
{% if base == "debian" %}
|
||||
{{ xx_cargo_config() }} && \
|
||||
{% elif base == "alpine" %}
|
||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||
{% endif %}
|
||||
find . -not -path "./target*" -delete
|
||||
|
||||
# Copies the complete project
|
||||
@@ -170,7 +151,11 @@ RUN source /env-cargo && \
|
||||
# Also do this for build.rs to ensure the version is rechecked
|
||||
touch build.rs src/main.rs && \
|
||||
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
||||
{% if base == "debian" %}
|
||||
{{ xx_cargo_config() }} && \
|
||||
{% elif base == "alpine" %}
|
||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||
{% endif %}
|
||||
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
||||
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
||||
else \
|
||||
@@ -212,7 +197,7 @@ RUN mkdir /data && \
|
||||
--no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libmariadb-dev \
|
||||
libmariadb3 \
|
||||
libpq5 \
|
||||
openssl && \
|
||||
apt-get clean && \
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ path = "src/lib.rs"
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
quote = "1.0.41"
|
||||
syn = "2.0.108"
|
||||
quote = "1.0.45"
|
||||
syn = "2.0.117"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+5
-4
@@ -1,14 +1,15 @@
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{DeriveInput, parse_macro_input};
|
||||
|
||||
#[proc_macro_derive(UuidFromParam)]
|
||||
pub fn derive_uuid_from_param(input: TokenStream) -> TokenStream {
|
||||
let ast = syn::parse(input).unwrap();
|
||||
let ast = parse_macro_input!(input as DeriveInput);
|
||||
|
||||
impl_derive_uuid_macro(&ast)
|
||||
}
|
||||
|
||||
fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream {
|
||||
fn impl_derive_uuid_macro(ast: &DeriveInput) -> TokenStream {
|
||||
let name = &ast.ident;
|
||||
let gen_derive = quote! {
|
||||
#[automatically_derived]
|
||||
@@ -30,12 +31,12 @@ fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream {
|
||||
|
||||
#[proc_macro_derive(IdFromParam)]
|
||||
pub fn derive_id_from_param(input: TokenStream) -> TokenStream {
|
||||
let ast = syn::parse(input).unwrap();
|
||||
let ast = parse_macro_input!(input as DeriveInput);
|
||||
|
||||
impl_derive_safestring_macro(&ast)
|
||||
}
|
||||
|
||||
fn impl_derive_safestring_macro(ast: &syn::DeriveInput) -> TokenStream {
|
||||
fn impl_derive_safestring_macro(ast: &DeriveInput) -> TokenStream {
|
||||
let name = &ast.ident;
|
||||
let gen_derive = quote! {
|
||||
#[automatically_derived]
|
||||
|
||||
@@ -1,2 +1,15 @@
|
||||
ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`;
|
||||
-- Dynamically create DROP FOREIGN KEY
|
||||
-- Some versions of MySQL or MariaDB might fail if the key doesn't exists
|
||||
-- This checks if the key exists, and if so, will drop it.
|
||||
SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE
|
||||
CONSTRAINT_SCHEMA = DATABASE() AND
|
||||
TABLE_NAME = 'sso_users' AND
|
||||
CONSTRAINT_NAME = 'sso_users_ibfk_1' AND
|
||||
CONSTRAINT_TYPE = 'FOREIGN KEY') = true,
|
||||
'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @drop_sso_fk;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
DROP TABLE IF EXISTS sso_auth;
|
||||
|
||||
CREATE TABLE sso_nonce (
|
||||
state VARCHAR(512) NOT NULL PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
verifier TEXT,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
DROP TABLE IF EXISTS sso_nonce;
|
||||
|
||||
CREATE TABLE sso_auth (
|
||||
state VARCHAR(512) NOT NULL PRIMARY KEY,
|
||||
client_challenge TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_response TEXT,
|
||||
auth_response TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS archives;
|
||||
@@ -0,0 +1,10 @@
|
||||
DROP TABLE IF EXISTS archives;
|
||||
|
||||
CREATE TABLE archives (
|
||||
user_uuid CHAR(36) NOT NULL,
|
||||
cipher_uuid CHAR(36) NOT NULL,
|
||||
archived_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_uuid, cipher_uuid),
|
||||
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE,
|
||||
FOREIGN KEY (cipher_uuid) REFERENCES ciphers (uuid) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth DROP COLUMN binding_hash;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth DROP COLUMN code_response_error;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT;
|
||||
@@ -0,0 +1,9 @@
|
||||
DROP TABLE IF EXISTS sso_auth;
|
||||
|
||||
CREATE TABLE sso_nonce (
|
||||
state TEXT NOT NULL PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
verifier TEXT,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
DROP TABLE IF EXISTS sso_nonce;
|
||||
|
||||
CREATE TABLE sso_auth (
|
||||
state TEXT NOT NULL PRIMARY KEY,
|
||||
client_challenge TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_response TEXT,
|
||||
auth_response TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS archives;
|
||||
@@ -0,0 +1,8 @@
|
||||
DROP TABLE IF EXISTS archives;
|
||||
|
||||
CREATE TABLE archives (
|
||||
user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE,
|
||||
cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE,
|
||||
archived_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_uuid, cipher_uuid)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth DROP COLUMN binding_hash;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth DROP COLUMN IF EXISTS code_response_error;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth ADD COLUMN IF NOT EXISTS code_response_error TEXT;
|
||||
@@ -0,0 +1,9 @@
|
||||
DROP TABLE IF EXISTS sso_auth;
|
||||
|
||||
CREATE TABLE sso_nonce (
|
||||
state TEXT NOT NULL PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
verifier TEXT,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
DROP TABLE IF EXISTS sso_nonce;
|
||||
|
||||
CREATE TABLE sso_auth (
|
||||
state TEXT NOT NULL PRIMARY KEY,
|
||||
client_challenge TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_response TEXT,
|
||||
auth_response TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS archives;
|
||||
@@ -0,0 +1,8 @@
|
||||
DROP TABLE IF EXISTS archives;
|
||||
|
||||
CREATE TABLE archives (
|
||||
user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE,
|
||||
cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE,
|
||||
archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_uuid, cipher_uuid)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth DROP COLUMN binding_hash;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth DROP COLUMN code_response_error;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT;
|
||||
@@ -97,9 +97,9 @@ npx playwright codegen "http://127.0.0.1:8003"
|
||||
|
||||
## Override web-vault
|
||||
|
||||
It is possible to change the `web-vault` used by referencing a different `bw_web_builds` commit.
|
||||
It is possible to change the `web-vault` used by referencing a different `vw_web_builds` commit.
|
||||
|
||||
Simplest is to set and uncomment `PW_WV_REPO_URL` and `PW_WV_COMMIT_HASH` in the `test.env`.
|
||||
Simplest is to set and uncomment `PW_VW_REPO_URL` and `PW_VW_COMMIT_HASH` in the `test.env`.
|
||||
Ensure that the image is built with:
|
||||
|
||||
```bash
|
||||
@@ -112,6 +112,8 @@ You can check the result running:
|
||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
||||
```
|
||||
|
||||
Then check `http://127.0.0.1:8003/admin/diagnostics` with `admin`.
|
||||
|
||||
# OpenID Connect test setup
|
||||
|
||||
Additionally this `docker-compose` template allows to run locally Vaultwarden,
|
||||
|
||||
@@ -6,18 +6,19 @@ echo $COMMIT_HASH
|
||||
if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
|
||||
rm -rf /web-vault
|
||||
|
||||
mkdir bw_web_builds;
|
||||
cd bw_web_builds;
|
||||
mkdir -p vw_web_builds;
|
||||
cd vw_web_builds;
|
||||
|
||||
git -c init.defaultBranch=main init
|
||||
git remote add origin "$REPO_URL"
|
||||
git fetch --depth 1 origin "$COMMIT_HASH"
|
||||
git -c advice.detachedHead=false checkout FETCH_HEAD
|
||||
|
||||
export VAULT_VERSION=$(cat Dockerfile | grep "ARG VAULT_VERSION" | cut -d "=" -f2)
|
||||
./scripts/checkout_web_vault.sh
|
||||
./scripts/build_web_vault.sh
|
||||
printf '{"version":"%s"}' "$COMMIT_HASH" > ./web-vault/apps/web/build/vw-version.json
|
||||
npm ci --ignore-scripts
|
||||
|
||||
mv ./web-vault/apps/web/build /web-vault
|
||||
cd apps/web
|
||||
npm run dist:oss:selfhost
|
||||
printf '{"version":"%s"}' "$COMMIT_HASH" > build/vw-version.json
|
||||
|
||||
mv build /web-vault
|
||||
fi
|
||||
|
||||
@@ -18,10 +18,11 @@ services:
|
||||
context: compose/warden
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
REPO_URL: ${PW_WV_REPO_URL:-}
|
||||
COMMIT_HASH: ${PW_WV_COMMIT_HASH:-}
|
||||
REPO_URL: ${PW_VW_REPO_URL:-}
|
||||
COMMIT_HASH: ${PW_VW_COMMIT_HASH:-}
|
||||
env_file: ${DC_ENV_FILE:-.env}
|
||||
environment:
|
||||
- ADMIN_TOKEN
|
||||
- DATABASE_URL
|
||||
- I_REALLY_WANT_VOLATILE_STORAGE
|
||||
- LOG_LEVEL
|
||||
|
||||
@@ -221,9 +221,13 @@ export async function restartVault(page: Page, testInfo: TestInfo, env, resetDB:
|
||||
}
|
||||
|
||||
export async function checkNotification(page: Page, hasText: string) {
|
||||
await expect(page.locator('bit-toast').filter({ hasText })).toBeVisible();
|
||||
await page.locator('bit-toast').filter({ hasText }).getByRole('button').click();
|
||||
await expect(page.locator('bit-toast').filter({ hasText })).toHaveCount(0);
|
||||
await expect(page.locator('bit-toast', { hasText })).toBeVisible();
|
||||
try {
|
||||
await page.locator('bit-toast', { hasText }).getByRole('button', { name: 'Close' }).click({force: true, timeout: 10_000});
|
||||
} catch (error) {
|
||||
console.log(`Closing notification failed but it should now be invisible (${error})`);
|
||||
}
|
||||
await expect(page.locator('bit-toast', { hasText })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function cleanLanding(page: Page) {
|
||||
@@ -244,3 +248,15 @@ export async function logout(test: Test, page: Page, user: { name: string }) {
|
||||
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
export async function ignoreExtension(page: Page) {
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Add it later' }).click({timeout: 5_000});
|
||||
await page.getByRole('link', { name: 'Skip to web app' }).click();
|
||||
} catch (error) {
|
||||
console.log('Extension setup not visible. Continuing');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Generated
+126
-202
@@ -9,15 +9,15 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"mysql2": "3.15.0",
|
||||
"mysql2": "3.15.3",
|
||||
"otpauth": "9.4.1",
|
||||
"pg": "8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.55.1",
|
||||
"dotenv": "17.2.2",
|
||||
"@playwright/test": "1.56.1",
|
||||
"dotenv": "17.2.3",
|
||||
"dotenv-expand": "12.0.3",
|
||||
"maildev": "npm:@timshel_npm/maildev@^3.2.3"
|
||||
"maildev": "npm:@timshel_npm/maildev@3.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
@@ -34,16 +34,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "6.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.5.6.tgz",
|
||||
"integrity": "sha512-Mj3Hu9ymlsERd7WOsUKNUZnJYL4IZ/I9wVVYgtvOsWYiEFbkQ4G7VRIh2USxTVW4BBDIsLG+gBUgqOqf2Kvqow==",
|
||||
"version": "6.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.3.tgz",
|
||||
"integrity": "sha512-kiGFeY+Hxf5KbPpjRLf+ffWbkos1aGo8MBfd91oxS3O57RgU3XhZrt/6UzoVF9VMpWbC3v87SRc9jxGrc9qHtQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.1.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.2.1"
|
||||
"lru-cache": "^11.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
@@ -144,9 +144,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz",
|
||||
"integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==",
|
||||
"version": "1.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.15.tgz",
|
||||
"integrity": "sha512-q0p6zkVq2lJnmzZVPR33doA51G7YOja+FBvRdp5ISIthL0MtFCgYHHhR563z9WFGxcOn0WfjSkPDJ5Qig3H3Sw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -160,9 +160,6 @@
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
@@ -196,12 +193,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz",
|
||||
"integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==",
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
|
||||
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.55.1"
|
||||
"playwright": "1.56.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -249,12 +246,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz",
|
||||
"integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==",
|
||||
"version": "24.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
|
||||
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.12.0"
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
@@ -363,9 +360,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -637,9 +634,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
|
||||
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
|
||||
"dev": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -660,9 +657,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
|
||||
"integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -952,9 +949,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -992,9 +989,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -1340,20 +1337,20 @@
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "27.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz",
|
||||
"integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==",
|
||||
"version": "27.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz",
|
||||
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@asamuzakjp/dom-selector": "^6.5.4",
|
||||
"cssstyle": "^5.3.0",
|
||||
"@asamuzakjp/dom-selector": "^6.7.2",
|
||||
"cssstyle": "^5.3.1",
|
||||
"data-urls": "^6.0.0",
|
||||
"decimal.js": "^10.5.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"parse5": "^7.3.0",
|
||||
"parse5": "^8.0.0",
|
||||
"rrweb-cssom": "^0.8.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
@@ -1362,8 +1359,8 @@
|
||||
"webidl-conversions": "^8.0.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^15.0.0",
|
||||
"ws": "^8.18.2",
|
||||
"whatwg-url": "^15.1.0",
|
||||
"ws": "^8.18.3",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1426,9 +1423,9 @@
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz",
|
||||
"integrity": "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==",
|
||||
"version": "11.2.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
|
||||
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -1450,9 +1447,9 @@
|
||||
},
|
||||
"node_modules/maildev": {
|
||||
"name": "@timshel_npm/maildev",
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@timshel_npm/maildev/-/maildev-3.2.3.tgz",
|
||||
"integrity": "sha512-CNxMz4Obw7nL8MZbx4y1YUFeqqAQk+Qwm51tcBV5lRBotMlXKeYhuEcayb1v66nUwq832bUfKF4hyQpJixFZrw==",
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@timshel_npm/maildev/-/maildev-3.2.5.tgz",
|
||||
"integrity": "sha512-suWQu2s2kmO+MXtNJYW9peklznhd+aorIUb4tSNrfaKoEJjDa3vLXTvWf+3cb67o4Yv4Z6nPeKdMTCDZVn/Nyw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/mailparser": "3.4.6",
|
||||
@@ -1461,13 +1458,13 @@
|
||||
"commander": "14.0.1",
|
||||
"compression": "1.8.1",
|
||||
"cors": "2.8.5",
|
||||
"dompurify": "3.2.7",
|
||||
"dompurify": "3.3.0",
|
||||
"express": "5.1.0",
|
||||
"jsdom": "27.0.0",
|
||||
"mailparser": "3.7.4",
|
||||
"jsdom": "27.0.1",
|
||||
"mailparser": "3.7.5",
|
||||
"mime": "4.1.0",
|
||||
"nodemailer": "7.0.6",
|
||||
"smtp-server": "3.14.0",
|
||||
"nodemailer": "7.0.9",
|
||||
"smtp-server": "3.15.0",
|
||||
"socket.io": "4.8.1",
|
||||
"wildstring": "1.0.9"
|
||||
},
|
||||
@@ -1479,36 +1476,44 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "3.7.4",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.4.tgz",
|
||||
"integrity": "sha512-Beh4yyR4jLq3CZZ32asajByrXnW8dLyKCAQD3WvtTiBnMtFWhxO+wa93F6sJNjDmfjxXs4NRNjw3XAGLqZR3Vg==",
|
||||
"version": "3.7.5",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.5.tgz",
|
||||
"integrity": "sha512-o59RgZC+4SyCOn4xRH1mtRiZ1PbEmi6si6Ufnd3tbX/V9zmZN1qcqu8xbXY62H6CwIclOT3ppm5u/wV2nujn4g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"iconv-lite": "0.6.3",
|
||||
"iconv-lite": "0.7.0",
|
||||
"libmime": "5.3.7",
|
||||
"linkify-it": "5.0.0",
|
||||
"mailsplit": "5.4.5",
|
||||
"nodemailer": "7.0.4",
|
||||
"mailsplit": "5.4.6",
|
||||
"nodemailer": "7.0.9",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.259.0"
|
||||
"tlds": "1.260.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/nodemailer": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz",
|
||||
"integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==",
|
||||
"node_modules/mailparser/node_modules/iconv-lite": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/mailsplit": {
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.5.tgz",
|
||||
"integrity": "sha512-oMfhmvclR689IIaQmIcR5nODnZRRVwAKtqFT407TIvmhX2OLUBnshUTcxzQBt3+96sZVDud9NfSe1NxAkUNXEQ==",
|
||||
"version": "5.4.6",
|
||||
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.6.tgz",
|
||||
"integrity": "sha512-M+cqmzaPG/mEiCDmqQUz8L177JZLZmXAUpq38owtpq2xlXlTSw+kntnxRt2xsxVFFV6+T8Mj/U0l5s7s6e0rNw==",
|
||||
"deprecated": "This package has been renamed to @zone-eu/mailsplit. Please update your dependencies.",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
@@ -1595,9 +1600,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.0.tgz",
|
||||
"integrity": "sha512-tT6pomf5Z/I7Jzxu8sScgrYBMK9bUFWd7Kbo6Fs1L0M13OOIJ/ZobGKS3Z7tQ8Re4lj+LnLXIQVZZxa3fhYKzA==",
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
||||
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.1",
|
||||
"denque": "^2.1.0",
|
||||
@@ -1647,25 +1652,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||
@@ -1676,9 +1662,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz",
|
||||
"integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==",
|
||||
"version": "7.0.9",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||
"integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -1747,9 +1733,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
@@ -1793,13 +1779,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
||||
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
@@ -1892,20 +1877,13 @@
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.55.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
|
||||
"integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==",
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.55.1"
|
||||
"playwright-core": "1.56.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -1918,9 +1896,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.55.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
|
||||
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
@@ -1929,35 +1907,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
@@ -2049,34 +1998,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
|
||||
"integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
|
||||
"integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.7.0",
|
||||
"iconv-lite": "0.6.3",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body/node_modules/iconv-lite": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
@@ -2105,9 +2038,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/router/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -2205,9 +2138,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -2326,29 +2259,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/smtp-server": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.14.0.tgz",
|
||||
"integrity": "sha512-cEw/hdIY+xw1pkbQbQ23hvnm9kNABAsgYB+jJYGkzAynZxJ2VB9aqC6JhB1vpdDnqan7C7AL3qHYRGwz5eD6BQ==",
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.15.0.tgz",
|
||||
"integrity": "sha512-yv945vk0/xcukSKAoIhGz6GOlcXoCyGQH2w9IlLrTKk3SJiOBH9bcO6tD0ILTZYJsMqRa6OTRZAyqeuLXkv59Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"base32.js": "0.1.0",
|
||||
"ipv6-normalize": "1.0.1",
|
||||
"nodemailer": "7.0.3",
|
||||
"nodemailer": "7.0.9",
|
||||
"punycode.js": "2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/smtp-server/node_modules/nodemailer": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz",
|
||||
"integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
||||
@@ -2564,30 +2488,30 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tlds": {
|
||||
"version": "1.259.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz",
|
||||
"integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==",
|
||||
"version": "1.260.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.260.0.tgz",
|
||||
"integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tlds": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.16",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.16.tgz",
|
||||
"integrity": "sha512-5bdPHSwbKTeHmXrgecID4Ljff8rQjv7g8zKQPkCozRo2HWWni+p310FSn5ImI+9kWw9kK4lzOB5q/a6iv0IJsw==",
|
||||
"version": "7.0.17",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz",
|
||||
"integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.0.16"
|
||||
"tldts-core": "^7.0.17"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.0.16",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.16.tgz",
|
||||
"integrity": "sha512-XHhPmHxphLi+LGbH0G/O7dmUH9V65OY20R7vH8gETHsp5AZCjBk9l8sqmRKLaGOxnETU7XNSDUPtewAy/K6jbA==",
|
||||
"version": "7.0.17",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz",
|
||||
"integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
@@ -2644,9 +2568,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
|
||||
"integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.55.1",
|
||||
"dotenv": "17.2.2",
|
||||
"@playwright/test": "1.56.1",
|
||||
"dotenv": "17.2.3",
|
||||
"dotenv-expand": "12.0.3",
|
||||
"maildev": "npm:@timshel_npm/maildev@^3.2.3"
|
||||
"maildev": "npm:@timshel_npm/maildev@3.2.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"mysql2": "3.15.0",
|
||||
"mysql2": "3.15.3",
|
||||
"otpauth": "9.4.1",
|
||||
"pg": "8.16.3"
|
||||
}
|
||||
|
||||
+3
-2
@@ -55,6 +55,7 @@ ROCKET_PORT=8003
|
||||
DOMAIN=http://localhost:${ROCKET_PORT}
|
||||
LOG_LEVEL=info,oidcwarden::sso=debug
|
||||
LOGIN_RATELIMIT_MAX_BURST=100
|
||||
ADMIN_TOKEN=admin
|
||||
|
||||
SMTP_SECURITY=off
|
||||
SMTP_PORT=${MAILDEV_SMTP_PORT}
|
||||
@@ -67,8 +68,8 @@ SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
|
||||
SSO_DEBUG_TOKENS=true
|
||||
|
||||
# Custom web-vault build
|
||||
# PW_WV_REPO_URL=https://github.com/dani-garcia/bw_web_builds.git
|
||||
# PW_WV_COMMIT_HASH=a5f5390895516bce2f48b7baadb6dc399e5fe75a
|
||||
# PW_VW_REPO_URL=https://github.com/vaultwarden/vw_web_builds.git
|
||||
# PW_VW_COMMIT_HASH=b5f5b2157b9b64b5813bc334a75a277d0377b5d3
|
||||
|
||||
###########################
|
||||
# Docker MariaDb container#
|
||||
|
||||
@@ -91,6 +91,9 @@ test('2fa', async ({ page }) => {
|
||||
await page.getByLabel(/Verification code/).fill(code);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Add it later' }).click();
|
||||
await page.getByRole('link', { name: 'Skip to web app' }).click();
|
||||
|
||||
await expect(page).toHaveTitle(/Vaults/);
|
||||
})
|
||||
|
||||
|
||||
@@ -57,15 +57,17 @@ test('invited with new account', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
||||
|
||||
//await page.getByLabel('Name').fill(users.user2.name);
|
||||
await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password);
|
||||
await page.getByLabel('Confirm new master password (').fill(users.user2.password);
|
||||
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
||||
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
await utils.checkNotification(page, 'Your new account has been created');
|
||||
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
await utils.ignoreExtension(page);
|
||||
|
||||
// Redirected to the vault
|
||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||
await utils.checkNotification(page, 'You have been logged in!');
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
// await utils.checkNotification(page, 'You have been logged in!');
|
||||
});
|
||||
|
||||
await test.step('Check mails', async () => {
|
||||
@@ -91,9 +93,11 @@ test('invited with existing account', async ({ page }) => {
|
||||
await page.getByLabel('Master password').fill(users.user3.password);
|
||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
await utils.ignoreExtension(page);
|
||||
|
||||
// We are now in the default vault page
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
|
||||
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
|
||||
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string
|
||||
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
||||
await page.getByRole('link', { name: 'Security' }).click();
|
||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||
await page.locator('bit-item').filter({ hasText: 'Email Email Enter a code sent' }).getByRole('button').click();
|
||||
await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
|
||||
await page.getByLabel('Master password (required)').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.getByRole('button', { name: 'Send email' }).click();
|
||||
|
||||
@@ -33,19 +33,21 @@ export async function logNewUser(
|
||||
|
||||
await test.step('Create Vault account', async () => {
|
||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||
await page.getByLabel('New master password (required)', { exact: true }).fill(user.password);
|
||||
await page.getByLabel('Confirm new master password (').fill(user.password);
|
||||
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
||||
await page.getByLabel('Confirm master password (').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
});
|
||||
|
||||
await utils.checkNotification(page, 'Account successfully created!');
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
|
||||
await utils.ignoreExtension(page);
|
||||
|
||||
await test.step('Default vault page', async () => {
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
await utils.checkNotification(page, 'Account successfully created!');
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
|
||||
if( options.mailBuffer ){
|
||||
let mailBuffer = options.mailBuffer;
|
||||
await test.step('Check emails', async () => {
|
||||
@@ -115,6 +117,8 @@ export async function logUser(
|
||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||
});
|
||||
|
||||
await utils.ignoreExtension(page);
|
||||
|
||||
await test.step('Default vault page', async () => {
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||
|
||||
@@ -17,15 +17,16 @@ export async function createAccount(test, page: Page, user: { email: string, nam
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Vault finish Creation
|
||||
await page.getByLabel('New master password (required)', { exact: true }).fill(user.password);
|
||||
await page.getByLabel('Confirm new master password (').fill(user.password);
|
||||
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
||||
await page.getByLabel('Confirm master password (').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
|
||||
await utils.checkNotification(page, 'Your new account has been created')
|
||||
await utils.ignoreExtension(page);
|
||||
|
||||
// We are now in the default vault page
|
||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||
await utils.checkNotification(page, 'You have been logged in!');
|
||||
// await utils.checkNotification(page, 'You have been logged in!');
|
||||
|
||||
if( mailBuffer ){
|
||||
await mailBuffer.expect((m) => m.subject === "Welcome");
|
||||
@@ -45,6 +46,8 @@ export async function logUser(test, page: Page, user: { email: string, password:
|
||||
await page.getByLabel('Master password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||
|
||||
await utils.ignoreExtension(page);
|
||||
|
||||
// We are now in the default vault page
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
|
||||
|
||||
@@ -67,16 +67,17 @@ test('invited with new account', async ({ page }) => {
|
||||
|
||||
await test.step('Create Vault account', async () => {
|
||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||
await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password);
|
||||
await page.getByLabel('Confirm new master password (').fill(users.user2.password);
|
||||
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
||||
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
|
||||
await utils.checkNotification(page, 'Account successfully created!');
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
await utils.ignoreExtension(page);
|
||||
});
|
||||
|
||||
await test.step('Default vault page', async () => {
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
|
||||
await utils.checkNotification(page, 'Account successfully created!');
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
});
|
||||
|
||||
await test.step('Check mails', async () => {
|
||||
@@ -107,11 +108,13 @@ test('invited with existing account', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||
await page.getByLabel('Master password').fill(users.user3.password);
|
||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
await utils.ignoreExtension(page);
|
||||
});
|
||||
|
||||
await test.step('Default vault page', async () => {
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
});
|
||||
|
||||
await test.step('Check mails', async () => {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.91.0"
|
||||
channel = "1.95.0"
|
||||
components = [ "rustfmt", "clippy" ]
|
||||
profile = "minimal"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
max_width = 120
|
||||
newline_style = "Unix"
|
||||
use_small_heuristics = "Off"
|
||||
|
||||
+146
-111
@@ -2,39 +2,40 @@ use std::{env, sync::LazyLock};
|
||||
|
||||
use reqwest::Method;
|
||||
use rocket::{
|
||||
Catcher, Route,
|
||||
form::Form,
|
||||
http::{Cookie, CookieJar, MediaType, SameSite, Status},
|
||||
request::{FromRequest, Outcome, Request},
|
||||
response::{content::RawHtml as Html, Redirect},
|
||||
response::{Redirect, content::RawHtml as Html},
|
||||
serde::json::Json,
|
||||
Catcher, Route,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG, VERSION,
|
||||
api::{
|
||||
ApiResult, EmptyResult, JsonResult, Notify,
|
||||
core::{log_event, two_factor},
|
||||
unregister_push_device, ApiResult, EmptyResult, JsonResult, Notify,
|
||||
unregister_push_device,
|
||||
},
|
||||
auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp, Secure},
|
||||
auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
|
||||
config::ConfigBuilder,
|
||||
db::{
|
||||
backup_sqlite, get_sql_server_version,
|
||||
ACTIVE_DB_TYPE, DbConn, DbConnType, backup_sqlite, get_sql_server_version,
|
||||
models::{
|
||||
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
|
||||
MembershipType, OrgPolicy, OrgPolicyErr, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
||||
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
||||
},
|
||||
DbConn, DbConnType, ACTIVE_DB_TYPE,
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
http_client::make_http_request,
|
||||
mail,
|
||||
sso::FAKE_SSO_IDENTIFIER,
|
||||
util::{
|
||||
container_base_image, format_naive_datetime_local, get_display_size, get_web_vault_version,
|
||||
is_running_in_container, NumberOrString,
|
||||
FeatureFlagFilter, NumberOrString, container_base_image, format_naive_datetime_local, get_active_web_release,
|
||||
get_display_size, is_running_in_container, parse_experimental_client_feature_flags,
|
||||
},
|
||||
CONFIG, VERSION,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -92,8 +93,7 @@ static DB_TYPE: LazyLock<&str> = LazyLock::new(|| match ACTIVE_DB_TYPE.get() {
|
||||
});
|
||||
|
||||
#[cfg(sqlite)]
|
||||
static CAN_BACKUP: LazyLock<bool> =
|
||||
LazyLock::new(|| ACTIVE_DB_TYPE.get().map(|t| *t == DbConnType::Sqlite).unwrap_or(false));
|
||||
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| ACTIVE_DB_TYPE.get().is_some_and(|t| *t == DbConnType::Sqlite));
|
||||
#[cfg(not(sqlite))]
|
||||
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| false);
|
||||
|
||||
@@ -199,13 +199,7 @@ fn post_admin_login(
|
||||
}
|
||||
|
||||
// If the token is invalid, redirect to login page
|
||||
if !_validate_token(&data.token) {
|
||||
error!("Invalid admin token. IP: {}", ip.ip);
|
||||
Err(AdminResponse::Unauthorized(render_admin_login(
|
||||
Some("Invalid admin token, please try again."),
|
||||
redirect.as_deref(),
|
||||
)))
|
||||
} else {
|
||||
if validate_token(&data.token) {
|
||||
// If the token received is valid, generate JWT and save it as a cookie
|
||||
let claims = generate_admin_claims();
|
||||
let jwt = encode_jwt(&claims);
|
||||
@@ -223,10 +217,16 @@ fn post_admin_login(
|
||||
} else {
|
||||
Err(AdminResponse::Ok(render_admin_page()))
|
||||
}
|
||||
} else {
|
||||
error!("Invalid admin token. IP: {}", ip.ip);
|
||||
Err(AdminResponse::Unauthorized(render_admin_login(
|
||||
Some("Invalid admin token, please try again."),
|
||||
redirect.as_deref(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn _validate_token(token: &str) -> bool {
|
||||
fn validate_token(token: &str) -> bool {
|
||||
match CONFIG.admin_token().as_ref() {
|
||||
None => false,
|
||||
Some(t) if t.starts_with("$argon2") => {
|
||||
@@ -306,17 +306,14 @@ async fn get_user_or_404(user_id: &UserId, conn: &DbConn) -> ApiResult<User> {
|
||||
|
||||
#[post("/invite", format = "application/json", data = "<data>")]
|
||||
async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult {
|
||||
let data: InviteData = data.into_inner();
|
||||
if User::find_by_mail(&data.email, &conn).await.is_some() {
|
||||
err_code!("User already exists", Status::Conflict.code)
|
||||
}
|
||||
|
||||
let mut user = User::new(&data.email, None);
|
||||
|
||||
async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
|
||||
async fn generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
|
||||
if CONFIG.mail_enabled() {
|
||||
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
|
||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
||||
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
||||
FAKE_SSO_IDENTIFIER.into()
|
||||
} else {
|
||||
FAKE_ADMIN_UUID.into()
|
||||
};
|
||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into();
|
||||
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||
} else {
|
||||
let invitation = Invitation::new(&user.email);
|
||||
@@ -324,7 +321,14 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -
|
||||
}
|
||||
}
|
||||
|
||||
_generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
|
||||
let data: InviteData = data.into_inner();
|
||||
if User::find_by_mail(&data.email, &conn).await.is_some() {
|
||||
err_code!("User already exists", Status::Conflict.code)
|
||||
}
|
||||
|
||||
let mut user = User::new(&data.email, None);
|
||||
|
||||
generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
|
||||
user.save(&conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
|
||||
|
||||
Ok(Json(user.to_json(&conn).await))
|
||||
@@ -381,7 +385,7 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<Stri
|
||||
None => json!("Never"),
|
||||
};
|
||||
|
||||
usr["sso_identifier"] = json!(sso_u.map(|u| u.identifier.to_string()).unwrap_or(String::new()));
|
||||
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
|
||||
|
||||
users_json.push(usr);
|
||||
}
|
||||
@@ -464,15 +468,15 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
|
||||
|
||||
if CONFIG.push_enabled() {
|
||||
for device in Device::find_push_devices_by_user(&user.uuid, &conn).await {
|
||||
match unregister_push_device(&device.push_uuid).await {
|
||||
match unregister_push_device(device.push_uuid.as_ref()).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
user.reset_security_stamp();
|
||||
user.reset_security_stamp(&conn).await?;
|
||||
|
||||
user.save(&conn).await
|
||||
}
|
||||
@@ -480,14 +484,15 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
|
||||
#[post("/users/<user_id>/disable", format = "application/json")]
|
||||
async fn disable_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let mut user = get_user_or_404(&user_id, &conn).await?;
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
user.reset_security_stamp();
|
||||
user.reset_security_stamp(&conn).await?;
|
||||
user.enabled = false;
|
||||
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
nt.send_logout(&user, None, &conn).await;
|
||||
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
|
||||
save_result
|
||||
}
|
||||
|
||||
@@ -517,8 +522,12 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -
|
||||
}
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
|
||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
||||
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
||||
FAKE_SSO_IDENTIFIER.into()
|
||||
} else {
|
||||
FAKE_ADMIN_UUID.into()
|
||||
};
|
||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into();
|
||||
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||
} else {
|
||||
Ok(())
|
||||
@@ -544,9 +553,10 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
||||
err!("The specified user isn't member of the organization")
|
||||
};
|
||||
|
||||
let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
|
||||
Some(new_type) => new_type as i32,
|
||||
None => err!("Invalid type"),
|
||||
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
|
||||
new_type as i32
|
||||
} else {
|
||||
err!("Invalid type")
|
||||
};
|
||||
|
||||
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
||||
@@ -556,23 +566,9 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
||||
}
|
||||
}
|
||||
|
||||
member_to_edit.atype = new_type;
|
||||
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
|
||||
// It returns different error messages per function.
|
||||
if new_type < MembershipType::Admin {
|
||||
match OrgPolicy::is_user_allowed(&member_to_edit.user_uuid, &member_to_edit.org_uuid, true, &conn).await {
|
||||
Ok(_) => {}
|
||||
Err(OrgPolicyErr::TwoFactorMissing) => {
|
||||
if CONFIG.email_2fa_auto_fallback() {
|
||||
two_factor::email::find_and_activate_email_2fa(&member_to_edit.user_uuid, &conn).await?;
|
||||
} else {
|
||||
err!("You cannot modify this user to this type because they have not setup 2FA");
|
||||
}
|
||||
}
|
||||
Err(OrgPolicyErr::SingleOrgEnforced) => {
|
||||
err!("You cannot modify this user to this type because it is a member of an organization which forbids it");
|
||||
}
|
||||
}
|
||||
}
|
||||
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
|
||||
|
||||
log_event(
|
||||
EventType::OrganizationUserUpdated as i32,
|
||||
@@ -585,7 +581,6 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
||||
)
|
||||
.await;
|
||||
|
||||
member_to_edit.atype = new_type;
|
||||
member_to_edit.save(&conn).await
|
||||
}
|
||||
|
||||
@@ -652,7 +647,6 @@ use cached::proc_macro::cached;
|
||||
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
|
||||
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
|
||||
/// Any cache will be lost if Vaultwarden is restarted
|
||||
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.
|
||||
@@ -662,48 +656,66 @@ async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
||||
.await
|
||||
{
|
||||
Ok(r) => r.tag_name,
|
||||
_ => "-".to_string(),
|
||||
_ => "-".to_owned(),
|
||||
},
|
||||
match get_json_api::<GitCommit>("https://api.github.com/repos/dani-garcia/vaultwarden/commits/main").await {
|
||||
Ok(mut c) => {
|
||||
c.sha.truncate(8);
|
||||
c.sha
|
||||
}
|
||||
_ => "-".to_string(),
|
||||
_ => "-".to_owned(),
|
||||
},
|
||||
// Do not fetch the web-vault version when running within a container
|
||||
// The web-vault version is embedded within the container it self, and should not be updated manually
|
||||
match get_json_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest")
|
||||
.await
|
||||
{
|
||||
Ok(r) => r.tag_name.trim_start_matches('v').to_string(),
|
||||
_ => "-".to_string(),
|
||||
Ok(r) => r.tag_name.trim_start_matches('v').to_owned(),
|
||||
_ => "-".to_owned(),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
("-".to_string(), "-".to_string(), "-".to_string())
|
||||
("-".to_owned(), "-".to_owned(), "-".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_ntp_time(has_http_access: bool) -> String {
|
||||
if has_http_access {
|
||||
if let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
|
||||
for line in cf_trace.lines() {
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
if key == "ts" {
|
||||
let ts = value.split_once('.').map_or(value, |(s, _)| s);
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") {
|
||||
return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
||||
}
|
||||
break;
|
||||
}
|
||||
if has_http_access && let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
|
||||
for line in cf_trace.lines() {
|
||||
if let Some((key, value)) = line.split_once('=')
|
||||
&& key == "ts"
|
||||
{
|
||||
let ts = value.split_once('.').map_or(value, |(s, _)| s);
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") {
|
||||
return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from("Unable to fetch NTP time.")
|
||||
}
|
||||
|
||||
fn web_vault_compare(active: &str, latest: &str) -> i8 {
|
||||
use semver::Version;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
let active_semver = Version::parse(active).unwrap_or_else(|e| {
|
||||
warn!("Unable to parse active web-vault version '{active}': {e}");
|
||||
Version::parse("2025.1.1").unwrap()
|
||||
});
|
||||
let latest_semver = Version::parse(latest).unwrap_or_else(|e| {
|
||||
warn!("Unable to parse latest web-vault version '{latest}': {e}");
|
||||
Version::parse("2025.1.1").unwrap()
|
||||
});
|
||||
|
||||
match active_semver.cmp(&latest_semver) {
|
||||
Ordering::Less => -1,
|
||||
Ordering::Equal => 0,
|
||||
Ordering::Greater => 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/diagnostics")]
|
||||
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
|
||||
use chrono::prelude::*;
|
||||
@@ -720,35 +732,31 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
||||
// Check if we are able to resolve DNS entries
|
||||
let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) {
|
||||
Ok(Some(a)) => a.ip().to_string(),
|
||||
_ => "Unable to resolve domain name.".to_string(),
|
||||
_ => "Unable to resolve domain name.".to_owned(),
|
||||
};
|
||||
|
||||
let (latest_release, latest_commit, latest_web_build) = get_release_info(has_http_access).await;
|
||||
let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await;
|
||||
let active_web_release = get_active_web_release();
|
||||
let web_vault_compare = web_vault_compare(&active_web_release, &latest_web_release);
|
||||
|
||||
let ip_header_name = &ip_header.0.unwrap_or_default();
|
||||
|
||||
// Get current running versions
|
||||
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 invalid_feature_flags: Vec<String> = parse_experimental_client_feature_flags(
|
||||
&CONFIG.experimental_client_feature_flags(),
|
||||
&FeatureFlagFilter::InvalidOnly,
|
||||
)
|
||||
.into_keys()
|
||||
.collect();
|
||||
|
||||
let diagnostics_json = json!({
|
||||
"dns_resolved": dns_resolved,
|
||||
"current_release": VERSION,
|
||||
"latest_release": latest_release,
|
||||
"latest_commit": latest_commit,
|
||||
"latest_release": latest_vw_release,
|
||||
"latest_commit": latest_vw_commit,
|
||||
"web_vault_enabled": &CONFIG.web_vault_enabled(),
|
||||
"web_vault_version": web_vault_version,
|
||||
"latest_web_build": latest_web_build,
|
||||
"web_vault_pre_release": web_vault_pre_release,
|
||||
"active_web_release": active_web_release,
|
||||
"latest_web_release": latest_web_release,
|
||||
"web_vault_compare": web_vault_compare,
|
||||
"running_within_container": running_within_container,
|
||||
"container_base_image": if running_within_container { container_base_image() } else { "Not applicable" },
|
||||
"has_http_access": has_http_access,
|
||||
@@ -762,6 +770,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
||||
"db_version": get_sql_server_version(&conn).await,
|
||||
"admin_url": format!("{}/diagnostics", admin_url()),
|
||||
"overrides": &CONFIG.get_overrides().join(", "),
|
||||
"invalid_feature_flags": invalid_feature_flags,
|
||||
"host_arch": env::consts::ARCH,
|
||||
"host_os": env::consts::OS,
|
||||
"tz_env": env::var("TZ").unwrap_or_default(),
|
||||
@@ -823,33 +832,30 @@ impl<'r> FromRequest<'r> for AdminToken {
|
||||
type Error = &'static str;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let ip = match ClientIp::from_request(request).await {
|
||||
Outcome::Success(ip) => ip,
|
||||
_ => err_handler!("Error getting Client IP"),
|
||||
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||
err_handler!("Error getting Client IP")
|
||||
};
|
||||
|
||||
if !CONFIG.disable_admin_token() {
|
||||
let cookies = request.cookies();
|
||||
|
||||
let access_token = match cookies.get(COOKIE_NAME) {
|
||||
Some(cookie) => cookie.value(),
|
||||
None => {
|
||||
let requested_page =
|
||||
request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
|
||||
// When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page
|
||||
// Else, return a 401 failure, which will be caught
|
||||
if requested_page.is_empty() {
|
||||
return Outcome::Forward(Status::Unauthorized);
|
||||
} else {
|
||||
return Outcome::Error((Status::Unauthorized, "Unauthorized"));
|
||||
}
|
||||
let access_token = if let Some(cookie) = cookies.get(COOKIE_NAME) {
|
||||
cookie.value()
|
||||
} else {
|
||||
let requested_page =
|
||||
request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
|
||||
// When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page
|
||||
// Else, return a 401 failure, which will be caught
|
||||
if requested_page.is_empty() {
|
||||
return Outcome::Forward(Status::Unauthorized);
|
||||
}
|
||||
return Outcome::Error((Status::Unauthorized, "Unauthorized"));
|
||||
};
|
||||
|
||||
if decode_admin(access_token).is_err() {
|
||||
// Remove admin cookie
|
||||
cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
|
||||
error!("Invalid or expired admin JWT. IP: {}.", &ip.ip);
|
||||
error!("Invalid or expired admin JWT. IP: {}.", ip.ip);
|
||||
return Outcome::Error((Status::Unauthorized, "Session expired"));
|
||||
}
|
||||
}
|
||||
@@ -859,3 +865,32 @@ impl<'r> FromRequest<'r> for AdminToken {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_web_vault_compare() {
|
||||
// web_vault_compare(active, latest)
|
||||
// Test normal versions
|
||||
assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1);
|
||||
assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0);
|
||||
assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1);
|
||||
|
||||
// Test patched/+build.n versions
|
||||
// Newer latest version
|
||||
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1);
|
||||
assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1);
|
||||
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1);
|
||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1);
|
||||
// Equal versions
|
||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0);
|
||||
assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0);
|
||||
// Newer active version
|
||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1);
|
||||
assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1);
|
||||
assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1);
|
||||
assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1);
|
||||
}
|
||||
}
|
||||
|
||||
+183
-138
@@ -1,39 +1,41 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::db::DbPool;
|
||||
use chrono::Utc;
|
||||
use rocket::serde::json::Json;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
core::{accept_org_invite, log_user_event, two_factor::email},
|
||||
master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult,
|
||||
JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||
},
|
||||
auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers},
|
||||
crypto,
|
||||
db::{
|
||||
models::{
|
||||
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, EmergencyAccess,
|
||||
EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, OrgPolicy,
|
||||
OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
|
||||
},
|
||||
DbConn,
|
||||
},
|
||||
mail,
|
||||
util::{format_date, NumberOrString},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
use rocket::{
|
||||
http::Status,
|
||||
request::{FromRequest, Outcome, Request},
|
||||
serde::json::Json,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||
core::{accept_org_invite, log_user_event, two_factor::email},
|
||||
master_password_policy, register_push_device, unregister_push_device,
|
||||
},
|
||||
auth::{ClientHeaders, Headers, decode_delete, decode_invite, decode_verify_email},
|
||||
crypto,
|
||||
db::{
|
||||
DbConn, DbPool,
|
||||
models::{
|
||||
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest,
|
||||
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId,
|
||||
OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
|
||||
},
|
||||
},
|
||||
mail,
|
||||
util::{NumberOrString, deser_opt_nonempty_str, format_date},
|
||||
};
|
||||
|
||||
use super::{
|
||||
ciphers::{CipherData, update_cipher_from_data},
|
||||
sends::{SendData, update_send_from_data},
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<rocket::Route> {
|
||||
routes![
|
||||
register,
|
||||
profile,
|
||||
put_profile,
|
||||
post_profile,
|
||||
@@ -55,9 +57,9 @@ pub fn routes() -> Vec<rocket::Route> {
|
||||
delete_account,
|
||||
revision_date,
|
||||
password_hint,
|
||||
prelogin,
|
||||
post_prelogin,
|
||||
verify_password,
|
||||
api_key,
|
||||
post_api_key,
|
||||
rotate_api_key,
|
||||
get_known_device,
|
||||
get_all_devices,
|
||||
@@ -66,6 +68,7 @@ pub fn routes() -> Vec<rocket::Route> {
|
||||
put_device_token,
|
||||
put_clear_device_token,
|
||||
post_clear_device_token,
|
||||
get_tasks,
|
||||
post_auth_request,
|
||||
get_auth_request,
|
||||
put_auth_request,
|
||||
@@ -75,12 +78,16 @@ pub fn routes() -> Vec<rocket::Route> {
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KDFData {
|
||||
#[serde(alias = "kdfType")]
|
||||
kdf: i32,
|
||||
#[serde(alias = "iterations")]
|
||||
kdf_iterations: i32,
|
||||
#[serde(alias = "memory")]
|
||||
kdf_memory: Option<i32>,
|
||||
#[serde(alias = "parallelism")]
|
||||
kdf_parallelism: Option<i32>,
|
||||
}
|
||||
|
||||
@@ -102,7 +109,6 @@ pub struct RegisterData {
|
||||
|
||||
name: Option<String>,
|
||||
|
||||
#[allow(dead_code)]
|
||||
organization_user_id: Option<MembershipId>,
|
||||
|
||||
// Used only from the register/finish endpoint
|
||||
@@ -134,17 +140,17 @@ struct KeysData {
|
||||
}
|
||||
|
||||
/// Trims whitespace from password hints, and converts blank password hints to `None`.
|
||||
fn clean_password_hint(password_hint: &Option<String>) -> Option<String> {
|
||||
fn clean_password_hint(password_hint: Option<&String>) -> Option<String> {
|
||||
match password_hint {
|
||||
None => None,
|
||||
Some(h) => match h.trim() {
|
||||
"" => None,
|
||||
ht => Some(ht.to_string()),
|
||||
ht => Some(ht.to_owned()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce_password_hint_setting(password_hint: &Option<String>) -> EmptyResult {
|
||||
fn enforce_password_hint_setting(password_hint: Option<&String>) -> EmptyResult {
|
||||
if password_hint.is_some() && !CONFIG.password_hints_allowed() {
|
||||
err!("Password hints have been disabled by the administrator. Remove the hint and try again.");
|
||||
}
|
||||
@@ -163,12 +169,7 @@ async fn is_email_2fa_required(member_id: Option<MembershipId>, conn: &DbConn) -
|
||||
false
|
||||
}
|
||||
|
||||
#[post("/accounts/register", data = "<data>")]
|
||||
async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
||||
_register(data, false, conn).await
|
||||
}
|
||||
|
||||
pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn: DbConn) -> JsonResult {
|
||||
pub async fn register(data: Json<RegisterData>, email_verification: bool, conn: DbConn) -> JsonResult {
|
||||
let mut data: RegisterData = data.into_inner();
|
||||
let email = data.email.to_lowercase();
|
||||
|
||||
@@ -239,16 +240,16 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
||||
|
||||
// Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden)
|
||||
// This also prevents issues with very long usernames causing to large JWT's. See #2419
|
||||
if let Some(ref name) = data.name {
|
||||
if name.len() > 50 {
|
||||
err!("The field Name must be a string with a maximum length of 50.");
|
||||
}
|
||||
if let Some(ref name) = data.name
|
||||
&& name.len() > 50
|
||||
{
|
||||
err!("The field Name must be a string with a maximum length of 50.");
|
||||
}
|
||||
|
||||
// Check against the password hint setting here so if it fails, the user
|
||||
// can retry without losing their invitation below.
|
||||
let password_hint = clean_password_hint(&data.master_password_hint);
|
||||
enforce_password_hint_setting(&password_hint)?;
|
||||
let password_hint = clean_password_hint(data.master_password_hint.as_ref());
|
||||
enforce_password_hint_setting(password_hint.as_ref())?;
|
||||
|
||||
let mut user = match User::find_by_mail(&email, &conn).await {
|
||||
Some(user) => {
|
||||
@@ -297,7 +298,7 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
||||
|
||||
set_kdf_data(&mut user, &data.kdf)?;
|
||||
|
||||
user.set_password(&data.master_password_hash, Some(data.key), true, None);
|
||||
user.set_password(&data.master_password_hash, Some(data.key), true, None, &conn).await?;
|
||||
user.password_hint = password_hint;
|
||||
|
||||
// Add extra fields if present
|
||||
@@ -355,8 +356,8 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
||||
|
||||
// Check against the password hint setting here so if it fails,
|
||||
// the user can retry without losing their invitation below.
|
||||
let password_hint = clean_password_hint(&data.master_password_hint);
|
||||
enforce_password_hint_setting(&password_hint)?;
|
||||
let password_hint = clean_password_hint(data.master_password_hint.as_ref());
|
||||
enforce_password_hint_setting(password_hint.as_ref())?;
|
||||
|
||||
set_kdf_data(&mut user, &data.kdf)?;
|
||||
|
||||
@@ -365,7 +366,9 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
||||
Some(data.key),
|
||||
false,
|
||||
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
|
||||
);
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
user.password_hint = password_hint;
|
||||
|
||||
if let Some(keys) = data.keys {
|
||||
@@ -373,20 +376,19 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
||||
user.public_key = Some(keys.public_key);
|
||||
}
|
||||
|
||||
if let Some(identifier) = data.org_identifier {
|
||||
if identifier != crate::sso::FAKE_IDENTIFIER {
|
||||
let org = match Organization::find_by_uuid(&identifier.into(), &conn).await {
|
||||
None => err!("Failed to retrieve the associated organization"),
|
||||
Some(org) => org,
|
||||
};
|
||||
if let Some(identifier) = data.org_identifier
|
||||
&& identifier != crate::sso::FAKE_SSO_IDENTIFIER
|
||||
&& identifier != crate::api::admin::FAKE_ADMIN_UUID
|
||||
{
|
||||
let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else {
|
||||
err!("Failed to retrieve the associated organization")
|
||||
};
|
||||
|
||||
let membership = match Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await {
|
||||
None => err!("Failed to retrieve the invitation"),
|
||||
Some(org) => org,
|
||||
};
|
||||
let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await else {
|
||||
err!("Failed to retrieve the invitation")
|
||||
};
|
||||
|
||||
accept_org_invite(&user, membership, None, &conn).await?;
|
||||
}
|
||||
accept_org_invite(&user, membership, None, &conn).await?;
|
||||
}
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
@@ -401,8 +403,8 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
||||
user.save(&conn).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"Object": "set-password",
|
||||
"CaptchaBypassToken": "",
|
||||
"object": "set-password",
|
||||
"captchaBypassToken": "",
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -453,10 +455,10 @@ async fn put_avatar(data: Json<AvatarData>, headers: Headers, conn: DbConn) -> J
|
||||
// It looks like it only supports the 6 hex color format.
|
||||
// If you try to add the short value it will not show that color.
|
||||
// Check and force 7 chars, including the #.
|
||||
if let Some(color) = &data.avatar_color {
|
||||
if color.len() != 7 {
|
||||
err!("The field AvatarColor must be a HTML/Hex color code with a length of 7 characters")
|
||||
}
|
||||
if let Some(color) = &data.avatar_color
|
||||
&& color.len() != 7
|
||||
{
|
||||
err!("The field AvatarColor must be a HTML/Hex color code with a length of 7 characters")
|
||||
}
|
||||
|
||||
let mut user = headers.user;
|
||||
@@ -517,8 +519,8 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
|
||||
err!("Invalid password")
|
||||
}
|
||||
|
||||
user.password_hint = clean_password_hint(&data.master_password_hint);
|
||||
enforce_password_hint_setting(&user.password_hint)?;
|
||||
user.password_hint = clean_password_hint(data.master_password_hint.as_ref());
|
||||
enforce_password_hint_setting(user.password_hint.as_ref())?;
|
||||
|
||||
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||
.await;
|
||||
@@ -533,29 +535,20 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
|
||||
String::from("get_public_keys"),
|
||||
String::from("get_api_webauthn"),
|
||||
]),
|
||||
);
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
// Prevent logging out the client where the user requested this endpoint from.
|
||||
// If you do logout the user it will causes issues at the client side.
|
||||
// Adding the device uuid will prevent this.
|
||||
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
|
||||
nt.send_logout(&user, Some(&headers.device), &conn).await;
|
||||
|
||||
save_result
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ChangeKdfData {
|
||||
#[serde(flatten)]
|
||||
kdf: KDFData,
|
||||
|
||||
master_password_hash: String,
|
||||
new_master_password_hash: String,
|
||||
key: String,
|
||||
}
|
||||
|
||||
fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult {
|
||||
if data.kdf == UserKdfType::Pbkdf2 as i32 && data.kdf_iterations < 100_000 {
|
||||
err!("PBKDF2 KDF iterations must be at least 100000.")
|
||||
@@ -591,21 +584,65 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AuthenticationData {
|
||||
salt: String,
|
||||
kdf: KDFData,
|
||||
master_password_authentication_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UnlockData {
|
||||
salt: String,
|
||||
kdf: KDFData,
|
||||
master_key_wrapped_user_key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ChangeKdfData {
|
||||
#[allow(dead_code)]
|
||||
new_master_password_hash: String,
|
||||
#[allow(dead_code)]
|
||||
key: String,
|
||||
authentication_data: AuthenticationData,
|
||||
unlock_data: UnlockData,
|
||||
master_password_hash: String,
|
||||
}
|
||||
|
||||
#[post("/accounts/kdf", data = "<data>")]
|
||||
async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let data: ChangeKdfData = data.into_inner();
|
||||
let mut user = headers.user;
|
||||
|
||||
if !user.check_valid_password(&data.master_password_hash) {
|
||||
if !headers.user.check_valid_password(&data.master_password_hash) {
|
||||
err!("Invalid password")
|
||||
}
|
||||
|
||||
set_kdf_data(&mut user, &data.kdf)?;
|
||||
if data.authentication_data.kdf != data.unlock_data.kdf {
|
||||
err!("KDF settings must be equal for authentication and unlock")
|
||||
}
|
||||
|
||||
user.set_password(&data.new_master_password_hash, Some(data.key), true, None);
|
||||
if headers.user.email != data.authentication_data.salt || headers.user.email != data.unlock_data.salt {
|
||||
err!("Invalid master password salt")
|
||||
}
|
||||
|
||||
let mut user = headers.user;
|
||||
|
||||
set_kdf_data(&mut user, &data.unlock_data.kdf)?;
|
||||
|
||||
user.set_password(
|
||||
&data.authentication_data.master_password_authentication_hash,
|
||||
Some(data.unlock_data.master_key_wrapped_user_key),
|
||||
true,
|
||||
None,
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
|
||||
nt.send_logout(&user, Some(&headers.device), &conn).await;
|
||||
|
||||
save_result
|
||||
}
|
||||
@@ -616,6 +653,7 @@ struct UpdateFolderData {
|
||||
// There is a bug in 2024.3.x which adds a `null` item.
|
||||
// To bypass this we allow a Option here, but skip it during the updates
|
||||
// See: https://github.com/bitwarden/clients/issues/8453
|
||||
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||
id: Option<FolderId>,
|
||||
name: String,
|
||||
}
|
||||
@@ -634,9 +672,6 @@ struct UpdateResetPasswordData {
|
||||
reset_password_key: String,
|
||||
}
|
||||
|
||||
use super::ciphers::CipherData;
|
||||
use super::sends::{update_send_from_data, SendData};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KeyData {
|
||||
@@ -806,7 +841,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
};
|
||||
|
||||
saved_folder.name = folder_data.name;
|
||||
saved_folder.save(&conn).await?
|
||||
saved_folder.save(&conn).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -819,7 +854,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
};
|
||||
|
||||
saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted);
|
||||
saved_emergency_access.save(&conn).await?
|
||||
saved_emergency_access.save(&conn).await?;
|
||||
}
|
||||
|
||||
// Update reset password data
|
||||
@@ -831,7 +866,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
};
|
||||
|
||||
membership.reset_password_key = Some(reset_password_data.reset_password_key);
|
||||
membership.save(&conn).await?
|
||||
membership.save(&conn).await?;
|
||||
}
|
||||
|
||||
// Update send data
|
||||
@@ -844,8 +879,6 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
}
|
||||
|
||||
// Update cipher data
|
||||
use super::ciphers::update_cipher_from_data;
|
||||
|
||||
for cipher_data in data.account_data.ciphers {
|
||||
if cipher_data.organization_id.is_none() {
|
||||
let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap())
|
||||
@@ -856,7 +889,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
// Prevent triggering cipher updates via WebSockets by settings UpdateType::None
|
||||
// The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues.
|
||||
// We force the users to logout after the user has been saved to try and prevent these issues.
|
||||
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?
|
||||
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -869,14 +902,16 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key),
|
||||
true,
|
||||
None,
|
||||
);
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
// Prevent logging out the client where the user requested this endpoint from.
|
||||
// If you do logout the user it will causes issues at the client side.
|
||||
// Adding the device uuid will prevent this.
|
||||
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
|
||||
nt.send_logout(&user, Some(&headers.device), &conn).await;
|
||||
|
||||
save_result
|
||||
}
|
||||
@@ -888,12 +923,13 @@ async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbCo
|
||||
|
||||
data.validate(&user, true, &conn).await?;
|
||||
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
user.reset_security_stamp();
|
||||
user.reset_security_stamp(&conn).await?;
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
nt.send_logout(&user, None, &conn).await;
|
||||
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
|
||||
save_result
|
||||
}
|
||||
|
||||
@@ -983,24 +1019,22 @@ async fn post_email(data: Json<ChangeEmailData>, headers: Headers, conn: DbConn,
|
||||
err!("Email already in use");
|
||||
}
|
||||
|
||||
match user.email_new {
|
||||
Some(ref val) => {
|
||||
if val != &data.new_email {
|
||||
err!("Email change mismatch");
|
||||
}
|
||||
if let Some(ref val) = user.email_new {
|
||||
if val != &data.new_email {
|
||||
err!("Email change mismatch");
|
||||
}
|
||||
None => err!("No email change pending"),
|
||||
} else {
|
||||
err!("No email change pending")
|
||||
}
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
// Only check the token if we sent out an email...
|
||||
match user.email_new_token {
|
||||
Some(ref val) => {
|
||||
if *val != data.token.into_string() {
|
||||
err!("Token mismatch");
|
||||
}
|
||||
if let Some(ref val) = user.email_new_token {
|
||||
if *val != data.token.into_string() {
|
||||
err!("Token mismatch");
|
||||
}
|
||||
None => err!("No email change pending"),
|
||||
} else {
|
||||
err!("No email change pending")
|
||||
}
|
||||
user.verified_at = Some(Utc::now().naive_utc());
|
||||
} else {
|
||||
@@ -1011,7 +1045,7 @@ async fn post_email(data: Json<ChangeEmailData>, headers: Headers, conn: DbConn,
|
||||
user.email_new = None;
|
||||
user.email_new_token = None;
|
||||
|
||||
user.set_password(&data.new_master_password_hash, Some(data.key), true, None);
|
||||
user.set_password(&data.new_master_password_hash, Some(data.key), true, None, &conn).await?;
|
||||
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
@@ -1077,10 +1111,10 @@ async fn post_delete_recover(data: Json<DeleteRecoverData>, conn: DbConn) -> Emp
|
||||
let data: DeleteRecoverData = data.into_inner();
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
if let Some(user) = User::find_by_mail(&data.email, &conn).await {
|
||||
if let Err(e) = mail::send_delete_account(&user.email, &user.uuid).await {
|
||||
error!("Error sending delete account email: {e:#?}");
|
||||
}
|
||||
if let Some(user) = User::find_by_mail(&data.email, &conn).await
|
||||
&& let Err(e) = mail::send_delete_account(&user.email, &user.uuid).await
|
||||
{
|
||||
error!("Error sending delete account email: {e:#?}");
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -1132,6 +1166,7 @@ async fn delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: D
|
||||
user.delete(&conn).await
|
||||
}
|
||||
|
||||
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
||||
#[get("/accounts/revision-date")]
|
||||
fn revision_date(headers: Headers) -> JsonResult {
|
||||
let revision_date = headers.user.updated_at.and_utc().timestamp_millis();
|
||||
@@ -1146,12 +1181,12 @@ struct PasswordHintData {
|
||||
|
||||
#[post("/accounts/password-hint", data = "<data>")]
|
||||
async fn password_hint(data: Json<PasswordHintData>, conn: DbConn) -> EmptyResult {
|
||||
const NO_HINT: &str = "Sorry, you have no password hint...";
|
||||
|
||||
if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) {
|
||||
err!("This server is not configured to provide password hints.");
|
||||
}
|
||||
|
||||
const NO_HINT: &str = "Sorry, you have no password hint...";
|
||||
|
||||
let data: PasswordHintData = data.into_inner();
|
||||
let email = &data.email;
|
||||
|
||||
@@ -1162,10 +1197,9 @@ async fn password_hint(data: Json<PasswordHintData>, conn: DbConn) -> EmptyResul
|
||||
// There is still a timing side channel here in that the code
|
||||
// paths that send mail take noticeably longer than ones that
|
||||
// don't. Add a randomized sleep to mitigate this somewhat.
|
||||
use rand::{rngs::SmallRng, Rng, SeedableRng};
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
let delta: i32 = 100;
|
||||
let sleep_ms = (1_000 + rng.random_range(-delta..=delta)) as u64;
|
||||
use rand::{RngExt, rngs::SmallRng};
|
||||
let mut rng: SmallRng = rand::make_rng();
|
||||
let sleep_ms: u64 = rng.random_range(900..=1100);
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -1193,11 +1227,11 @@ pub struct PreloginData {
|
||||
}
|
||||
|
||||
#[post("/accounts/prelogin", data = "<data>")]
|
||||
async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||
_prelogin(data, conn).await
|
||||
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||
prelogin(data, conn).await
|
||||
}
|
||||
|
||||
pub async fn _prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||
pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||
let data: PreloginData = data.into_inner();
|
||||
|
||||
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
|
||||
@@ -1224,7 +1258,7 @@ struct SecretVerificationRequest {
|
||||
pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &DbConn) -> ApiResult<()> {
|
||||
if user.password_iterations < CONFIG.password_iterations() {
|
||||
user.password_iterations = CONFIG.password_iterations();
|
||||
user.set_password(pwd_hash, None, false, None);
|
||||
user.set_password(pwd_hash, None, false, None, conn).await?;
|
||||
|
||||
if let Err(e) = user.save(conn).await {
|
||||
error!("Error updating user: {e:#?}");
|
||||
@@ -1247,9 +1281,7 @@ async fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers
|
||||
Ok(Json(master_password_policy(&user, &conn).await))
|
||||
}
|
||||
|
||||
async fn _api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
use crate::util::format_date;
|
||||
|
||||
async fn update_api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let data: PasswordOrOtpData = data.into_inner();
|
||||
let mut user = headers.user;
|
||||
|
||||
@@ -1268,13 +1300,13 @@ async fn _api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers,
|
||||
}
|
||||
|
||||
#[post("/accounts/api-key", data = "<data>")]
|
||||
async fn api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
_api_key(data, false, headers, conn).await
|
||||
async fn post_api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
update_api_key(data, false, headers, conn).await
|
||||
}
|
||||
|
||||
#[post("/accounts/rotate-api-key", data = "<data>")]
|
||||
async fn rotate_api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
_api_key(data, true, headers, conn).await
|
||||
update_api_key(data, true, headers, conn).await
|
||||
}
|
||||
|
||||
#[get("/devices/knowndevice")]
|
||||
@@ -1298,6 +1330,11 @@ impl<'r> FromRequest<'r> for KnownDevice {
|
||||
|
||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let email = if let Some(email_b64) = req.headers().get_one("X-Request-Email") {
|
||||
// Bitwarden seems to send padded Base64 strings since 2026.2.1
|
||||
// Since these values are not streamed and Headers are always split by newlines
|
||||
// we can safely ignore padding here and remove any '=' appended.
|
||||
let email_b64 = email_b64.trim_end_matches('=');
|
||||
|
||||
let Ok(email_bytes) = data_encoding::BASE64URL_NOPAD.decode(email_b64.as_bytes()) else {
|
||||
return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as base64url"));
|
||||
};
|
||||
@@ -1312,7 +1349,7 @@ impl<'r> FromRequest<'r> for KnownDevice {
|
||||
};
|
||||
|
||||
let uuid = if let Some(uuid) = req.headers().get_one("X-Device-Identifier") {
|
||||
uuid.to_string().into()
|
||||
uuid.to_owned().into()
|
||||
} else {
|
||||
return Outcome::Error((Status::BadRequest, "X-Device-Identifier value is required"));
|
||||
};
|
||||
@@ -1327,7 +1364,7 @@ impl<'r> FromRequest<'r> for KnownDevice {
|
||||
#[get("/devices")]
|
||||
async fn get_all_devices(headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let devices = Device::find_with_auth_request_by_user(&headers.user.uuid, &conn).await;
|
||||
let devices = devices.iter().map(|device| device.to_json()).collect::<Vec<Value>>();
|
||||
let devices = devices.iter().map(DeviceWithAuthRequest::to_json).collect::<Vec<Value>>();
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": devices,
|
||||
@@ -1373,7 +1410,7 @@ async fn put_device_token(device_id: DeviceId, data: Json<PushToken>, headers: H
|
||||
}
|
||||
|
||||
device.push_token = Some(token);
|
||||
if let Err(e) = device.save(&conn).await {
|
||||
if let Err(e) = device.save(true, &conn).await {
|
||||
err!(format!("An error occurred while trying to save the device push token: {e}"));
|
||||
}
|
||||
|
||||
@@ -1397,7 +1434,7 @@ async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResul
|
||||
|
||||
if let Some(device) = Device::find_by_uuid(&device_id, &conn).await {
|
||||
Device::clear_push_token_by_uuid(&device_id, &conn).await?;
|
||||
unregister_push_device(&device.push_uuid).await?;
|
||||
unregister_push_device(device.push_uuid.as_ref()).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1409,6 +1446,14 @@ async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResu
|
||||
put_clear_device_token(device_id, conn).await
|
||||
}
|
||||
|
||||
#[get("/tasks")]
|
||||
fn get_tasks(_client_headers: ClientHeaders) -> JsonResult {
|
||||
Ok(Json(json!({
|
||||
"data": [],
|
||||
"object": "list"
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AuthRequestRequest {
|
||||
@@ -1659,6 +1704,6 @@ pub async fn purge_auth_requests(pool: DbPool) {
|
||||
if let Ok(conn) = pool.get().await {
|
||||
AuthRequest::purge_expired_auth_requests(&conn).await;
|
||||
} else {
|
||||
error!("Failed to get DB connection while purging trashed ciphers")
|
||||
error!("Failed to get DB connection while purging auth requests");
|
||||
}
|
||||
}
|
||||
|
||||
+396
-188
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,23 @@
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use rocket::{serde::json::Json, Route};
|
||||
use rocket::{Route, serde::json::Json};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
core::{CipherSyncData, CipherSyncType},
|
||||
EmptyResult, JsonResult,
|
||||
core::{CipherSyncData, CipherSyncType},
|
||||
},
|
||||
auth::{decode_emergency_access_invite, Headers},
|
||||
auth::{Headers, decode_emergency_access_invite},
|
||||
db::{
|
||||
DbConn, DbPool,
|
||||
models::{
|
||||
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
|
||||
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
||||
},
|
||||
DbConn, DbPool,
|
||||
},
|
||||
mail,
|
||||
util::NumberOrString,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -47,28 +47,15 @@ pub fn routes() -> Vec<Route> {
|
||||
|
||||
#[get("/emergency-access/trusted")]
|
||||
async fn get_contacts(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
if !CONFIG.emergency_access_allowed() {
|
||||
return Json(json!({
|
||||
"data": [{
|
||||
"id": "",
|
||||
"status": 2,
|
||||
"type": 0,
|
||||
"waitTimeDays": 0,
|
||||
"granteeId": "",
|
||||
"email": "",
|
||||
"name": "NOTE: Emergency Access is disabled!",
|
||||
"object": "emergencyAccessGranteeDetails",
|
||||
|
||||
}],
|
||||
"object": "list",
|
||||
"continuationToken": null
|
||||
}));
|
||||
}
|
||||
let emergency_access_list = EmergencyAccess::find_all_by_grantor_uuid(&headers.user.uuid, &conn).await;
|
||||
let emergency_access_list = if CONFIG.emergency_access_allowed() {
|
||||
EmergencyAccess::find_all_by_grantor_uuid(&headers.user.uuid, &conn).await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
|
||||
for ea in emergency_access_list {
|
||||
if let Some(grantee) = ea.to_json_grantee_details(&conn).await {
|
||||
emergency_access_list_json.push(grantee)
|
||||
emergency_access_list_json.push(grantee);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,11 +89,14 @@ async fn get_grantees(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await {
|
||||
Some(emergency_access) => Ok(Json(
|
||||
if let Some(emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||
{
|
||||
Ok(Json(
|
||||
emergency_access.to_json_grantee_details(&conn).await.expect("Grantee user should exist but does not!"),
|
||||
)),
|
||||
None => err!("Emergency access not valid."),
|
||||
))
|
||||
} else {
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,9 +139,10 @@ async fn post_emergency_access(
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||
Some(new_type) => new_type as i32,
|
||||
None => err!("Invalid emergency access type."),
|
||||
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||
new_type as i32
|
||||
} else {
|
||||
err!("Invalid emergency access type.")
|
||||
};
|
||||
|
||||
emergency_access.atype = new_type;
|
||||
@@ -218,9 +209,10 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
||||
|
||||
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
||||
|
||||
let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||
Some(new_type) => new_type as i32,
|
||||
None => err!("Invalid emergency access type."),
|
||||
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||
new_type as i32
|
||||
} else {
|
||||
err!("Invalid emergency access type.")
|
||||
};
|
||||
|
||||
let grantor_user = headers.user;
|
||||
@@ -355,12 +347,11 @@ async fn accept_invite(
|
||||
err!("Claim email does not match current users email")
|
||||
}
|
||||
|
||||
let grantee_user = match User::find_by_mail(&claims.email, &conn).await {
|
||||
Some(user) => {
|
||||
Invitation::take(&claims.email, &conn).await;
|
||||
user
|
||||
}
|
||||
None => err!("Invited user not found"),
|
||||
let grantee_user = if let Some(user) = User::find_by_mail(&claims.email, &conn).await {
|
||||
Invitation::take(&claims.email, &conn).await;
|
||||
user
|
||||
} else {
|
||||
err!("Invited user not found")
|
||||
};
|
||||
|
||||
// We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database.
|
||||
@@ -666,7 +657,7 @@ async fn password_emergency_access(
|
||||
};
|
||||
|
||||
// change grantor_user password
|
||||
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None);
|
||||
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?;
|
||||
grantor_user.save(&conn).await?;
|
||||
|
||||
// Disable TwoFactor providers since they will otherwise block logins
|
||||
@@ -779,7 +770,7 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("Failed to get DB connection while searching emergency request timed out")
|
||||
error!("Failed to get DB connection while searching emergency request timed out");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,6 +829,6 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("Failed to get DB connection while searching emergency notification reminder")
|
||||
error!("Failed to get DB connection while searching emergency notification reminder");
|
||||
}
|
||||
}
|
||||
|
||||
+55
-61
@@ -1,18 +1,18 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use rocket::{form::FromForm, serde::json::Json, Route};
|
||||
use rocket::{Route, form::FromForm, serde::json::Json};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{EmptyResult, JsonResult},
|
||||
auth::{AdminHeaders, Headers},
|
||||
db::{
|
||||
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
|
||||
DbConn, DbPool,
|
||||
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
|
||||
},
|
||||
util::parse_date,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
/// ###############################################################################################################
|
||||
@@ -38,9 +38,7 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
||||
|
||||
// Return an empty vec when we org events are disabled.
|
||||
// This prevents client errors
|
||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
||||
Vec::with_capacity(0)
|
||||
} else {
|
||||
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
||||
let start_date = parse_date(&data.start);
|
||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||
parse_date(before_date)
|
||||
@@ -51,8 +49,10 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
||||
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|e| e.to_json())
|
||||
.map(Event::to_json)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::with_capacity(0)
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -64,27 +64,21 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
||||
|
||||
#[get("/ciphers/<cipher_id>/events?<data..>")]
|
||||
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
// Return an empty vec when we org events are disabled.
|
||||
// Return an empty vec when org events are disabled.
|
||||
// This prevents client errors
|
||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
||||
Vec::with_capacity(0)
|
||||
} else {
|
||||
let mut events_json = Vec::with_capacity(0);
|
||||
if Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await {
|
||||
let start_date = parse_date(&data.start);
|
||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||
parse_date(before_date)
|
||||
} else {
|
||||
parse_date(&data.end)
|
||||
};
|
||||
let events_json: Vec<Value> = if CONFIG.org_events_enabled()
|
||||
&& Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await
|
||||
{
|
||||
let start_date = parse_date(&data.start);
|
||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||
parse_date(before_date)
|
||||
} else {
|
||||
parse_date(&data.end)
|
||||
};
|
||||
|
||||
events_json = Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|e| e.to_json())
|
||||
.collect()
|
||||
}
|
||||
events_json
|
||||
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
|
||||
} else {
|
||||
Vec::with_capacity(0)
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -107,9 +101,7 @@ async fn get_user_events(
|
||||
}
|
||||
// Return an empty vec when we org events are disabled.
|
||||
// This prevents client errors
|
||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
||||
Vec::with_capacity(0)
|
||||
} else {
|
||||
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
||||
let start_date = parse_date(&data.start);
|
||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||
parse_date(before_date)
|
||||
@@ -120,8 +112,10 @@ async fn get_user_events(
|
||||
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|e| e.to_json())
|
||||
.map(Event::to_json)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::with_capacity(0)
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -134,7 +128,8 @@ async fn get_user_events(
|
||||
fn get_continuation_token(events_json: &[Value]) -> Option<&str> {
|
||||
// When the length of the vec equals the max page_size there probably is more data
|
||||
// When it is less, then all events are loaded.
|
||||
if events_json.len() as i64 == Event::PAGE_SIZE {
|
||||
#[expect(clippy::cast_possible_truncation, reason = "PAGE_SIZE fits within usize")]
|
||||
if events_json.len() == Event::PAGE_SIZE as usize {
|
||||
if let Some(last_event) = events_json.last() {
|
||||
last_event["date"].as_str()
|
||||
} else {
|
||||
@@ -176,7 +171,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
||||
let event_date = parse_date(&event.date);
|
||||
match event.r#type {
|
||||
1000..=1099 => {
|
||||
_log_user_event(
|
||||
log_user_event_impl(
|
||||
event.r#type,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
@@ -188,7 +183,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
||||
}
|
||||
1600..=1699 => {
|
||||
if let Some(org_id) = &event.organization_id {
|
||||
_log_event(
|
||||
log_event_impl(
|
||||
event.r#type,
|
||||
org_id,
|
||||
org_id,
|
||||
@@ -202,22 +197,21 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(cipher_uuid) = &event.cipher_id {
|
||||
if let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await {
|
||||
if let Some(org_id) = cipher.organization_uuid {
|
||||
_log_event(
|
||||
event.r#type,
|
||||
cipher_uuid,
|
||||
&org_id,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
Some(event_date),
|
||||
&headers.ip.ip,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Some(cipher_uuid) = &event.cipher_id
|
||||
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
||||
&& let Some(org_id) = cipher.organization_uuid
|
||||
{
|
||||
log_event_impl(
|
||||
event.r#type,
|
||||
cipher_uuid,
|
||||
&org_id,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
Some(event_date),
|
||||
&headers.ip.ip,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,10 +223,10 @@ pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32,
|
||||
if !CONFIG.org_events_enabled() {
|
||||
return;
|
||||
}
|
||||
_log_user_event(event_type, user_id, device_type, None, ip, conn).await;
|
||||
log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await;
|
||||
}
|
||||
|
||||
async fn _log_user_event(
|
||||
async fn log_user_event_impl(
|
||||
event_type: i32,
|
||||
user_id: &UserId,
|
||||
device_type: i32,
|
||||
@@ -240,7 +234,7 @@ async fn _log_user_event(
|
||||
ip: &IpAddr,
|
||||
conn: &DbConn,
|
||||
) {
|
||||
let memberships = Membership::find_by_user(user_id, conn).await;
|
||||
let memberships = Membership::find_confirmed_by_user(user_id, conn).await;
|
||||
let mut events: Vec<Event> = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org
|
||||
|
||||
// Upstream saves the event also without any org_id.
|
||||
@@ -278,11 +272,11 @@ pub async fn log_event(
|
||||
if !CONFIG.org_events_enabled() {
|
||||
return;
|
||||
}
|
||||
_log_event(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
||||
log_event_impl(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn _log_event(
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
async fn log_event_impl(
|
||||
event_type: i32,
|
||||
source_uuid: &str,
|
||||
org_id: &OrganizationId,
|
||||
@@ -298,24 +292,24 @@ async fn _log_event(
|
||||
// 1000..=1099 Are user events, they need to be logged via log_user_event()
|
||||
// Cipher Events
|
||||
1100..=1199 => {
|
||||
event.cipher_uuid = Some(source_uuid.to_string().into());
|
||||
event.cipher_uuid = Some(source_uuid.to_owned().into());
|
||||
}
|
||||
// Collection Events
|
||||
1300..=1399 => {
|
||||
event.collection_uuid = Some(source_uuid.to_string().into());
|
||||
event.collection_uuid = Some(source_uuid.to_owned().into());
|
||||
}
|
||||
// Group Events
|
||||
1400..=1499 => {
|
||||
event.group_uuid = Some(source_uuid.to_string().into());
|
||||
event.group_uuid = Some(source_uuid.to_owned().into());
|
||||
}
|
||||
// Org User Events
|
||||
1500..=1599 => {
|
||||
event.org_user_uuid = Some(source_uuid.to_string().into());
|
||||
event.org_user_uuid = Some(source_uuid.to_owned().into());
|
||||
}
|
||||
// 1600..=1699 Are organizational events, and they do not need the source_uuid
|
||||
// Policy Events
|
||||
1700..=1799 => {
|
||||
event.policy_uuid = Some(source_uuid.to_string().into());
|
||||
event.policy_uuid = Some(source_uuid.to_owned().into());
|
||||
}
|
||||
// Ignore others
|
||||
_ => {}
|
||||
@@ -338,6 +332,6 @@ pub async fn event_cleanup_job(pool: DbPool) {
|
||||
if let Ok(conn) = pool.get().await {
|
||||
Event::clean_events(&conn).await.ok();
|
||||
} else {
|
||||
error!("Failed to get DB connection while trying to cleanup the events table")
|
||||
error!("Failed to get DB connection while trying to cleanup the events table");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ use crate::{
|
||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||
auth::Headers,
|
||||
db::{
|
||||
models::{Folder, FolderId},
|
||||
DbConn,
|
||||
models::{Folder, FolderId},
|
||||
},
|
||||
util::deser_opt_nonempty_str,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<rocket::Route> {
|
||||
@@ -28,9 +29,10 @@ async fn get_folders(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
|
||||
#[get("/folders/<folder_id>")]
|
||||
async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
match Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await {
|
||||
Some(folder) => Ok(Json(folder.to_json())),
|
||||
_ => err!("Invalid folder", "Folder does not exist or belongs to another user"),
|
||||
if let Some(folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await {
|
||||
Ok(Json(folder.to_json()))
|
||||
} else {
|
||||
err!("Invalid folder", "Folder does not exist or belongs to another user")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +40,7 @@ async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> Json
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FolderData {
|
||||
pub name: String,
|
||||
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||
pub id: Option<FolderId>,
|
||||
}
|
||||
|
||||
|
||||
+65
-70
@@ -1,4 +1,6 @@
|
||||
pub mod accounts;
|
||||
pub mod two_factor;
|
||||
|
||||
mod ciphers;
|
||||
mod emergency_access;
|
||||
mod events;
|
||||
@@ -6,17 +8,32 @@ mod folders;
|
||||
mod organizations;
|
||||
mod public;
|
||||
mod sends;
|
||||
pub mod two_factor;
|
||||
|
||||
pub use accounts::purge_auth_requests;
|
||||
pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType};
|
||||
pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers};
|
||||
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
|
||||
pub use events::{event_cleanup_job, log_event, log_user_event};
|
||||
use reqwest::Method;
|
||||
pub use sends::purge_sends;
|
||||
|
||||
use reqwest::Method;
|
||||
use rocket::{Catcher, Route, serde::json::Json, serde::json::Value};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||
auth::Headers,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
|
||||
},
|
||||
error::Error,
|
||||
http_client::make_http_request,
|
||||
mail,
|
||||
util::{FeatureFlagFilter, parse_experimental_client_feature_flags},
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_domains];
|
||||
let mut eq_domains_routes = routes![get_settings_domains, post_settings_domains, put_settings_domains];
|
||||
let mut hibp_routes = routes![hibp_breach];
|
||||
let mut meta_routes = routes![alive, now, version, config, get_api_webauthn];
|
||||
|
||||
@@ -44,24 +61,6 @@ pub fn events_routes() -> Vec<Route> {
|
||||
routes
|
||||
}
|
||||
|
||||
//
|
||||
// Move this somewhere else
|
||||
//
|
||||
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
|
||||
|
||||
use crate::{
|
||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||
auth::Headers,
|
||||
db::{
|
||||
models::{Membership, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyErr, Organization, User},
|
||||
DbConn,
|
||||
},
|
||||
error::Error,
|
||||
http_client::make_http_request,
|
||||
mail,
|
||||
util::parse_experimental_client_feature_flags,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GlobalDomain {
|
||||
@@ -72,15 +71,17 @@ struct GlobalDomain {
|
||||
|
||||
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
|
||||
|
||||
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
||||
#[get("/settings/domains")]
|
||||
fn get_eq_domains(headers: Headers) -> Json<Value> {
|
||||
_get_eq_domains(headers, false)
|
||||
fn get_settings_domains(headers: Headers) -> Json<Value> {
|
||||
get_eq_domains(&headers, false)
|
||||
}
|
||||
|
||||
fn _get_eq_domains(headers: Headers, no_excluded: bool) -> Json<Value> {
|
||||
let user = headers.user;
|
||||
fn get_eq_domains(headers: &Headers, no_excluded: bool) -> Json<Value> {
|
||||
use serde_json::from_str;
|
||||
|
||||
let user = &headers.user;
|
||||
|
||||
let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap();
|
||||
let excluded_globals: Vec<i32> = from_str(&user.excluded_globals).unwrap();
|
||||
|
||||
@@ -109,34 +110,45 @@ struct EquivDomainData {
|
||||
}
|
||||
|
||||
#[post("/settings/domains", data = "<data>")]
|
||||
async fn post_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
async fn post_settings_domains(
|
||||
data: Json<EquivDomainData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
use serde_json::to_string;
|
||||
|
||||
let data: EquivDomainData = data.into_inner();
|
||||
|
||||
let excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default();
|
||||
let equivalent_domains = data.equivalent_domains.unwrap_or_default();
|
||||
|
||||
let mut user = headers.user;
|
||||
use serde_json::to_string;
|
||||
|
||||
user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_string());
|
||||
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_string());
|
||||
user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_owned());
|
||||
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_owned());
|
||||
|
||||
user.save(&conn).await?;
|
||||
|
||||
nt.send_user_update(UpdateType::SyncSettings, &user, &headers.device.push_uuid, &conn).await;
|
||||
nt.send_user_update(UpdateType::SyncSettings, &user, headers.device.push_uuid.as_ref(), &conn).await;
|
||||
|
||||
Ok(Json(json!({})))
|
||||
}
|
||||
|
||||
#[put("/settings/domains", data = "<data>")]
|
||||
async fn put_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
post_eq_domains(data, headers, conn, nt).await
|
||||
async fn put_settings_domains(
|
||||
data: Json<EquivDomainData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
post_settings_domains(data, headers, conn, nt).await
|
||||
}
|
||||
|
||||
#[get("/hibp/breach?<username>")]
|
||||
async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult {
|
||||
let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect();
|
||||
if let Some(api_key) = crate::CONFIG.hibp_api_key() {
|
||||
if let Some(api_key) = CONFIG.hibp_api_key() {
|
||||
let url = format!(
|
||||
"https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false"
|
||||
);
|
||||
@@ -197,19 +209,17 @@ fn get_api_webauthn(_headers: Headers) -> Json<Value> {
|
||||
|
||||
#[get("/config")]
|
||||
fn config() -> Json<Value> {
|
||||
let domain = crate::CONFIG.domain();
|
||||
let domain = 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 =
|
||||
parse_experimental_client_feature_flags(&crate::CONFIG.experimental_client_feature_flags());
|
||||
feature_states.insert("duo-redirect".to_string(), true);
|
||||
feature_states.insert("email-verification".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);
|
||||
// Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135
|
||||
// Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12
|
||||
// Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31
|
||||
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
|
||||
let mut feature_states = parse_experimental_client_feature_flags(
|
||||
&CONFIG.experimental_client_feature_flags(),
|
||||
&FeatureFlagFilter::ValidOnly,
|
||||
);
|
||||
feature_states.insert("pm-19148-innovation-archive".to_owned(), true);
|
||||
|
||||
Json(json!({
|
||||
// Note: The clients use this version to handle backwards compatibility concerns
|
||||
@@ -217,14 +227,15 @@ fn config() -> Json<Value> {
|
||||
// We should make sure that we keep this updated when we support the new server features
|
||||
// Version history:
|
||||
// - Individual cipher key encryption: 2024.2.0
|
||||
"version": "2025.6.0",
|
||||
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0
|
||||
"version": "2025.12.0",
|
||||
"gitHash": option_env!("GIT_REV"),
|
||||
"server": {
|
||||
"name": "Vaultwarden",
|
||||
"url": "https://github.com/dani-garcia/vaultwarden"
|
||||
},
|
||||
"settings": {
|
||||
"disableUserRegistration": crate::CONFIG.is_signup_disabled()
|
||||
"disableUserRegistration": CONFIG.is_signup_disabled()
|
||||
},
|
||||
"environment": {
|
||||
"vault": domain,
|
||||
@@ -269,33 +280,17 @@ async fn accept_org_invite(
|
||||
err!("User already accepted the invitation");
|
||||
}
|
||||
|
||||
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
||||
// It returns different error messages per function.
|
||||
if member.atype < MembershipType::Admin {
|
||||
match OrgPolicy::is_user_allowed(&member.user_uuid, &member.org_uuid, false, conn).await {
|
||||
Ok(_) => {}
|
||||
Err(OrgPolicyErr::TwoFactorMissing) => {
|
||||
if crate::CONFIG.email_2fa_auto_fallback() {
|
||||
two_factor::email::activate_email_2fa(user, conn).await?;
|
||||
} else {
|
||||
err!("You cannot join this organization until you enable two-step login on your user account");
|
||||
}
|
||||
}
|
||||
Err(OrgPolicyErr::SingleOrgEnforced) => {
|
||||
err!("You cannot join this organization because you are a member of an organization which forbids it");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
member.status = MembershipStatus::Accepted as i32;
|
||||
member.reset_password_key = reset_password_key;
|
||||
|
||||
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
||||
OrgPolicy::check_user_allowed(&member, "join", conn).await?;
|
||||
|
||||
member.save(conn).await?;
|
||||
|
||||
if crate::CONFIG.mail_enabled() {
|
||||
let org = match Organization::find_by_uuid(&member.org_uuid, conn).await {
|
||||
Some(org) => org,
|
||||
None => err!("Organization not found."),
|
||||
if CONFIG.mail_enabled() {
|
||||
let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else {
|
||||
err!("Organization not found.")
|
||||
};
|
||||
// User was invited to an organization, so they must be confirmed manually after acceptance
|
||||
mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name)
|
||||
|
||||
+375
-687
File diff suppressed because it is too large
Load Diff
+64
-67
@@ -1,23 +1,24 @@
|
||||
use chrono::Utc;
|
||||
use rocket::{
|
||||
request::{FromRequest, Outcome},
|
||||
serde::json::Json,
|
||||
Request, Route,
|
||||
};
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::Utc;
|
||||
use rocket::{
|
||||
Request, Route,
|
||||
request::{FromRequest, Outcome},
|
||||
serde::json::Json,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::EmptyResult,
|
||||
auth,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{
|
||||
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization,
|
||||
OrganizationApiKey, OrganizationId, User,
|
||||
},
|
||||
DbConn,
|
||||
},
|
||||
mail, CONFIG,
|
||||
mail,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -90,19 +91,18 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
||||
}
|
||||
} else {
|
||||
// If user is not part of the organization
|
||||
let user = match User::find_by_mail(&user_data.email, &conn).await {
|
||||
Some(user) => user, // exists in vaultwarden
|
||||
None => {
|
||||
// User does not exist yet
|
||||
let mut new_user = User::new(&user_data.email, None);
|
||||
new_user.save(&conn).await?;
|
||||
let user = if let Some(user) = User::find_by_mail(&user_data.email, &conn).await {
|
||||
user
|
||||
} else {
|
||||
// User does not exist yet
|
||||
let mut new_user = User::new(&user_data.email, None);
|
||||
new_user.save(&conn).await?;
|
||||
|
||||
if !CONFIG.mail_enabled() {
|
||||
Invitation::new(&new_user.email).save(&conn).await?;
|
||||
}
|
||||
user_created = true;
|
||||
new_user
|
||||
if !CONFIG.mail_enabled() {
|
||||
Invitation::new(&new_user.email).save(&conn).await?;
|
||||
}
|
||||
user_created = true;
|
||||
new_user
|
||||
};
|
||||
let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() {
|
||||
MembershipStatus::Invited as i32
|
||||
@@ -110,9 +110,10 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
||||
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
||||
};
|
||||
|
||||
let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &conn).await {
|
||||
Some(org) => (org.name, org.billing_email),
|
||||
None => err!("Error looking up organization"),
|
||||
let (org_name, org_email) = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await {
|
||||
(org.name, org.billing_email)
|
||||
} else {
|
||||
err!("Error looking up organization")
|
||||
};
|
||||
|
||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
|
||||
@@ -123,40 +124,36 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
||||
|
||||
new_member.save(&conn).await?;
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
if let Err(e) =
|
||||
if CONFIG.mail_enabled()
|
||||
&& let Err(e) =
|
||||
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await
|
||||
{
|
||||
// Upon error delete the user, invite and org member records when needed
|
||||
if user_created {
|
||||
user.delete(&conn).await?;
|
||||
} else {
|
||||
new_member.delete(&conn).await?;
|
||||
}
|
||||
|
||||
err!(format!("Error sending invite: {e:?} "));
|
||||
{
|
||||
// Upon error delete the user, invite and org member records when needed
|
||||
if user_created {
|
||||
user.delete(&conn).await?;
|
||||
} else {
|
||||
new_member.delete(&conn).await?;
|
||||
}
|
||||
|
||||
err!(format!("Error sending invite: {e:?} "));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if CONFIG.org_groups_enabled() {
|
||||
for group_data in &data.groups {
|
||||
let group_uuid = match Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await {
|
||||
Some(group) => group.uuid,
|
||||
None => {
|
||||
let mut group = Group::new(
|
||||
org_id.clone(),
|
||||
group_data.name.clone(),
|
||||
false,
|
||||
Some(group_data.external_id.clone()),
|
||||
);
|
||||
group.save(&conn).await?;
|
||||
group.uuid
|
||||
}
|
||||
let group_uuid = if let Some(group) =
|
||||
Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await
|
||||
{
|
||||
group.uuid
|
||||
} else {
|
||||
let mut group =
|
||||
Group::new(org_id.clone(), group_data.name.clone(), false, Some(group_data.external_id.clone()));
|
||||
group.save(&conn).await?;
|
||||
group.uuid
|
||||
};
|
||||
|
||||
GroupUser::delete_all_by_group(&group_uuid, &conn).await?;
|
||||
GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?;
|
||||
|
||||
for ext_id in &group_data.member_external_ids {
|
||||
if let Some(member) = Membership::find_by_external_id_and_org(ext_id, &org_id, &conn).await {
|
||||
@@ -174,18 +171,17 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
||||
// Generate a HashSet to quickly verify if a member is listed or not.
|
||||
let sync_members: HashSet<String> = data.members.into_iter().map(|m| m.external_id).collect();
|
||||
for member in Membership::find_by_org(&org_id, &conn).await {
|
||||
if let Some(ref user_external_id) = member.external_id {
|
||||
if !sync_members.contains(user_external_id) {
|
||||
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
|
||||
// Removing owner, check that there is at least one other confirmed owner
|
||||
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1
|
||||
{
|
||||
warn!("Can't delete the last owner");
|
||||
continue;
|
||||
}
|
||||
if let Some(ref user_external_id) = member.external_id
|
||||
&& !sync_members.contains(user_external_id)
|
||||
{
|
||||
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
|
||||
// Removing owner, check that there is at least one other confirmed owner
|
||||
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
|
||||
warn!("Can't delete the last owner");
|
||||
continue;
|
||||
}
|
||||
member.delete(&conn).await?;
|
||||
}
|
||||
member.delete(&conn).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,12 +198,14 @@ impl<'r> FromRequest<'r> for PublicToken {
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = request.headers();
|
||||
// Get access_token
|
||||
let access_token: &str = match headers.get_one("Authorization") {
|
||||
Some(a) => match a.rsplit("Bearer ").next() {
|
||||
Some(split) => split,
|
||||
None => err_handler!("No access token provided"),
|
||||
},
|
||||
None => err_handler!("No access token provided"),
|
||||
let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
|
||||
if let Some(split) = a.rsplit("Bearer ").next() {
|
||||
split
|
||||
} else {
|
||||
err_handler!("No access token provided")
|
||||
}
|
||||
} else {
|
||||
err_handler!("No access token provided")
|
||||
};
|
||||
// Check JWT token is valid and get device and user from it
|
||||
let Ok(claims) = auth::decode_api_org(access_token) else {
|
||||
@@ -229,14 +227,13 @@ impl<'r> FromRequest<'r> for PublicToken {
|
||||
|
||||
// Check if claims.sub is org_api_key.uuid
|
||||
// Check if claims.client_sub is org_api_key.org_uuid
|
||||
let conn = match DbConn::from_request(request).await {
|
||||
Outcome::Success(conn) => conn,
|
||||
_ => err_handler!("Error getting DB"),
|
||||
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||
err_handler!("Error getting DB")
|
||||
};
|
||||
let Some(org_id) = claims.client_id.strip_prefix("organization.") else {
|
||||
err_handler!("Malformed client_id")
|
||||
};
|
||||
let org_id: OrganizationId = org_id.to_string().into();
|
||||
let org_id: OrganizationId = org_id.to_owned().into();
|
||||
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await else {
|
||||
err_handler!("Invalid client_id")
|
||||
};
|
||||
|
||||
+36
-34
@@ -10,15 +10,15 @@ use rocket::{
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
|
||||
auth::{ClientIp, Headers, Host},
|
||||
config::PathType,
|
||||
db::{
|
||||
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
|
||||
DbConn, DbPool,
|
||||
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
|
||||
},
|
||||
util::{save_temp_file, NumberOrString},
|
||||
CONFIG,
|
||||
util::{NumberOrString, save_temp_file},
|
||||
};
|
||||
|
||||
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
|
||||
@@ -63,7 +63,7 @@ pub async fn purge_sends(pool: DbPool) {
|
||||
if let Ok(conn) = pool.get().await {
|
||||
Send::purge(&conn).await;
|
||||
} else {
|
||||
error!("Failed to get DB connection while purging sends")
|
||||
error!("Failed to get DB connection while purging sends");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
|
||||
#[get("/sends")]
|
||||
async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
let sends = Send::find_by_user(&headers.user.uuid, &conn);
|
||||
let sends_json: Vec<Value> = sends.await.iter().map(|s| s.to_json()).collect();
|
||||
let sends_json: Vec<Value> = sends.await.iter().map(Send::to_json).collect();
|
||||
|
||||
Json(json!({
|
||||
"data": sends_json,
|
||||
@@ -179,9 +179,10 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
|
||||
#[get("/sends/<send_id>")]
|
||||
async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
match Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await {
|
||||
Some(send) => Ok(Json(send.to_json())),
|
||||
None => err!("Send not found", "Invalid send uuid or does not belong to user"),
|
||||
if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await {
|
||||
Ok(Json(send.to_json()))
|
||||
} else {
|
||||
err!("Send not found", "Invalid send uuid or does not belong to user")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,9 +311,10 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn)
|
||||
|
||||
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
|
||||
|
||||
let file_length = match &data.file_length {
|
||||
Some(m) => m.into_i64()?,
|
||||
_ => err!("Invalid send length"),
|
||||
let file_length = if let Some(m) = &data.file_length {
|
||||
m.into_i64()?
|
||||
} else {
|
||||
err!("Invalid send length")
|
||||
};
|
||||
if file_length < 0 {
|
||||
err!("Send size can't be negative")
|
||||
@@ -457,16 +459,16 @@ async fn post_access(
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
};
|
||||
|
||||
if let Some(max_access_count) = send.max_access_count {
|
||||
if send.access_count >= max_access_count {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
if let Some(max_access_count) = send.max_access_count
|
||||
&& send.access_count >= max_access_count
|
||||
{
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
if let Some(expiration) = send.expiration_date {
|
||||
if Utc::now().naive_utc() >= expiration {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
if let Some(expiration) = send.expiration_date
|
||||
&& Utc::now().naive_utc() >= expiration
|
||||
{
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
|
||||
if Utc::now().naive_utc() >= send.deletion_date {
|
||||
@@ -517,16 +519,16 @@ async fn post_access_file(
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
};
|
||||
|
||||
if let Some(max_access_count) = send.max_access_count {
|
||||
if send.access_count >= max_access_count {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
if let Some(max_access_count) = send.max_access_count
|
||||
&& send.access_count >= max_access_count
|
||||
{
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
|
||||
if let Some(expiration) = send.expiration_date {
|
||||
if Utc::now().naive_utc() >= expiration {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
if let Some(expiration) = send.expiration_date
|
||||
&& Utc::now().naive_utc() >= expiration
|
||||
{
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
|
||||
if Utc::now().naive_utc() >= send.deletion_date {
|
||||
@@ -568,22 +570,22 @@ async fn post_access_file(
|
||||
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 {
|
||||
if crate::storage::is_fs_operator(&operator) {
|
||||
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))
|
||||
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())
|
||||
Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_mins(5)).await?.uri().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/sends/<send_id>/<file_id>?<t>")]
|
||||
async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> {
|
||||
if let Ok(claims) = crate::auth::decode_send(t) {
|
||||
if claims.sub == format!("{send_id}/{file_id}") {
|
||||
return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok();
|
||||
}
|
||||
if let Ok(claims) = crate::auth::decode_send(t)
|
||||
&& claims.sub == format!("{send_id}/{file_id}")
|
||||
{
|
||||
return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
use data_encoding::BASE32;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use rocket::{Route, serde::json::Json};
|
||||
|
||||
use crate::{
|
||||
api::{core::log_user_event, core::two_factor::_generate_recover_code, EmptyResult, JsonResult, PasswordOrOtpData},
|
||||
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code},
|
||||
auth::{ClientIp, Headers},
|
||||
crypto,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||
DbConn,
|
||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||
},
|
||||
util::NumberOrString,
|
||||
};
|
||||
@@ -70,9 +69,10 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
||||
.await?;
|
||||
|
||||
// Validate key as base32 and 20 bytes length
|
||||
let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) {
|
||||
Ok(decoded) => decoded,
|
||||
_ => err!("Invalid totp secret"),
|
||||
let decoded_key: Vec<u8> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) {
|
||||
decoded
|
||||
} else {
|
||||
err!("Invalid totp secret")
|
||||
};
|
||||
|
||||
if decoded_key.len() != 20 {
|
||||
@@ -82,7 +82,7 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
||||
// Validate the token provided with the key, and save new twofactor
|
||||
validate_totp_code(&user.uuid, &token, &key.to_uppercase(), &headers.ip, &conn).await?;
|
||||
|
||||
_generate_recover_code(&mut user, &conn).await;
|
||||
generate_recover_code(&mut user, &conn).await;
|
||||
|
||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
|
||||
@@ -119,7 +119,7 @@ pub async fn validate_totp_code(
|
||||
ip: &ClientIp,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
use totp_lite::{totp_custom, Sha1};
|
||||
use totp_lite::{Sha1, totp_custom};
|
||||
|
||||
let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else {
|
||||
err!("Invalid TOTP secret")
|
||||
@@ -128,7 +128,7 @@ pub async fn validate_totp_code(
|
||||
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
|
||||
{
|
||||
Some(tf) => tf,
|
||||
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_string()),
|
||||
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()),
|
||||
};
|
||||
|
||||
// The amount of steps back and forward in time
|
||||
@@ -145,7 +145,7 @@ pub async fn validate_totp_code(
|
||||
|
||||
// We need to calculate the time offsite and cast it as an u64.
|
||||
// Since we only have times into the future and the totp generator needs an u64 instead of the default i64.
|
||||
let time = (current_timestamp + step * 30i64) as u64;
|
||||
let time: u64 = (current_timestamp + step * 30i64).cast_unsigned();
|
||||
let generated = totp_custom::<Sha1>(30, 6, &decoded_secret, time);
|
||||
|
||||
// Check the given code equals the generated and if the time_step is larger then the one last used.
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
use chrono::Utc;
|
||||
use data_encoding::BASE64;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use rocket::{Route, serde::json::Json};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
core::log_user_event, core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult,
|
||||
PasswordOrOtpData,
|
||||
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event,
|
||||
core::two_factor::generate_recover_code,
|
||||
},
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
DbConn,
|
||||
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
},
|
||||
error::MapResult,
|
||||
http_client::make_http_request,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -82,8 +81,7 @@ enum DuoStatus {
|
||||
impl DuoStatus {
|
||||
fn data(self) -> Option<DuoData> {
|
||||
match self {
|
||||
DuoStatus::Global(data) => Some(data),
|
||||
DuoStatus::User(data) => Some(data),
|
||||
DuoStatus::Global(data) | DuoStatus::User(data) => Some(data),
|
||||
DuoStatus::Disabled(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -182,7 +180,7 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
|
||||
let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str);
|
||||
twofactor.save(&conn).await?;
|
||||
|
||||
_generate_recover_code(&mut user, &conn).await;
|
||||
generate_recover_code(&mut user, &conn).await;
|
||||
|
||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
|
||||
@@ -201,14 +199,14 @@ async fn activate_duo_put(data: Json<EnableDuoData>, headers: Headers, conn: DbC
|
||||
}
|
||||
|
||||
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
|
||||
use reqwest::{header, Method};
|
||||
use reqwest::{Method, header};
|
||||
use std::str::FromStr;
|
||||
|
||||
// https://duo.com/docs/authapi#api-details
|
||||
let url = format!("https://{}{path}", &data.host);
|
||||
let date = Utc::now().to_rfc2822();
|
||||
let url = format!("https://{}{path}", data.host);
|
||||
let dt = Utc::now().to_rfc2822();
|
||||
let username = &data.ik;
|
||||
let fields = [&date, method, &data.host, path, params];
|
||||
let fields = [&dt, method, &data.host, path, params];
|
||||
let password = crypto::hmac_sign(&data.sk, &fields.join("\n"));
|
||||
|
||||
let m = Method::from_str(method).unwrap_or_default();
|
||||
@@ -216,7 +214,7 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
|
||||
make_http_request(m, &url)?
|
||||
.basic_auth(username, Some(password))
|
||||
.header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)")
|
||||
.header(header::DATE, date)
|
||||
.header(header::DATE, dt)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
@@ -356,9 +354,10 @@ fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -
|
||||
err!("Invalid ikey")
|
||||
}
|
||||
|
||||
let expire: i64 = match expire.parse() {
|
||||
Ok(e) => e,
|
||||
Err(_) => err!("Invalid expire time"),
|
||||
let expire: i64 = if let Ok(e) = expire.parse() {
|
||||
e
|
||||
} else {
|
||||
err!("Invalid expire time")
|
||||
};
|
||||
|
||||
if time >= expire {
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use data_encoding::HEXLOWER;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use reqwest::{header, StatusCode};
|
||||
use ring::digest::{digest, Digest, SHA512_256};
|
||||
use reqwest::{StatusCode, header};
|
||||
use ring::digest::{Digest, SHA512_256, digest};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
api::{core::two_factor::duo::get_duo_keys_email, EmptyResult},
|
||||
CONFIG,
|
||||
api::{EmptyResult, core::two_factor::duo::get_duo_keys_email},
|
||||
crypto,
|
||||
db::{
|
||||
models::{DeviceId, EventType, TwoFactorDuoContext},
|
||||
DbConn, DbPool,
|
||||
models::{DeviceId, EventType, TwoFactorDuoContext},
|
||||
},
|
||||
error::Error,
|
||||
http_client::make_http_request,
|
||||
CONFIG,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
// The location on this service that Duo should redirect users to. For us, this is a bridge
|
||||
// built in to the Bitwarden clients.
|
||||
@@ -124,7 +125,7 @@ impl DuoClient {
|
||||
ClientAssertion {
|
||||
iss: self.client_id.clone(),
|
||||
sub: self.client_id.clone(),
|
||||
aud: url.to_string(),
|
||||
aud: url.to_owned(),
|
||||
exp: now + JWT_VALIDITY_SECS,
|
||||
jti: jwt_id,
|
||||
iat: now,
|
||||
@@ -302,7 +303,7 @@ impl DuoClient {
|
||||
|
||||
if !(matching_nonces && matching_usernames) {
|
||||
err!("Error validating Duo authorization, nonce or username mismatch.")
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -347,7 +348,7 @@ pub async fn purge_duo_contexts(pool: DbPool) {
|
||||
if let Ok(conn) = pool.get().await {
|
||||
TwoFactorDuoContext::purge_expired_duo_contexts(&conn).await;
|
||||
} else {
|
||||
error!("Failed to get DB connection while purging expired Duo authentications")
|
||||
error!("Failed to get DB connection while purging expired Duo authentications");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +395,7 @@ pub async fn get_duo_auth_url(
|
||||
match client.health_check().await {
|
||||
Ok(()) => {}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
}
|
||||
|
||||
// Generate random OAuth2 state and OIDC Nonce
|
||||
let state: String = crypto::get_random_string_alphanum(STATE_LENGTH);
|
||||
@@ -438,16 +439,13 @@ pub async fn validate_duo_login(
|
||||
|
||||
// Get the context by the state reported by the client. If we don't have one,
|
||||
// it means the context is either missing or expired.
|
||||
let ctx = match extract_context(state, conn).await {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
err!(
|
||||
"Error validating duo authentication",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
)
|
||||
}
|
||||
let Some(ctx) = extract_context(state, conn).await else {
|
||||
err!(
|
||||
"Error validating duo authentication",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
// Context validation steps
|
||||
@@ -476,13 +474,13 @@ pub async fn validate_duo_login(
|
||||
match client.health_check().await {
|
||||
Ok(()) => {}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
}
|
||||
|
||||
let d: Digest = digest(&SHA512_256, format!("{}{device_identifier}", ctx.nonce).as_bytes());
|
||||
let hash: String = HEXLOWER.encode(d.as_ref());
|
||||
|
||||
match client.exchange_authz_code_for_result(code, email, hash.as_str()).await {
|
||||
Ok(_) => Ok(()),
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => {
|
||||
err!(
|
||||
"Error validating duo authentication",
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use rocket::{Route, serde::json::Json};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
core::{log_user_event, two_factor::_generate_recover_code},
|
||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||
core::{log_user_event, two_factor::generate_recover_code},
|
||||
},
|
||||
auth::Headers,
|
||||
auth::{ClientHeaders, Headers},
|
||||
crypto,
|
||||
db::{
|
||||
models::{DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
DbConn,
|
||||
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
mail, CONFIG,
|
||||
mail,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -25,37 +25,84 @@ pub fn routes() -> Vec<Route> {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SendEmailLoginData {
|
||||
#[serde(alias = "DeviceIdentifier")]
|
||||
device_identifier: DeviceId,
|
||||
|
||||
#[allow(unused)]
|
||||
device_identifier: Option<DeviceId>,
|
||||
#[serde(alias = "Email")]
|
||||
email: Option<String>,
|
||||
|
||||
#[allow(unused)]
|
||||
#[serde(alias = "MasterPasswordHash")]
|
||||
master_password_hash: Option<String>,
|
||||
auth_request_id: Option<AuthRequestId>,
|
||||
auth_request_access_code: Option<String>,
|
||||
}
|
||||
|
||||
/// User is trying to login and wants to use email 2FA.
|
||||
/// Does not require Bearer token
|
||||
#[post("/two-factor/send-email-login", data = "<data>")] // JsonResult
|
||||
async fn send_email_login(data: Json<SendEmailLoginData>, conn: DbConn) -> EmptyResult {
|
||||
async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: ClientHeaders, conn: DbConn) -> EmptyResult {
|
||||
let data: SendEmailLoginData = data.into_inner();
|
||||
|
||||
use crate::db::models::User;
|
||||
|
||||
// Get the user
|
||||
let Some(user) = User::find_by_device_id(&data.device_identifier, &conn).await else {
|
||||
err!("Cannot find user. Try again.")
|
||||
};
|
||||
|
||||
if !CONFIG._enable_email_2fa() {
|
||||
err!("Email 2FA is disabled")
|
||||
}
|
||||
|
||||
send_token(&user.uuid, &conn).await?;
|
||||
// Ratelimit the login
|
||||
crate::ratelimit::check_limit_login(&client_headers.ip.ip)?;
|
||||
|
||||
Ok(())
|
||||
// Get the user
|
||||
let email = match &data.email {
|
||||
Some(email) if !email.is_empty() => Some(email),
|
||||
_ => None,
|
||||
};
|
||||
let master_password_hash = match &data.master_password_hash {
|
||||
Some(password_hash) if !password_hash.is_empty() => Some(password_hash),
|
||||
_ => None,
|
||||
};
|
||||
let auth_request_id = match &data.auth_request_id {
|
||||
Some(auth_request_id) if !auth_request_id.is_empty() => Some(auth_request_id),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let user = if let Some(email) = email {
|
||||
let Some(user) = User::find_by_mail(email, &conn).await else {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
};
|
||||
|
||||
if let Some(master_password_hash) = master_password_hash {
|
||||
// Check password
|
||||
if !user.check_valid_password(master_password_hash) {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
}
|
||||
} else if let Some(auth_request_id) = auth_request_id {
|
||||
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
|
||||
err!("AuthRequest doesn't exist", "User not found")
|
||||
};
|
||||
let Some(code) = &data.auth_request_access_code else {
|
||||
err!("no auth request access code")
|
||||
};
|
||||
|
||||
if auth_request.device_type != client_headers.device_type
|
||||
|| auth_request.request_ip != client_headers.ip.ip.to_string()
|
||||
|| !auth_request.check_access_code(code)
|
||||
{
|
||||
err!("AuthRequest doesn't exist", "Invalid device, IP or code")
|
||||
}
|
||||
} else {
|
||||
err!("No password hash has been submitted.")
|
||||
}
|
||||
|
||||
user
|
||||
} else {
|
||||
let Some(device_identifier) = &data.device_identifier else {
|
||||
err!("No device identifier has been submitted.")
|
||||
};
|
||||
// SSO login only sends device id, so we get the user by the most recently used device
|
||||
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
};
|
||||
|
||||
user
|
||||
};
|
||||
|
||||
send_token(&user.uuid, &conn).await
|
||||
}
|
||||
|
||||
/// Generate the token, save the data for later verification and send email to user
|
||||
@@ -185,7 +232,7 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
|
||||
twofactor.data = email_data.to_json();
|
||||
twofactor.save(&conn).await?;
|
||||
|
||||
_generate_recover_code(&mut user, &conn).await;
|
||||
generate_recover_code(&mut user, &conn).await;
|
||||
|
||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
|
||||
@@ -237,9 +284,9 @@ pub async fn validate_email_code_str(
|
||||
twofactor.data = email_data.to_json();
|
||||
twofactor.save(conn).await?;
|
||||
|
||||
let date = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
||||
let max_time = CONFIG.email_expiration_time() as i64;
|
||||
if date + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
||||
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
||||
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
||||
err!(
|
||||
"Token has expired",
|
||||
ErrorEvent {
|
||||
@@ -295,9 +342,10 @@ impl EmailTokenData {
|
||||
|
||||
pub fn from_json(string: &str) -> Result<EmailTokenData, Error> {
|
||||
let res: Result<EmailTokenData, serde_json::Error> = serde_json::from_str(string);
|
||||
match res {
|
||||
Ok(x) => Ok(x),
|
||||
Err(_) => err!("Could not decode EmailTokenData from string"),
|
||||
if let Ok(x) = res {
|
||||
Ok(x)
|
||||
} else {
|
||||
err!("Could not decode EmailTokenData from string")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,18 +363,17 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult {
|
||||
pub fn obscure_email(email: &str) -> String {
|
||||
let split: Vec<&str> = email.rsplitn(2, '@').collect();
|
||||
|
||||
let mut name = split[1].to_string();
|
||||
let mut name = split[1].to_owned();
|
||||
let domain = &split[0];
|
||||
|
||||
let name_size = name.chars().count();
|
||||
|
||||
let new_name = match name_size {
|
||||
1..=3 => "*".repeat(name_size),
|
||||
_ => {
|
||||
let stars = "*".repeat(name_size - 2);
|
||||
name.truncate(2);
|
||||
format!("{name}{stars}")
|
||||
}
|
||||
let new_name = if let 1..=3 = name_size {
|
||||
"*".repeat(name_size)
|
||||
} else {
|
||||
let stars = "*".repeat(name_size - 2);
|
||||
name.truncate(2);
|
||||
format!("{name}{stars}")
|
||||
};
|
||||
|
||||
format!("{new_name}@{domain}")
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use data_encoding::BASE32;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use num_traits::FromPrimitive;
|
||||
use rocket::{Route, serde::json::Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
core::{log_event, log_user_event},
|
||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||
core::{log_event, log_user_event},
|
||||
},
|
||||
auth::{ClientHeaders, Headers},
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{
|
||||
DbConn, DbPool,
|
||||
models::{
|
||||
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
|
||||
TwoFactorIncomplete, User, UserId,
|
||||
TwoFactorIncomplete, TwoFactorType, User, UserId,
|
||||
},
|
||||
DbConn, DbPool,
|
||||
},
|
||||
mail,
|
||||
util::NumberOrString,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub mod authenticator;
|
||||
@@ -31,11 +32,46 @@ pub mod protected_actions;
|
||||
pub mod webauthn;
|
||||
pub mod yubikey;
|
||||
|
||||
fn has_global_duo_credentials() -> bool {
|
||||
CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some()
|
||||
}
|
||||
|
||||
pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data: Option<&str>) -> bool {
|
||||
#[derive(Deserialize)]
|
||||
struct DuoProviderData {
|
||||
host: String,
|
||||
ik: String,
|
||||
sk: String,
|
||||
}
|
||||
|
||||
match provider_type {
|
||||
TwoFactorType::Authenticator | TwoFactorType::RecoveryCode => true,
|
||||
TwoFactorType::Email => CONFIG._enable_email_2fa(),
|
||||
TwoFactorType::Duo | TwoFactorType::OrganizationDuo => {
|
||||
provider_data
|
||||
.and_then(|raw| serde_json::from_str::<DuoProviderData>(raw).ok())
|
||||
.is_some_and(|duo| !duo.host.is_empty() && !duo.ik.is_empty() && !duo.sk.is_empty())
|
||||
|| has_global_duo_credentials()
|
||||
}
|
||||
TwoFactorType::YubiKey => {
|
||||
CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some()
|
||||
}
|
||||
TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(),
|
||||
TwoFactorType::Remember => !CONFIG.disable_2fa_remember(),
|
||||
TwoFactorType::U2f
|
||||
| TwoFactorType::U2fRegisterChallenge
|
||||
| TwoFactorType::U2fLoginChallenge
|
||||
| TwoFactorType::EmailVerificationChallenge
|
||||
| TwoFactorType::WebauthnRegisterChallenge
|
||||
| TwoFactorType::WebauthnLoginChallenge
|
||||
| TwoFactorType::ProtectedActions => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
let mut routes = routes![
|
||||
get_twofactor,
|
||||
get_recover,
|
||||
recover,
|
||||
disable_twofactor,
|
||||
disable_twofactor_put,
|
||||
get_device_verification_settings,
|
||||
@@ -54,7 +90,13 @@ pub fn routes() -> Vec<Route> {
|
||||
#[get("/two-factor")]
|
||||
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
|
||||
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_provider).collect();
|
||||
let twofactors_json: Vec<Value> = twofactors
|
||||
.iter()
|
||||
.filter_map(|tf| {
|
||||
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
||||
is_twofactor_provider_usable(&provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(json!({
|
||||
"data": twofactors_json,
|
||||
@@ -76,55 +118,7 @@ async fn get_recover(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbCo
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RecoverTwoFactor {
|
||||
master_password_hash: String,
|
||||
email: String,
|
||||
recovery_code: String,
|
||||
}
|
||||
|
||||
#[post("/two-factor/recover", data = "<data>")]
|
||||
async fn recover(data: Json<RecoverTwoFactor>, client_headers: ClientHeaders, conn: DbConn) -> JsonResult {
|
||||
let data: RecoverTwoFactor = data.into_inner();
|
||||
|
||||
use crate::db::models::User;
|
||||
|
||||
// Get the user
|
||||
let Some(mut user) = User::find_by_mail(&data.email, &conn).await else {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
};
|
||||
|
||||
// Check password
|
||||
if !user.check_valid_password(&data.master_password_hash) {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
}
|
||||
|
||||
// Check if recovery code is correct
|
||||
if !user.check_valid_recovery_code(&data.recovery_code) {
|
||||
err!("Recovery code is incorrect. Try again.")
|
||||
}
|
||||
|
||||
// Remove all twofactors from the user
|
||||
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
enforce_2fa_policy(&user, &user.uuid, client_headers.device_type, &client_headers.ip.ip, &conn).await?;
|
||||
|
||||
log_user_event(
|
||||
EventType::UserRecovered2fa as i32,
|
||||
&user.uuid,
|
||||
client_headers.device_type,
|
||||
&client_headers.ip.ip,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Remove the recovery code, not needed without twofactors
|
||||
user.totp_recover = None;
|
||||
user.save(&conn).await?;
|
||||
Ok(Json(Value::Object(serde_json::Map::new())))
|
||||
}
|
||||
|
||||
async fn _generate_recover_code(user: &mut User, conn: &DbConn) {
|
||||
async fn generate_recover_code(user: &mut User, conn: &DbConn) {
|
||||
if user.totp_recover.is_none() {
|
||||
let totp_recover = crypto::encode_random_bytes::<20>(&BASE32);
|
||||
user.totp_recover = Some(totp_recover);
|
||||
@@ -184,9 +178,7 @@ pub async fn enforce_2fa_policy(
|
||||
ip: &std::net::IpAddr,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
for member in
|
||||
Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await.into_iter()
|
||||
{
|
||||
for member in Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await {
|
||||
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org
|
||||
if member.atype < MembershipType::Admin {
|
||||
if CONFIG.mail_enabled() {
|
||||
@@ -221,7 +213,7 @@ pub async fn enforce_2fa_policy_for_org(
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
let org = Organization::find_by_uuid(org_id, conn).await.unwrap();
|
||||
for member in Membership::find_confirmed_by_org(org_id, conn).await.into_iter() {
|
||||
for member in Membership::find_confirmed_by_org(org_id, conn).await {
|
||||
// Don't enforce the policy for Admins and Owners.
|
||||
if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() {
|
||||
if CONFIG.mail_enabled() {
|
||||
@@ -255,12 +247,9 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
||||
return;
|
||||
}
|
||||
|
||||
let conn = match pool.get().await {
|
||||
Ok(conn) => conn,
|
||||
_ => {
|
||||
error!("Failed to get DB connection in send_incomplete_2fa_notifications()");
|
||||
return;
|
||||
}
|
||||
let Ok(conn) = pool.get().await else {
|
||||
error!("Failed to get DB connection in send_incomplete_2fa_notifications()");
|
||||
return;
|
||||
};
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
@@ -282,7 +271,7 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
Ok(()) => {
|
||||
if let Err(e) = login.delete(&conn).await {
|
||||
error!("Error deleting incomplete 2FA record: {e:#?}");
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
use chrono::{naive::serde::ts_seconds, NaiveDateTime, TimeDelta, Utc};
|
||||
use rocket::{serde::json::Json, Route};
|
||||
use chrono::{NaiveDateTime, TimeDelta, Utc, naive::serde::ts_seconds};
|
||||
use rocket::{Route, serde::json::Json};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::EmptyResult,
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{
|
||||
models::{TwoFactor, TwoFactorType, UserId},
|
||||
DbConn,
|
||||
models::{TwoFactor, TwoFactorType, UserId},
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
mail, CONFIG,
|
||||
mail,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -44,9 +45,10 @@ impl ProtectedActionData {
|
||||
|
||||
pub fn from_json(string: &str) -> Result<Self, Error> {
|
||||
let res: Result<Self, serde_json::Error> = serde_json::from_str(string);
|
||||
match res {
|
||||
Ok(x) => Ok(x),
|
||||
Err(_) => err!("Could not decode ProtectedActionData from string"),
|
||||
if let Ok(x) = res {
|
||||
Ok(x)
|
||||
} else {
|
||||
err!("Could not decode ProtectedActionData from string")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +64,9 @@ impl ProtectedActionData {
|
||||
#[post("/accounts/request-otp")]
|
||||
async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
if !CONFIG.mail_enabled() {
|
||||
err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device.");
|
||||
err!(
|
||||
"Email is disabled for this server. Either enable email or login using your master password instead of login via device."
|
||||
);
|
||||
}
|
||||
|
||||
let user = headers.user;
|
||||
@@ -102,7 +106,9 @@ struct ProtectedActionVerify {
|
||||
#[post("/accounts/verify-otp", data = "<data>")]
|
||||
async fn verify_otp(data: Json<ProtectedActionVerify>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
if !CONFIG.mail_enabled() {
|
||||
err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device.");
|
||||
err!(
|
||||
"Email is disabled for this server. Either enable email or login using your master password instead of login via device."
|
||||
);
|
||||
}
|
||||
|
||||
let user = headers.user;
|
||||
@@ -133,7 +139,7 @@ pub async fn validate_protected_action_otp(
|
||||
}
|
||||
|
||||
// Check if the token has expired (Using the email 2fa expiration time)
|
||||
let max_time = CONFIG.email_expiration_time() as i64;
|
||||
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||
if pa_data.time_since_sent().num_seconds() > max_time {
|
||||
pa.delete(conn).await?;
|
||||
err!("Token has expired")
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
use crate::{
|
||||
api::{
|
||||
core::{log_user_event, two_factor::_generate_recover_code},
|
||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||
},
|
||||
auth::Headers,
|
||||
crypto::ct_eq,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||
DbConn,
|
||||
},
|
||||
error::Error,
|
||||
util::NumberOrString,
|
||||
CONFIG,
|
||||
};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use std::{str::FromStr, sync::LazyLock, time::Duration};
|
||||
|
||||
use rocket::{Route, serde::json::Json};
|
||||
use serde_json::Value;
|
||||
use std::str::FromStr;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration};
|
||||
use webauthn_rs::{Webauthn, WebauthnBuilder};
|
||||
use webauthn_rs::{
|
||||
Webauthn, WebauthnBuilder,
|
||||
prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration},
|
||||
};
|
||||
use webauthn_rs_proto::{
|
||||
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
|
||||
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
|
||||
RequestAuthenticationExtensions, UserVerificationPolicy,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||
core::{log_user_event, two_factor::generate_recover_code},
|
||||
},
|
||||
auth::Headers,
|
||||
crypto::ct_eq,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||
},
|
||||
error::Error,
|
||||
util::NumberOrString,
|
||||
};
|
||||
|
||||
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
||||
let domain = CONFIG.domain();
|
||||
let domain_origin = CONFIG.domain_origin();
|
||||
@@ -38,7 +39,7 @@ static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
||||
let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
|
||||
.expect("Creating WebauthnBuilder failed")
|
||||
.rp_name(&domain)
|
||||
.timeout(Duration::from_millis(60000));
|
||||
.timeout(Duration::from_mins(1));
|
||||
|
||||
webauthn.build().expect("Building Webauthn failed")
|
||||
});
|
||||
@@ -108,8 +109,8 @@ impl WebauthnRegistration {
|
||||
|
||||
#[post("/two-factor/get-webauthn", data = "<data>")]
|
||||
async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
if !CONFIG.domain_set() {
|
||||
err!("`DOMAIN` environment variable is not set. Webauthn disabled")
|
||||
if !CONFIG.is_webauthn_2fa_supported() {
|
||||
err!("Configured `DOMAIN` is not compatible with Webauthn")
|
||||
}
|
||||
|
||||
let data: PasswordOrOtpData = data.into_inner();
|
||||
@@ -144,12 +145,12 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
|
||||
let (mut challenge, state) = WEBAUTHN.start_passkey_registration(
|
||||
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
|
||||
&user.email,
|
||||
&user.name,
|
||||
user.display_name(),
|
||||
Some(registrations),
|
||||
)?;
|
||||
|
||||
let mut state = serde_json::to_value(&state)?;
|
||||
state["rs"]["policy"] = Value::String("discouraged".to_string());
|
||||
state["rs"]["policy"] = Value::String("discouraged".to_owned());
|
||||
state["rs"]["extensions"].as_object_mut().unwrap().clear();
|
||||
|
||||
let type_ = TwoFactorType::WebauthnRegisterChallenge;
|
||||
@@ -265,13 +266,12 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
||||
|
||||
// Retrieve and delete the saved challenge state
|
||||
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
|
||||
let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||
Some(tf) => {
|
||||
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
|
||||
tf.delete(&conn).await?;
|
||||
state
|
||||
}
|
||||
None => err!("Can't recover challenge"),
|
||||
let state = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
|
||||
tf.delete(&conn).await?;
|
||||
state
|
||||
} else {
|
||||
err!("Can't recover challenge")
|
||||
};
|
||||
|
||||
// Verify the credentials with the saved state
|
||||
@@ -291,7 +291,7 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
||||
TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
||||
.save(&conn)
|
||||
.await?;
|
||||
_generate_recover_code(&mut user, &conn).await;
|
||||
generate_recover_code(&mut user, &conn).await;
|
||||
|
||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
|
||||
@@ -342,9 +342,10 @@ async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbCo
|
||||
// If entry is migrated from u2f, delete the u2f entry as well
|
||||
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await
|
||||
{
|
||||
let mut data: Vec<U2FRegistration> = match serde_json::from_str(&u2f.data) {
|
||||
Ok(d) => d,
|
||||
Err(_) => err!("Error parsing U2F data"),
|
||||
let mut data: Vec<U2FRegistration> = if let Ok(d) = serde_json::from_str(&u2f.data) {
|
||||
d
|
||||
} else {
|
||||
err!("Error parsing U2F data")
|
||||
};
|
||||
|
||||
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
|
||||
@@ -388,10 +389,10 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
|
||||
|
||||
// Modify to discourage user verification
|
||||
let mut state = serde_json::to_value(&state)?;
|
||||
state["ast"]["policy"] = Value::String("discouraged".to_string());
|
||||
state["ast"]["policy"] = Value::String("discouraged".to_owned());
|
||||
|
||||
// Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well
|
||||
let app_id = format!("{}/app-id.json", &CONFIG.domain());
|
||||
let app_id = format!("{}/app-id.json", CONFIG.domain());
|
||||
state["ast"]["appid"] = Value::String(app_id.clone());
|
||||
|
||||
response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
|
||||
@@ -416,18 +417,17 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
|
||||
|
||||
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult {
|
||||
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
|
||||
let mut state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
||||
Some(tf) => {
|
||||
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
|
||||
tf.delete(conn).await?;
|
||||
state
|
||||
}
|
||||
None => err!(
|
||||
let mut state = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
||||
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
|
||||
tf.delete(conn).await?;
|
||||
state
|
||||
} else {
|
||||
err!(
|
||||
"Can't recover login challenge",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?;
|
||||
@@ -438,7 +438,7 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
|
||||
// We need to check for and update the backup_eligible flag when needed.
|
||||
// Vaultwarden did not have knowledge of this flag prior to migrating to webauthn-rs v0.5.x
|
||||
// Because of this we check the flag at runtime and update the registrations and state when needed
|
||||
check_and_update_backup_eligible(user_id, &rsp, &mut registrations, &mut state, conn).await?;
|
||||
let backup_flags_updated = check_and_update_backup_eligible(&rsp, &mut registrations, &mut state)?;
|
||||
|
||||
let authentication_result = WEBAUTHN.finish_passkey_authentication(&rsp, &state)?;
|
||||
|
||||
@@ -446,7 +446,8 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
|
||||
if ct_eq(reg.credential.cred_id(), authentication_result.cred_id()) {
|
||||
// If the cred id matches and the credential is updated, Some(true) is returned
|
||||
// In those cases, update the record, else leave it alone
|
||||
if reg.credential.update_credential(&authentication_result) == Some(true) {
|
||||
let credential_updated = reg.credential.update_credential(&authentication_result) == Some(true);
|
||||
if credential_updated || backup_flags_updated {
|
||||
TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
||||
.save(conn)
|
||||
.await?;
|
||||
@@ -463,13 +464,11 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
|
||||
)
|
||||
}
|
||||
|
||||
async fn check_and_update_backup_eligible(
|
||||
user_id: &UserId,
|
||||
fn check_and_update_backup_eligible(
|
||||
rsp: &PublicKeyCredential,
|
||||
registrations: &mut Vec<WebauthnRegistration>,
|
||||
state: &mut PasskeyAuthentication,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
) -> Result<bool, Error> {
|
||||
// The feature flags from the response
|
||||
// For details see: https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data
|
||||
const FLAG_BACKUP_ELIGIBLE: u8 = 0b0000_1000;
|
||||
@@ -486,16 +485,7 @@ async fn check_and_update_backup_eligible(
|
||||
let rsp_id = rsp.raw_id.as_slice();
|
||||
for reg in &mut *registrations {
|
||||
if ct_eq(reg.credential.cred_id().as_slice(), rsp_id) {
|
||||
// Try to update the key, and if needed also update the database, before the actual state check is done
|
||||
if reg.set_backup_eligible(backup_eligible, backup_state) {
|
||||
TwoFactor::new(
|
||||
user_id.clone(),
|
||||
TwoFactorType::Webauthn,
|
||||
serde_json::to_string(®istrations)?,
|
||||
)
|
||||
.save(conn)
|
||||
.await?;
|
||||
|
||||
// We also need to adjust the current state which holds the challenge used to start the authentication verification
|
||||
// Because Vaultwarden supports multiple keys, we need to loop through the deserialized state and check which key to update
|
||||
let mut raw_state = serde_json::to_value(&state)?;
|
||||
@@ -517,11 +507,12 @@ async fn check_and_update_backup_eligible(
|
||||
}
|
||||
|
||||
*state = serde_json::from_value(raw_state)?;
|
||||
return Ok(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use rocket::{Route, serde::json::Json};
|
||||
use serde_json::Value;
|
||||
use yubico::{config::Config, verify_async};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
core::{log_user_event, two_factor::_generate_recover_code},
|
||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||
core::{log_user_event, two_factor::generate_recover_code},
|
||||
},
|
||||
auth::Headers,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType},
|
||||
DbConn,
|
||||
models::{EventType, TwoFactor, TwoFactorType},
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -46,7 +45,7 @@ pub struct YubikeyMetadata {
|
||||
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
|
||||
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
|
||||
|
||||
data_keys.iter().filter_map(|e| e.as_ref().cloned()).collect()
|
||||
data_keys.into_iter().flatten().cloned().collect()
|
||||
}
|
||||
|
||||
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
|
||||
@@ -64,9 +63,10 @@ fn get_yubico_credentials() -> Result<(String, String), Error> {
|
||||
err!("Yubico support is disabled");
|
||||
}
|
||||
|
||||
match (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
|
||||
(Some(id), Some(secret)) => Ok((id, secret)),
|
||||
_ => err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled"),
|
||||
if let (Some(id), Some(secret)) = (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
|
||||
Ok((id, secret))
|
||||
} else {
|
||||
err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
|
||||
yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap();
|
||||
yubikey_data.save(&conn).await?;
|
||||
|
||||
_generate_recover_code(&mut user, &conn).await;
|
||||
generate_recover_code(&mut user, &conn).await;
|
||||
|
||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
|
||||
|
||||
+102
-121
@@ -6,28 +6,29 @@ use std::{
|
||||
};
|
||||
|
||||
use bytes::{Bytes, BytesMut};
|
||||
use futures::{stream::StreamExt, TryFutureExt};
|
||||
use futures::{TryFutureExt, stream::StreamExt};
|
||||
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
|
||||
use regex::Regex;
|
||||
use reqwest::{
|
||||
header::{self, HeaderMap, HeaderValue},
|
||||
Client, Response,
|
||||
header::{self, HeaderMap, HeaderValue},
|
||||
};
|
||||
use rocket::{http::ContentType, response::Redirect, Route};
|
||||
use svg_hush::{data_url_filter, Filter};
|
||||
use rocket::{Route, http::ContentType, response::Redirect};
|
||||
use svg_hush::{Filter, data_url_filter};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
config::PathType,
|
||||
error::Error,
|
||||
http_client::{get_reqwest_client_builder, should_block_address, CustomHttpClientError},
|
||||
http_client::{CustomHttpClientError, get_reqwest_client_builder, get_valid_host, should_block_host},
|
||||
util::Cached,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
match CONFIG.icon_service().as_str() {
|
||||
"internal" => routes![icon_internal],
|
||||
_ => routes![icon_external],
|
||||
if CONFIG.icon_service().as_str() == "internal" {
|
||||
routes![icon_internal]
|
||||
} else {
|
||||
routes![icon_external]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,20 +82,20 @@ static ICON_SIZE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?x)(\d+
|
||||
// The function name `icon_external` is checked in the `on_response` function in `AppHeaders`
|
||||
// It is used to prevent sending a specific header which breaks icon downloads.
|
||||
// If this function needs to be renamed, also adjust the code in `util.rs`
|
||||
#[get("/<domain>/icon.png")]
|
||||
fn icon_external(domain: &str) -> Option<Redirect> {
|
||||
if !is_valid_domain(domain) {
|
||||
warn!("Invalid domain: {domain}");
|
||||
return None;
|
||||
#[get("/<host>/icon.png")]
|
||||
fn icon_external(host: &str) -> Cached<Option<Redirect>> {
|
||||
let Ok(host) = get_valid_host(host) else {
|
||||
warn!("Invalid host: {host}");
|
||||
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
|
||||
};
|
||||
|
||||
if should_block_host(&host).is_err() {
|
||||
warn!("Blocked address: {host}");
|
||||
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
|
||||
}
|
||||
|
||||
if should_block_address(domain) {
|
||||
warn!("Blocked address: {domain}");
|
||||
return None;
|
||||
}
|
||||
|
||||
let url = CONFIG._icon_service_url().replace("{}", domain);
|
||||
match CONFIG.icon_redirect_code() {
|
||||
let url = CONFIG._icon_service_url().replace("{}", &host.to_string());
|
||||
let redir = match CONFIG.icon_redirect_code() {
|
||||
301 => Some(Redirect::moved(url)), // legacy permanent redirect
|
||||
302 => Some(Redirect::found(url)), // legacy temporary redirect
|
||||
307 => Some(Redirect::temporary(url)),
|
||||
@@ -103,15 +104,25 @@ fn icon_external(domain: &str) -> Option<Redirect> {
|
||||
error!("Unexpected redirect code {}", CONFIG.icon_redirect_code());
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
Cached::ttl(redir, CONFIG.icon_cache_ttl(), true)
|
||||
}
|
||||
|
||||
#[get("/<domain>/icon.png")]
|
||||
async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
|
||||
#[get("/<host>/icon.png")]
|
||||
async fn icon_internal(host: &str) -> Cached<(ContentType, Vec<u8>)> {
|
||||
const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png");
|
||||
|
||||
if !is_valid_domain(domain) {
|
||||
warn!("Invalid domain: {domain}");
|
||||
let Ok(host) = get_valid_host(host) else {
|
||||
warn!("Invalid host: {host}");
|
||||
return Cached::ttl(
|
||||
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
|
||||
CONFIG.icon_cache_negttl(),
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
if should_block_host(&host).is_err() {
|
||||
warn!("Blocked address: {host}");
|
||||
return Cached::ttl(
|
||||
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
|
||||
CONFIG.icon_cache_negttl(),
|
||||
@@ -119,16 +130,7 @@ async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
|
||||
);
|
||||
}
|
||||
|
||||
if should_block_address(domain) {
|
||||
warn!("Blocked address: {domain}");
|
||||
return Cached::ttl(
|
||||
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
|
||||
CONFIG.icon_cache_negttl(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
match get_icon(domain).await {
|
||||
match get_icon(&host.to_string()).await {
|
||||
Some((icon, icon_type)) => {
|
||||
Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true)
|
||||
}
|
||||
@@ -136,42 +138,6 @@ async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns if the domain provided is valid or not.
|
||||
///
|
||||
/// This does some manual checks and makes use of Url to do some basic checking.
|
||||
/// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255.
|
||||
fn is_valid_domain(domain: &str) -> bool {
|
||||
const ALLOWED_CHARS: &str = "_-.";
|
||||
|
||||
// If parsing the domain fails using Url, it will not work with reqwest.
|
||||
if let Err(parse_error) = url::Url::parse(format!("https://{domain}").as_str()) {
|
||||
debug!("Domain parse error: '{domain}' - {parse_error:?}");
|
||||
return false;
|
||||
} else if domain.is_empty()
|
||||
|| domain.contains("..")
|
||||
|| domain.starts_with('.')
|
||||
|| domain.starts_with('-')
|
||||
|| domain.ends_with('-')
|
||||
{
|
||||
debug!(
|
||||
"Domain validation error: '{domain}' is either empty, contains '..', starts with an '.', starts or ends with a '-'"
|
||||
);
|
||||
return false;
|
||||
} else if domain.len() > 255 {
|
||||
debug!("Domain validation error: '{domain}' exceeds 255 characters");
|
||||
return false;
|
||||
}
|
||||
|
||||
for c in domain.chars() {
|
||||
if !c.is_alphanumeric() && !ALLOWED_CHARS.contains(c) {
|
||||
debug!("Domain validation error: '{domain}' contains an invalid character '{c}'");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
||||
let path = format!("{domain}.png");
|
||||
|
||||
@@ -182,7 +148,7 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
||||
|
||||
if let Some(icon) = get_cached_icon(&path).await {
|
||||
let icon_type = get_icon_type(&icon).unwrap_or("x-icon");
|
||||
return Some((icon, icon_type.to_string()));
|
||||
return Some((icon, icon_type.to_owned()));
|
||||
}
|
||||
|
||||
if CONFIG.disable_icon_download() {
|
||||
@@ -193,7 +159,7 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
||||
match download_icon(domain).await {
|
||||
Ok((icon, icon_type)) => {
|
||||
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_owned()))
|
||||
}
|
||||
Err(e) => {
|
||||
// If this error comes from the custom resolver, this means this is a blocked domain
|
||||
@@ -218,10 +184,10 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
|
||||
}
|
||||
|
||||
// Try to read the cached icon, and return it if it exists
|
||||
if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache) {
|
||||
if let Ok(buf) = operator.read(path).await {
|
||||
return Some(buf.to_vec());
|
||||
}
|
||||
if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)
|
||||
&& let Ok(buf) = operator.read(path).await
|
||||
{
|
||||
return Some(buf.to_vec());
|
||||
}
|
||||
|
||||
None
|
||||
@@ -315,17 +281,17 @@ fn get_favicons_node(dom: Tokenizer<StringReader<'_>, FaviconEmitter>, icons: &m
|
||||
}
|
||||
|
||||
for icon_tag in icon_tags {
|
||||
if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF) {
|
||||
if let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default()) {
|
||||
let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) {
|
||||
std::str::from_utf8(v).unwrap_or_default()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let priority = get_icon_priority(full_href.as_str(), sizes);
|
||||
icons.push(Icon::new(priority, full_href.to_string()));
|
||||
}
|
||||
};
|
||||
if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF)
|
||||
&& let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default())
|
||||
{
|
||||
let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) {
|
||||
std::str::from_utf8(v).unwrap_or_default()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let priority = get_icon_priority(full_href.as_str(), sizes);
|
||||
icons.push(Icon::new(priority, full_href.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +332,7 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> {
|
||||
tld = domain_parts.next_back().unwrap(),
|
||||
base = domain_parts.next_back().unwrap()
|
||||
);
|
||||
if is_valid_domain(&base_domain) {
|
||||
if get_valid_host(&base_domain).is_ok() {
|
||||
let sslbase = format!("https://{base_domain}");
|
||||
let httpbase = format!("http://{base_domain}");
|
||||
debug!("[get_icon_url]: Trying without subdomains '{base_domain}'");
|
||||
@@ -377,7 +343,7 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> {
|
||||
// When the domain is not an IP, and has less then 2 dots, try to add www. infront of it.
|
||||
} else if is_ip.is_err() && domain.matches('.').count() < 2 {
|
||||
let www_domain = format!("www.{domain}");
|
||||
if is_valid_domain(&www_domain) {
|
||||
if get_valid_host(&www_domain).is_ok() {
|
||||
let sslwww = format!("https://{www_domain}");
|
||||
let httpwww = format!("http://{www_domain}");
|
||||
debug!("[get_icon_url]: Trying with www. prefix '{www_domain}'");
|
||||
@@ -441,7 +407,7 @@ async fn get_page(url: &str) -> Result<Response, Error> {
|
||||
async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> {
|
||||
let mut client = CLIENT.get(url);
|
||||
if !referer.is_empty() {
|
||||
client = client.header("Referer", referer)
|
||||
client = client.header("Referer", referer);
|
||||
}
|
||||
|
||||
Ok(client.send().await?.error_for_status()?)
|
||||
@@ -512,13 +478,11 @@ fn parse_sizes(sizes: &str) -> (u16, u16) {
|
||||
|
||||
if !sizes.is_empty() {
|
||||
match ICON_SIZE_REGEX.captures(sizes.trim()) {
|
||||
None => {}
|
||||
Some(dimensions) => {
|
||||
if dimensions.len() >= 3 {
|
||||
width = dimensions[1].parse::<u16>().unwrap_or_default();
|
||||
height = dimensions[2].parse::<u16>().unwrap_or_default();
|
||||
}
|
||||
Some(dimensions) if dimensions.len() >= 3 => {
|
||||
width = dimensions[1].parse::<u16>().unwrap_or_default();
|
||||
height = dimensions[2].parse::<u16>().unwrap_or_default();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,11 +495,10 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
|
||||
let mut buffer = Bytes::new();
|
||||
let mut icon_type: Option<&str> = None;
|
||||
|
||||
use data_url::DataUrl;
|
||||
|
||||
for icon in icon_result.iconlist.iter().take(5) {
|
||||
let mut icons = icon_result.iconlist.iter().take(5).peekable();
|
||||
while let Some(icon) = icons.next() {
|
||||
if icon.href.starts_with("data:image") {
|
||||
let Ok(datauri) = DataUrl::process(&icon.href) else {
|
||||
let Ok(datauri) = data_url::DataUrl::process(&icon.href) else {
|
||||
continue;
|
||||
};
|
||||
// Check if we are able to decode the data uri
|
||||
@@ -559,13 +522,25 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
|
||||
}
|
||||
}
|
||||
_ => debug!("Extracted icon from data:image uri is invalid"),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
let res = get_page_with_referer(&icon.href, &icon_result.referer).await?;
|
||||
debug!("Trying {}", icon.href);
|
||||
// Make sure all icons are checked before returning error
|
||||
let res = match get_page_with_referer(&icon.href, &icon_result.referer).await {
|
||||
Ok(r) => r,
|
||||
Err(e) if icons.peek().is_none() => return Err(e),
|
||||
Err(e) if CustomHttpClientError::downcast_ref(&e).is_some() => return Err(e), // If blacklisted stop immediately instead of checking the rest of the icons. see explanation and actual handling inside get_icon()
|
||||
Err(e) => {
|
||||
warn!("Unable to download icon: {e:?}");
|
||||
|
||||
// Continue to next icon
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
buffer = stream_to_bytes_limit(res, 5120 * 1024).await?; // 5120KB/5MB for each icon max (Same as icons.bitwarden.net)
|
||||
|
||||
// Check if the icon type is allowed, else try an icon from the list.
|
||||
// Check if the icon type is allowed, else try another icon from the list.
|
||||
icon_type = get_icon_type(&buffer);
|
||||
if icon_type.is_none() {
|
||||
buffer.clear();
|
||||
@@ -611,22 +586,25 @@ async fn save_icon(path: &str, icon: Vec<u8>) {
|
||||
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");
|
||||
}
|
||||
if let Ok(content) = std::str::from_utf8(&bytes[..bytes.len().min(1024)])
|
||||
&& (content.contains("<svg") || content.contains("<SVG"))
|
||||
{
|
||||
return Some("svg+xml");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Some details can be found here:
|
||||
// - https://www.garykessler.net/library/file_sigs_GCK_latest.html
|
||||
// - https://en.wikipedia.org/wiki/List_of_file_signatures
|
||||
match bytes {
|
||||
[137, 80, 78, 71, ..] => Some("png"),
|
||||
[0, 0, 1, 0, ..] => Some("x-icon"),
|
||||
[82, 73, 70, 70, ..] => Some("webp"),
|
||||
[255, 216, 255, ..] => Some("jpeg"),
|
||||
[71, 73, 70, 56, ..] => Some("gif"),
|
||||
[66, 77, ..] => Some("bmp"),
|
||||
[60, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg
|
||||
[137, 80, 78, 71, 13, 10, 26, 10, ..] => Some("png"),
|
||||
[0, 0, 1, 0, n1, n2, ..] if u16::from_le_bytes([*n1, *n2]) > 0 => Some("x-icon"), // https://en.wikipedia.org/wiki/ICO_(file_format)
|
||||
[82, 73, 70, 70, _, _, _, _, 87, 69, 66, 80, ..] => Some("webp"), // Only match WebP Images
|
||||
[255, 216, 255, b, ..] if *b >= 0xC0 => Some("jpeg"),
|
||||
[71, 73, 70, 56, 55 | 57, 97, ..] => Some("gif"),
|
||||
[66, 77, _, _, _, _, 0, 0, 0, 0, ..] => Some("bmp"), // https://en.wikipedia.org/wiki/BMP_file_format
|
||||
[60, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg
|
||||
[60, 63, 120, 109, 108, ..] => check_svg_after_xml_declaration(bytes), // An svg starting with <?xml
|
||||
_ => None,
|
||||
}
|
||||
@@ -754,7 +732,7 @@ impl FaviconEmitter {
|
||||
let rel_value =
|
||||
std::str::from_utf8(token.tag.attributes.get(ATTR_REL).unwrap()).unwrap_or_default();
|
||||
if rel_value.contains("icon") && !rel_value.contains("mask-icon") {
|
||||
self.emit_token = true
|
||||
self.emit_token = true;
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
@@ -796,8 +774,11 @@ impl Emitter for FaviconEmitter {
|
||||
fn emit_current_tag(&mut self) -> Option<html5gum::State> {
|
||||
self.flush_current_attribute(true);
|
||||
self.last_start_tag.clear();
|
||||
if self.current_token.is_some() && !self.current_token.as_ref().unwrap().closing {
|
||||
self.last_start_tag.extend(&*self.current_token.as_ref().unwrap().tag.name);
|
||||
match &self.current_token {
|
||||
Some(token) if !token.closing => {
|
||||
self.last_start_tag.extend(&*token.tag.name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
html5gum::naive_next_state(&self.last_start_tag)
|
||||
}
|
||||
@@ -824,13 +805,13 @@ impl Emitter for FaviconEmitter {
|
||||
|
||||
fn push_attribute_name(&mut self, s: &[u8]) {
|
||||
if let Some(attr) = &mut self.current_attribute {
|
||||
attr.0.extend(s)
|
||||
attr.0.extend(s);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_attribute_value(&mut self, s: &[u8]) {
|
||||
if let Some(attr) = &mut self.current_attribute {
|
||||
attr.1.extend(s)
|
||||
attr.1.extend(s);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+394
-216
File diff suppressed because it is too large
Load Diff
+8
-4
@@ -32,11 +32,13 @@ pub use crate::api::{
|
||||
web::routes as web_routes,
|
||||
web::static_files,
|
||||
};
|
||||
use crate::db::{
|
||||
models::{OrgPolicy, OrgPolicyType, User},
|
||||
DbConn,
|
||||
use crate::{
|
||||
CONFIG,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{OrgPolicy, OrgPolicyType, User},
|
||||
},
|
||||
};
|
||||
use crate::CONFIG;
|
||||
|
||||
// Type aliases for API methods results
|
||||
pub type ApiResult<T> = Result<T, crate::error::Error>;
|
||||
@@ -47,6 +49,7 @@ pub type EmptyResult = ApiResult<()>;
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PasswordOrOtpData {
|
||||
#[serde(alias = "MasterPasswordHash")]
|
||||
master_password_hash: Option<String>,
|
||||
otp: Option<String>,
|
||||
}
|
||||
@@ -73,6 +76,7 @@ impl PasswordOrOtpData {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::struct_excessive_bools, reason = "Bitwarden clients expect the data in this specific format")]
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MasterPasswordPolicy {
|
||||
|
||||
+24
-22
@@ -6,17 +6,22 @@ use std::{
|
||||
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use rmpv::Value;
|
||||
use rocket::{futures::StreamExt, Route};
|
||||
use rocket::{Route, futures::StreamExt};
|
||||
use rocket_ws::{Message, WebSocket};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
use crate::{
|
||||
CONFIG, Error,
|
||||
auth::{ClientIp, WsAccessTokenHeader},
|
||||
db::{
|
||||
models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId},
|
||||
DbConn,
|
||||
models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId},
|
||||
},
|
||||
Error, CONFIG,
|
||||
};
|
||||
|
||||
use super::{
|
||||
push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout,
|
||||
push_send_update, push_user_update,
|
||||
};
|
||||
|
||||
pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
|
||||
@@ -31,11 +36,6 @@ pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscripti
|
||||
})
|
||||
});
|
||||
|
||||
use super::{
|
||||
push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout,
|
||||
push_send_update, push_user_update,
|
||||
};
|
||||
|
||||
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -102,7 +102,7 @@ impl Drop for WSAnonymousEntryMapGuard {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(tail_expr_drop_order)]
|
||||
#[expect(tail_expr_drop_order)]
|
||||
#[get("/hub?<data..>")]
|
||||
fn websockets_hub<'r>(
|
||||
ws: WebSocket,
|
||||
@@ -186,7 +186,7 @@ fn websockets_hub<'r>(
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(tail_expr_drop_order)]
|
||||
#[expect(tail_expr_drop_order)]
|
||||
#[get("/anonymous-hub?<token..>")]
|
||||
fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result<rocket_ws::Stream!['r], Error> {
|
||||
info!("Accepting Anonymous Rocket WS connection from {}", ip.ip);
|
||||
@@ -268,14 +268,15 @@ fn serialize(val: &Value) -> Vec<u8> {
|
||||
let mut len_buf: Vec<u8> = Vec::new();
|
||||
|
||||
loop {
|
||||
let mut size_part = size & 0x7f;
|
||||
#[expect(clippy::cast_possible_truncation, reason = "masked to 7 bits, fits u8")]
|
||||
let mut size_part = (size & 0x7f) as u8;
|
||||
size >>= 7;
|
||||
|
||||
if size > 0 {
|
||||
size_part |= 0x80;
|
||||
}
|
||||
|
||||
len_buf.push(size_part as u8);
|
||||
len_buf.push(size_part);
|
||||
|
||||
if size == 0 {
|
||||
break;
|
||||
@@ -329,7 +330,7 @@ pub struct WebSocketUsers {
|
||||
impl WebSocketUsers {
|
||||
async fn send_update(&self, user_id: &UserId, data: &[u8]) {
|
||||
if let Some(user) = self.map.get(user_id.as_ref()).map(|v| v.clone()) {
|
||||
for (_, sender) in user.iter() {
|
||||
for (_, sender) in &user {
|
||||
if let Err(e) = sender.send(Message::binary(data)).await {
|
||||
error!("Error sending WS update {e}");
|
||||
}
|
||||
@@ -338,7 +339,7 @@ impl WebSocketUsers {
|
||||
}
|
||||
|
||||
// NOTE: The last modified date needs to be updated before calling these methods
|
||||
pub async fn send_user_update(&self, ut: UpdateType, user: &User, push_uuid: &Option<PushId>, conn: &DbConn) {
|
||||
pub async fn send_user_update(&self, ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
|
||||
// Skip any processing if both WebSockets and Push are not active
|
||||
if *NOTIFICATIONS_DISABLED {
|
||||
return;
|
||||
@@ -358,15 +359,16 @@ impl WebSocketUsers {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_logout(&self, user: &User, acting_device_id: Option<DeviceId>, conn: &DbConn) {
|
||||
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||
// Skip any processing if both WebSockets and Push are not active
|
||||
if *NOTIFICATIONS_DISABLED {
|
||||
return;
|
||||
}
|
||||
let acting_device_id = acting_device.map(|d| d.uuid.clone());
|
||||
let data = create_update(
|
||||
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
|
||||
UpdateType::LogOut,
|
||||
acting_device_id.clone(),
|
||||
acting_device_id,
|
||||
);
|
||||
|
||||
if CONFIG.enable_websocket() {
|
||||
@@ -374,7 +376,7 @@ impl WebSocketUsers {
|
||||
}
|
||||
|
||||
if CONFIG.push_enabled() {
|
||||
push_logout(user, acting_device_id.clone(), conn).await;
|
||||
push_logout(user, acting_device, conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,10 +539,10 @@ pub struct AnonymousWebSocketSubscriptions {
|
||||
|
||||
impl AnonymousWebSocketSubscriptions {
|
||||
async fn send_update(&self, token: &str, data: &[u8]) {
|
||||
if let Some(sender) = self.map.get(token).map(|v| v.clone()) {
|
||||
if let Err(e) = sender.send(Message::binary(data)).await {
|
||||
error!("Error sending WS update {e}");
|
||||
}
|
||||
if let Some(sender) = self.map.get(token).map(|v| v.clone())
|
||||
&& let Err(e) = sender.send(Message::binary(data)).await
|
||||
{
|
||||
error!("Error sending WS update {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,7 +583,7 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_id:
|
||||
V::Nil,
|
||||
"ReceiveMessage".into(),
|
||||
V::Array(vec![V::Map(vec![
|
||||
("ContextId".into(), acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| V::Nil)),
|
||||
("ContextId".into(), acting_device_id.map_or(V::Nil, |v| v.to_string().into())),
|
||||
("Type".into(), (ut as i32).into()),
|
||||
("Payload".into(), payload.into()),
|
||||
])]),
|
||||
|
||||
+30
-32
@@ -4,21 +4,21 @@ use std::{
|
||||
};
|
||||
|
||||
use reqwest::{
|
||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||
Method,
|
||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{ApiResult, EmptyResult, UpdateType},
|
||||
db::{
|
||||
models::{AuthRequestId, Cipher, Device, DeviceId, Folder, PushId, Send, User, UserId},
|
||||
DbConn,
|
||||
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
||||
},
|
||||
http_client::make_http_request,
|
||||
util::{format_date, get_uuid},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -74,9 +74,9 @@ async fn get_auth_api_token() -> ApiResult<String> {
|
||||
};
|
||||
|
||||
let mut api_token = API_TOKEN.write().await;
|
||||
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
|
||||
.unwrap();
|
||||
// Token valid for half the specified time
|
||||
let half_expires_in = u64::from((json_pushtoken.expires_in / 2).max(0).cast_unsigned());
|
||||
api_token.valid_until = Instant::now().checked_add(Duration::from_secs(half_expires_in)).unwrap();
|
||||
|
||||
api_token.access_token = json_pushtoken.access_token;
|
||||
|
||||
@@ -128,14 +128,14 @@ pub async fn register_push_device(device: &mut Device, conn: &DbConn) -> EmptyRe
|
||||
err!(format!("An error occurred while proceeding registration of a device: {e}"));
|
||||
}
|
||||
|
||||
if let Err(e) = device.save(conn).await {
|
||||
if let Err(e) = device.save(true, conn).await {
|
||||
err!(format!("An error occurred while trying to save the (registered) device push uuid: {e}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unregister_push_device(push_id: &Option<PushId>) -> EmptyResult {
|
||||
pub async fn unregister_push_device(push_id: Option<&PushId>) -> EmptyResult {
|
||||
if !CONFIG.push_enabled() || push_id.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -161,7 +161,7 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
|
||||
// We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too.
|
||||
if cipher.organization_uuid.is_some() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
let Some(user_id) = &cipher.user_uuid else {
|
||||
debug!("Cipher has no uuid");
|
||||
return;
|
||||
@@ -188,15 +188,13 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn push_logout(user: &User, acting_device_id: Option<DeviceId>, conn: &DbConn) {
|
||||
let acting_device_id: Value = acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| Value::Null);
|
||||
|
||||
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||
tokio::task::spawn(send_to_push_relay(json!({
|
||||
"userId": user.uuid,
|
||||
"organizationId": (),
|
||||
"deviceId": acting_device_id,
|
||||
"identifier": acting_device_id,
|
||||
"deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()),
|
||||
"identifier": acting_device.map(|d| &d.uuid),
|
||||
"type": UpdateType::LogOut as i32,
|
||||
"payload": {
|
||||
"userId": user.uuid,
|
||||
@@ -208,7 +206,7 @@ pub async fn push_logout(user: &User, acting_device_id: Option<DeviceId>, conn:
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: &Option<PushId>, conn: &DbConn) {
|
||||
pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
|
||||
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||
tokio::task::spawn(send_to_push_relay(json!({
|
||||
"userId": user.uuid,
|
||||
@@ -246,23 +244,23 @@ pub async fn push_folder_update(ut: UpdateType, folder: &Folder, device: &Device
|
||||
}
|
||||
|
||||
pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn: &DbConn) {
|
||||
if let Some(s) = &send.user_uuid {
|
||||
if Device::check_user_has_push_device(s, conn).await {
|
||||
tokio::task::spawn(send_to_push_relay(json!({
|
||||
if let Some(s) = &send.user_uuid
|
||||
&& Device::check_user_has_push_device(s, conn).await
|
||||
{
|
||||
tokio::task::spawn(send_to_push_relay(json!({
|
||||
"userId": send.user_uuid,
|
||||
"organizationId": null,
|
||||
"deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device)
|
||||
"identifier": device.uuid, // Should be the acting device id (aka uuid per device/app)
|
||||
"type": ut as i32,
|
||||
"payload": {
|
||||
"id": send.uuid,
|
||||
"userId": send.user_uuid,
|
||||
"organizationId": null,
|
||||
"deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device)
|
||||
"identifier": device.uuid, // Should be the acting device id (aka uuid per device/app)
|
||||
"type": ut as i32,
|
||||
"payload": {
|
||||
"id": send.uuid,
|
||||
"userId": send.user_uuid,
|
||||
"revisionDate": format_date(&send.revision_date)
|
||||
},
|
||||
"clientType": null,
|
||||
"installationId": null
|
||||
})));
|
||||
}
|
||||
"revisionDate": format_date(&send.revision_date)
|
||||
},
|
||||
"clientType": null,
|
||||
"installationId": null
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +296,7 @@ async fn send_to_push_relay(notification_data: Value) {
|
||||
.await
|
||||
{
|
||||
error!("An error occurred while sending a send update to the push relay: {e}");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn push_auth_request(user_id: &UserId, auth_request_id: &str, device: &Device, conn: &DbConn) {
|
||||
|
||||
+43
-13
@@ -1,21 +1,24 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rocket::{
|
||||
Catcher, Route,
|
||||
fs::NamedFile,
|
||||
http::ContentType,
|
||||
response::{content::RawCss as Css, content::RawHtml as Html, Redirect},
|
||||
response::{Redirect, content::RawCss as Css, content::RawHtml as Html},
|
||||
serde::json::Json,
|
||||
Catcher, Route,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
api::{core::now, ApiResult, EmptyResult},
|
||||
CONFIG,
|
||||
api::{ApiResult, EmptyResult, core::now},
|
||||
auth::decode_file_download,
|
||||
db::models::{AttachmentId, CipherId},
|
||||
db::{
|
||||
DbConn,
|
||||
models::{AttachmentId, CipherId},
|
||||
},
|
||||
error::Error,
|
||||
util::Cached,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -23,12 +26,20 @@ pub fn routes() -> Vec<Route> {
|
||||
// crate::utils::LOGGED_ROUTES to make sure they appear in the log
|
||||
let mut routes = routes![attachments, alive, alive_head, static_files];
|
||||
if CONFIG.web_vault_enabled() {
|
||||
routes.append(&mut routes![web_index, web_index_direct, web_index_head, app_id, web_files, vaultwarden_css]);
|
||||
routes.append(&mut routes![
|
||||
web_index,
|
||||
web_index_direct,
|
||||
web_index_head,
|
||||
app_id,
|
||||
apple_app_site_association,
|
||||
web_files,
|
||||
vaultwarden_css
|
||||
]);
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if CONFIG.reload_templates() {
|
||||
routes.append(&mut routes![_static_files_dev]);
|
||||
routes.append(&mut routes![static_files_dev]);
|
||||
}
|
||||
|
||||
routes
|
||||
@@ -60,11 +71,13 @@ fn vaultwarden_css() -> Cached<Css<String>> {
|
||||
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
|
||||
"mail_enabled": CONFIG.mail_enabled(),
|
||||
"sends_allowed": CONFIG.sends_allowed(),
|
||||
"remember_2fa_disabled": CONFIG.disable_2fa_remember(),
|
||||
"password_hints_allowed": CONFIG.password_hints_allowed(),
|
||||
"signup_disabled": CONFIG.is_signup_disabled(),
|
||||
"sso_enabled": CONFIG.sso_enabled(),
|
||||
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(),
|
||||
"yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(),
|
||||
"webauthn_2fa_supported": CONFIG.is_webauthn_2fa_supported(),
|
||||
"yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(),
|
||||
});
|
||||
|
||||
let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) {
|
||||
@@ -158,6 +171,24 @@ fn app_id() -> Cached<(ContentType, Json<Value>)> {
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/.well-known/apple-app-site-association")]
|
||||
fn apple_app_site_association() -> Cached<(ContentType, Json<Value>)> {
|
||||
Cached::long(
|
||||
(
|
||||
ContentType::JSON,
|
||||
Json(json!({
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"LTZ2PFU5D6.com.8bit.bitwarden",
|
||||
"LTZ2PFU5D6.com.8bit.bitwarden.beta"
|
||||
]
|
||||
}
|
||||
})),
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/<p..>", rank = 10)] // Only match this if the other routes don't match
|
||||
async fn web_files(p: PathBuf) -> Cached<Option<NamedFile>> {
|
||||
Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true)
|
||||
@@ -176,7 +207,6 @@ async fn attachments(cipher_id: CipherId, file_id: AttachmentId, token: String)
|
||||
}
|
||||
|
||||
// We use DbConn here to let the alive healthcheck also verify the database connection.
|
||||
use crate::db::DbConn;
|
||||
#[get("/alive")]
|
||||
fn alive(_conn: DbConn) -> Json<String> {
|
||||
now()
|
||||
@@ -195,7 +225,7 @@ fn alive_head(_conn: DbConn) -> EmptyResult {
|
||||
// NOTE: Do not forget to add any new files added to the `static_files` function below!
|
||||
#[cfg(debug_assertions)]
|
||||
#[get("/vw_static/<filename>", rank = 1)]
|
||||
pub async fn _static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||
pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||
warn!("LOADING STATIC FILES FROM DISK");
|
||||
let file = filename.to_str().unwrap_or_default();
|
||||
let ext = filename.extension().unwrap_or_default();
|
||||
@@ -208,7 +238,7 @@ pub async fn _static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||
|
||||
if let Ok(path) = path {
|
||||
return NamedFile::open(path).await.ok();
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -238,8 +268,8 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro
|
||||
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
|
||||
"datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
|
||||
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
|
||||
"jquery-3.7.1.slim.js" => {
|
||||
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-3.7.1.slim.js")))
|
||||
"jquery-4.0.0.slim.js" => {
|
||||
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-4.0.0.slim.js")))
|
||||
}
|
||||
_ => err!(format!("Static file not found: {filename}")),
|
||||
}
|
||||
|
||||
+155
-102
@@ -5,21 +5,30 @@ use std::{
|
||||
};
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header};
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
|
||||
use num_traits::FromPrimitive;
|
||||
use openssl::rsa::Rsa;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::ser::Serialize;
|
||||
use serde::{de::DeserializeOwned, ser::Serialize};
|
||||
|
||||
use rocket::{
|
||||
outcome::try_outcome,
|
||||
request::{FromRequest, Outcome, Request},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::ApiResult,
|
||||
config::PathType,
|
||||
db::models::{
|
||||
AttachmentId, CipherId, CollectionId, DeviceId, DeviceType, EmergencyAccessId, MembershipId, OrgApiKeyId,
|
||||
OrganizationId, SendFileId, SendId, UserId,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{
|
||||
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId,
|
||||
SendId, User, UserId, UserStampException,
|
||||
},
|
||||
},
|
||||
error::Error,
|
||||
sso, CONFIG,
|
||||
sso,
|
||||
};
|
||||
|
||||
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
||||
@@ -46,21 +55,18 @@ static JWT_FILE_DOWNLOAD_ISSUER: LazyLock<String> =
|
||||
LazyLock::new(|| format!("{}|file_download", CONFIG.domain_origin()));
|
||||
static JWT_REGISTER_VERIFY_ISSUER: LazyLock<String> =
|
||||
LazyLock::new(|| format!("{}|register_verify", CONFIG.domain_origin()));
|
||||
static JWT_2FA_REMEMBER_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|2faremember", CONFIG.domain_origin()));
|
||||
|
||||
static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
|
||||
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
|
||||
|
||||
pub async fn initialize_keys() -> Result<(), Error> {
|
||||
use std::io::Error;
|
||||
use std::io::Error as IoError;
|
||||
|
||||
let rsa_key_filename = std::path::PathBuf::from(CONFIG.private_rsa_key())
|
||||
.file_name()
|
||||
.ok_or_else(|| Error::other("Private RSA key path missing filename"))?
|
||||
.to_str()
|
||||
.ok_or_else(|| Error::other("Private RSA key path filename is not valid UTF-8"))?
|
||||
.to_string();
|
||||
let rsa_key_filename = crate::storage::file_name(&CONFIG.private_rsa_key())
|
||||
.ok_or_else(|| IoError::other("Private RSA key path missing filename"))?;
|
||||
|
||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(Error::other)?;
|
||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(IoError::other)?;
|
||||
|
||||
let priv_key_buffer = match operator.read(&rsa_key_filename).await {
|
||||
Ok(buffer) => Some(buffer),
|
||||
@@ -160,6 +166,10 @@ pub fn decode_register_verify(token: &str) -> Result<RegisterVerifyClaims, Error
|
||||
decode_jwt(token, JWT_REGISTER_VERIFY_ISSUER.to_string())
|
||||
}
|
||||
|
||||
pub fn decode_2fa_remember(token: &str) -> Result<TwoFactorRememberClaims, Error> {
|
||||
decode_jwt(token, JWT_2FA_REMEMBER_ISSUER.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginJwtClaims {
|
||||
// Not before
|
||||
@@ -225,7 +235,7 @@ impl LoginJwtClaims {
|
||||
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
||||
|
||||
if exp <= (now + *BW_EXPIRATION).timestamp() {
|
||||
warn!("Raise access_token lifetime to more than 5min.")
|
||||
warn!("Raise access_token lifetime to more than 5min.");
|
||||
}
|
||||
|
||||
// Create the JWT claims struct, to send to the client
|
||||
@@ -252,7 +262,7 @@ impl LoginJwtClaims {
|
||||
sstamp: user.security_stamp.clone(),
|
||||
device: device.uuid.clone(),
|
||||
devicetype: DeviceType::from_i32(device.atype).to_string(),
|
||||
client_id: client_id.unwrap_or("undefined".to_string()),
|
||||
client_id: client_id.unwrap_or("undefined".to_owned()),
|
||||
scope,
|
||||
amr: vec!["Application".into()],
|
||||
}
|
||||
@@ -440,6 +450,31 @@ pub fn generate_register_verify_claims(email: String, name: Option<String>, veri
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TwoFactorRememberClaims {
|
||||
// Not before
|
||||
pub nbf: i64,
|
||||
// Expiration time
|
||||
pub exp: i64,
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: DeviceId,
|
||||
// UserId
|
||||
pub user_uuid: UserId,
|
||||
}
|
||||
|
||||
pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) -> TwoFactorRememberClaims {
|
||||
let time_now = Utc::now();
|
||||
TwoFactorRememberClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(),
|
||||
iss: JWT_2FA_REMEMBER_ISSUER.to_string(),
|
||||
sub: device_uuid,
|
||||
user_uuid,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BasicJwtClaims {
|
||||
// Not before
|
||||
@@ -480,7 +515,7 @@ pub fn generate_admin_claims() -> BasicJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
|
||||
iss: JWT_ADMIN_ISSUER.to_string(),
|
||||
sub: "admin_panel".to_string(),
|
||||
sub: "admin_panel".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,16 +532,6 @@ pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtC
|
||||
//
|
||||
// Bearer token authentication
|
||||
//
|
||||
use rocket::{
|
||||
outcome::try_outcome,
|
||||
request::{FromRequest, Outcome, Request},
|
||||
};
|
||||
|
||||
use crate::db::{
|
||||
models::{Collection, Device, Membership, MembershipStatus, MembershipType, User, UserStampException},
|
||||
DbConn,
|
||||
};
|
||||
|
||||
pub struct Host {
|
||||
pub host: String,
|
||||
}
|
||||
@@ -522,7 +547,7 @@ impl<'r> FromRequest<'r> for Host {
|
||||
let host = if CONFIG.domain_set() {
|
||||
CONFIG.domain()
|
||||
} else if let Some(referer) = headers.get_one("Referer") {
|
||||
referer.to_string()
|
||||
referer.to_owned()
|
||||
} else {
|
||||
// Try to guess from the headers
|
||||
let protocol = if let Some(proto) = headers.get_one("X-Forwarded-Proto") {
|
||||
@@ -558,13 +583,15 @@ impl<'r> FromRequest<'r> for ClientHeaders {
|
||||
type Error = &'static str;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let ip = match ClientIp::from_request(request).await {
|
||||
Outcome::Success(ip) => ip,
|
||||
_ => err_handler!("Error getting Client IP"),
|
||||
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||
err_handler!("Error getting Client IP")
|
||||
};
|
||||
// When unknown or unable to parse, return 14, which is 'Unknown Browser'
|
||||
let device_type: i32 =
|
||||
request.headers().get_one("device-type").map(|d| d.parse().unwrap_or(14)).unwrap_or_else(|| 14);
|
||||
// When unknown or unable to parse, return 'UnknownBrowser'
|
||||
let device_type: i32 = request
|
||||
.headers()
|
||||
.get_one("device-type")
|
||||
.and_then(|d| d.parse().ok())
|
||||
.unwrap_or(DeviceType::UnknownBrowser as i32);
|
||||
|
||||
Outcome::Success(ClientHeaders {
|
||||
device_type,
|
||||
@@ -588,18 +615,19 @@ impl<'r> FromRequest<'r> for Headers {
|
||||
let headers = request.headers();
|
||||
|
||||
let host = try_outcome!(Host::from_request(request).await).host;
|
||||
let ip = match ClientIp::from_request(request).await {
|
||||
Outcome::Success(ip) => ip,
|
||||
_ => err_handler!("Error getting Client IP"),
|
||||
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||
err_handler!("Error getting Client IP")
|
||||
};
|
||||
|
||||
// Get access_token
|
||||
let access_token: &str = match headers.get_one("Authorization") {
|
||||
Some(a) => match a.rsplit("Bearer ").next() {
|
||||
Some(split) => split,
|
||||
None => err_handler!("No access token provided"),
|
||||
},
|
||||
None => err_handler!("No access token provided"),
|
||||
let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
|
||||
if let Some(split) = a.rsplit("Bearer ").next() {
|
||||
split
|
||||
} else {
|
||||
err_handler!("No access token provided")
|
||||
}
|
||||
} else {
|
||||
err_handler!("No access token provided")
|
||||
};
|
||||
|
||||
// Check JWT token is valid and get device and user from it
|
||||
@@ -610,9 +638,8 @@ impl<'r> FromRequest<'r> for Headers {
|
||||
let device_id = claims.device;
|
||||
let user_id = claims.sub;
|
||||
|
||||
let conn = match DbConn::from_request(request).await {
|
||||
Outcome::Success(conn) => conn,
|
||||
_ => err_handler!("Error getting DB"),
|
||||
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||
err_handler!("Error getting DB")
|
||||
};
|
||||
|
||||
let Some(device) = Device::find_by_uuid_and_user(&device_id, &user_id, &conn).await else {
|
||||
@@ -643,7 +670,7 @@ impl<'r> FromRequest<'r> for Headers {
|
||||
error!("Error updating user: {e:#?}");
|
||||
}
|
||||
err_handler!("Stamp exception is expired")
|
||||
} else if !stamp_exception.routes.contains(¤t_route.to_string()) {
|
||||
} else if !stamp_exception.routes.contains(¤t_route.to_owned()) {
|
||||
err_handler!("Invalid security stamp: Current route and exception route do not match")
|
||||
} else if stamp_exception.security_stamp != claims.sstamp {
|
||||
err_handler!("Invalid security stamp for matched stamp exception")
|
||||
@@ -674,10 +701,9 @@ pub struct OrgHeaders {
|
||||
|
||||
impl OrgHeaders {
|
||||
fn is_member(&self) -> bool {
|
||||
// NOTE: we don't care about MembershipStatus at the moment because this is only used
|
||||
// where an invited, accepted or confirmed user is expected if this ever changes or
|
||||
// if from_i32 is changed to return Some(Revoked) this check needs to be changed accordingly
|
||||
self.membership_type >= MembershipType::User
|
||||
// Only allow not revoked members, we can not use the Confirmed status here
|
||||
// as some endpoints can be triggered by invited users during joining
|
||||
self.membership_status != MembershipStatus::Revoked && self.membership_type >= MembershipType::User
|
||||
}
|
||||
fn is_confirmed_and_admin(&self) -> bool {
|
||||
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin
|
||||
@@ -690,6 +716,36 @@ impl OrgHeaders {
|
||||
}
|
||||
}
|
||||
|
||||
// org_id is usually the second path param ("/organizations/<org_id>"),
|
||||
// but there are cases where it is a query value.
|
||||
// First check the path, if this is not a valid uuid, try the query values.
|
||||
fn get_org_id(request: &Request<'_>) -> Option<OrganizationId> {
|
||||
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
|
||||
Some(org_id)
|
||||
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
|
||||
Some(org_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Special Guard to ensure that there is an organization id present
|
||||
// If there is no org id trigger the Outcome::Forward.
|
||||
// This is useful for endpoints which work for both organization and personal vaults, like purge.
|
||||
pub struct OrgIdGuard;
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for OrgIdGuard {
|
||||
type Error = &'static str;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
match get_org_id(request) {
|
||||
Some(_) => Outcome::Success(OrgIdGuard),
|
||||
None => Outcome::Forward(rocket::http::Status::NotFound),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for OrgHeaders {
|
||||
type Error = &'static str;
|
||||
@@ -697,24 +753,13 @@ impl<'r> FromRequest<'r> for OrgHeaders {
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = try_outcome!(Headers::from_request(request).await);
|
||||
|
||||
// org_id is usually the second path param ("/organizations/<org_id>"),
|
||||
// but there are cases where it is a query value.
|
||||
// First check the path, if this is not a valid uuid, try the query values.
|
||||
let url_org_id: Option<OrganizationId> = {
|
||||
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
|
||||
Some(org_id)
|
||||
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
|
||||
Some(org_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
// Extract the org_id from the request
|
||||
let url_org_id = get_org_id(request);
|
||||
|
||||
match url_org_id {
|
||||
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
|
||||
let conn = match DbConn::from_request(request).await {
|
||||
Outcome::Success(conn) => conn,
|
||||
_ => err_handler!("Error getting DB"),
|
||||
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||
err_handler!("Error getting DB")
|
||||
};
|
||||
|
||||
let user = headers.user;
|
||||
@@ -786,16 +831,16 @@ impl<'r> FromRequest<'r> for AdminHeaders {
|
||||
// but there could be cases where it is a query value.
|
||||
// First check the path, if this is not a valid uuid, try the query values.
|
||||
fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
|
||||
if let Some(Ok(col_id)) = request.param::<String>(3) {
|
||||
if uuid::Uuid::parse_str(&col_id).is_ok() {
|
||||
return Some(col_id.into());
|
||||
}
|
||||
if let Some(Ok(col_id)) = request.param::<String>(3)
|
||||
&& uuid::Uuid::parse_str(&col_id).is_ok()
|
||||
{
|
||||
return Some(col_id.into());
|
||||
}
|
||||
|
||||
if let Some(Ok(col_id)) = request.query_value::<String>("collectionId") {
|
||||
if uuid::Uuid::parse_str(&col_id).is_ok() {
|
||||
return Some(col_id.into());
|
||||
}
|
||||
if let Some(Ok(col_id)) = request.query_value::<String>("collectionId")
|
||||
&& uuid::Uuid::parse_str(&col_id).is_ok()
|
||||
{
|
||||
return Some(col_id.into());
|
||||
}
|
||||
|
||||
None
|
||||
@@ -819,18 +864,16 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
||||
if headers.is_confirmed_and_manager() {
|
||||
match get_col_id(request) {
|
||||
Some(col_id) => {
|
||||
let conn = match DbConn::from_request(request).await {
|
||||
Outcome::Success(conn) => conn,
|
||||
_ => err_handler!("Error getting DB"),
|
||||
};
|
||||
if let Some(col_id) = get_col_id(request) {
|
||||
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||
err_handler!("Error getting DB")
|
||||
};
|
||||
|
||||
if !Collection::can_access_collection(&headers.membership, &col_id, &conn).await {
|
||||
err_handler!("The current user isn't a manager for this collection")
|
||||
}
|
||||
if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await {
|
||||
err_handler!("The current user isn't a manager for this collection")
|
||||
}
|
||||
_ => err_handler!("Error getting the collection id"),
|
||||
} else {
|
||||
err_handler!("Error getting the collection id")
|
||||
}
|
||||
|
||||
Outcome::Success(Self {
|
||||
@@ -908,8 +951,8 @@ impl ManagerHeaders {
|
||||
if uuid::Uuid::parse_str(col_id.as_ref()).is_err() {
|
||||
err!("Collection Id is malformed!");
|
||||
}
|
||||
if !Collection::can_access_collection(&h.membership, col_id, conn).await {
|
||||
err!("You don't have access to all collections!");
|
||||
if !Collection::is_coll_manageable_by_user(col_id, &h.membership.user_uuid, conn).await {
|
||||
err!("Collection not found", "The current user isn't a manager for this collection")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -991,7 +1034,7 @@ impl From<OrgMemberHeaders> for Headers {
|
||||
//
|
||||
// Client IP address detection
|
||||
//
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct ClientIp {
|
||||
pub ip: IpAddr,
|
||||
}
|
||||
@@ -1023,6 +1066,7 @@ impl<'r> FromRequest<'r> for ClientIp {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Secure {
|
||||
pub https: bool,
|
||||
}
|
||||
@@ -1108,15 +1152,14 @@ pub enum AuthMethod {
|
||||
impl AuthMethod {
|
||||
pub fn scope(&self) -> String {
|
||||
match self {
|
||||
AuthMethod::OrgApiKey => "api.organization".to_string(),
|
||||
AuthMethod::Password => "api offline_access".to_string(),
|
||||
AuthMethod::Sso => "api offline_access".to_string(),
|
||||
AuthMethod::UserApiKey => "api".to_string(),
|
||||
AuthMethod::OrgApiKey => "api.organization".to_owned(),
|
||||
AuthMethod::UserApiKey => "api".to_owned(),
|
||||
AuthMethod::Password | AuthMethod::Sso => "api offline_access".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scope_vec(&self) -> Vec<String> {
|
||||
self.scope().split_whitespace().map(str::to_string).collect()
|
||||
self.scope().split_whitespace().map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
pub fn check_scope(&self, scope: Option<&String>) -> ApiResult<String> {
|
||||
@@ -1210,24 +1253,34 @@ pub async fn refresh_tokens(
|
||||
) -> ApiResult<(Device, AuthTokens)> {
|
||||
let refresh_claims = match decode_refresh(refresh_token) {
|
||||
Err(err) => {
|
||||
debug!("Failed to decode {} refresh_token: {refresh_token}", ip.ip);
|
||||
err_silent!(format!("Impossible to read refresh_token: {}", err.message()))
|
||||
error!("Failed to decode {} refresh_token: {refresh_token}: {err:?}", ip.ip);
|
||||
//err_silent!(format!("Impossible to read refresh_token: {}", err.message()))
|
||||
|
||||
// If the token failed to decode, it was probably one of the old style tokens that was just a Base64 string.
|
||||
// We can generate a claim for them for backwards compatibility. Note that the password refresh claims don't
|
||||
// check expiration or issuer, so they're not included here.
|
||||
RefreshJwtClaims {
|
||||
nbf: 0,
|
||||
exp: 0,
|
||||
iss: String::new(),
|
||||
sub: AuthMethod::Password,
|
||||
device_token: refresh_token.into(),
|
||||
token: None,
|
||||
}
|
||||
}
|
||||
Ok(claims) => claims,
|
||||
};
|
||||
|
||||
// Get device by refresh token
|
||||
let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await {
|
||||
None => err!("Invalid refresh token"),
|
||||
Some(device) => device,
|
||||
let Some(mut device) = Device::find_by_refresh_token(&refresh_claims.device_token, conn).await else {
|
||||
err!("Invalid refresh token")
|
||||
};
|
||||
|
||||
// Save to update `updated_at`.
|
||||
device.save(conn).await?;
|
||||
device.save(true, conn).await?;
|
||||
|
||||
let user = match User::find_by_uuid(&device.user_uuid, conn).await {
|
||||
None => err!("Impossible to find user"),
|
||||
Some(user) => user,
|
||||
let Some(user) = User::find_by_uuid(&device.user_uuid, conn).await else {
|
||||
err!("Impossible to find user")
|
||||
};
|
||||
|
||||
let auth_tokens = match refresh_claims.sub {
|
||||
|
||||
+206
-258
@@ -3,8 +3,8 @@ use std::{
|
||||
fmt,
|
||||
process::exit,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
LazyLock, RwLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,23 +14,23 @@ use serde::de::{self, Deserialize, Deserializer, MapAccess, Visitor};
|
||||
|
||||
use crate::{
|
||||
error::Error,
|
||||
util::{get_env, get_env_bool, get_web_vault_version, is_valid_email, parse_experimental_client_feature_flags},
|
||||
storage,
|
||||
util::{
|
||||
FeatureFlagFilter, get_active_web_release, get_env, get_env_bool, is_valid_email,
|
||||
parse_experimental_client_feature_flags,
|
||||
},
|
||||
};
|
||||
|
||||
static CONFIG_FILE: LazyLock<String> = LazyLock::new(|| {
|
||||
let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data"));
|
||||
get_env("CONFIG_FILE").unwrap_or_else(|| format!("{data_folder}/config.json"))
|
||||
get_env("CONFIG_FILE").unwrap_or_else(|| storage::join_path(&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_FILE_PARENT_DIR: LazyLock<String> =
|
||||
LazyLock::new(|| storage::parent(&CONFIG_FILE).unwrap_or_else(|| "data".to_owned()));
|
||||
|
||||
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()
|
||||
});
|
||||
static CONFIG_FILENAME: LazyLock<String> =
|
||||
LazyLock::new(|| storage::file_name(&CONFIG_FILE).unwrap_or_else(|| "config.json".to_owned()));
|
||||
|
||||
pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -260,7 +260,7 @@ macro_rules! make_config {
|
||||
}
|
||||
|
||||
async fn from_file() -> Result<Self, Error> {
|
||||
let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
||||
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
||||
let config_bytes = operator.read(&CONFIG_FILENAME).await?;
|
||||
println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE);
|
||||
serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into)
|
||||
@@ -360,13 +360,7 @@ macro_rules! make_config {
|
||||
)+)+
|
||||
|
||||
pub fn prepare_json(&self) -> serde_json::Value {
|
||||
let (def, cfg, overridden) = {
|
||||
// Lock the inner as short as possible and clone what is needed to prevent deadlocks
|
||||
let inner = &self.inner.read().unwrap();
|
||||
(inner._env.build(), inner.config.clone(), inner._overrides.clone())
|
||||
};
|
||||
|
||||
fn _get_form_type(rust_type: &'static str) -> &'static str {
|
||||
fn get_form_type(rust_type: &'static str) -> &'static str {
|
||||
match rust_type {
|
||||
"Pass" => "password",
|
||||
"String" => "text",
|
||||
@@ -375,7 +369,7 @@ macro_rules! make_config {
|
||||
}
|
||||
}
|
||||
|
||||
fn _get_doc(doc_str: &'static str) -> ElementDoc {
|
||||
fn get_doc(doc_str: &'static str) -> ElementDoc {
|
||||
let mut split = doc_str.split("|>").map(str::trim);
|
||||
ElementDoc {
|
||||
name: split.next().unwrap_or_default(),
|
||||
@@ -383,6 +377,12 @@ macro_rules! make_config {
|
||||
}
|
||||
}
|
||||
|
||||
let (def, cfg, overridden) = {
|
||||
// Lock the inner as short as possible and clone what is needed to prevent deadlocks
|
||||
let inner = &self.inner.read().unwrap();
|
||||
(inner._env.build(), inner.config.clone(), inner._overrides.clone())
|
||||
};
|
||||
|
||||
let data: Vec<GroupData> = vec![
|
||||
$( // This repetition is for each group
|
||||
GroupData {
|
||||
@@ -397,8 +397,8 @@ macro_rules! make_config {
|
||||
name: stringify!($name),
|
||||
value: serde_json::to_value(&cfg.$name).unwrap_or_default(),
|
||||
default: serde_json::to_value(&def.$name).unwrap_or_default(),
|
||||
r#type: _get_form_type(stringify!($ty)),
|
||||
doc: _get_doc(concat!($($doc),+)),
|
||||
r#type: get_form_type(stringify!($ty)),
|
||||
doc: get_doc(concat!($($doc),+)),
|
||||
overridden: overridden.contains(&pastey::paste!(stringify!([<$name:upper>]))),
|
||||
},
|
||||
)+], // End of elements repetition
|
||||
@@ -408,9 +408,31 @@ macro_rules! make_config {
|
||||
}
|
||||
|
||||
pub fn get_support_json(&self) -> serde_json::Value {
|
||||
/// We map over the string and remove all alphanumeric, _ and - characters.
|
||||
/// This is the fastest way (within micro-seconds) instead of using a regex (which takes mili-seconds)
|
||||
fn privacy_mask(value: &str) -> String {
|
||||
let mut n: u16 = 0;
|
||||
let mut colon_match = false;
|
||||
value
|
||||
.chars()
|
||||
.map(|c| {
|
||||
n += 1;
|
||||
match c {
|
||||
':' if n <= 11 => {
|
||||
colon_match = true;
|
||||
c
|
||||
}
|
||||
'/' if n <= 13 && colon_match => c,
|
||||
',' => c,
|
||||
_ => '*',
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
// Define which config keys need to be masked.
|
||||
// Pass types will always be masked and no need to put them in the list.
|
||||
// Besides Pass, only String types will be masked via _privacy_mask.
|
||||
// Besides Pass, only String types will be masked via privacy_mask.
|
||||
const PRIVACY_CONFIG: &[&str] = &[
|
||||
"allowed_connect_src",
|
||||
"allowed_iframe_ancestors",
|
||||
@@ -437,28 +459,6 @@ macro_rules! make_config {
|
||||
inner.config.clone()
|
||||
};
|
||||
|
||||
/// We map over the string and remove all alphanumeric, _ and - characters.
|
||||
/// This is the fastest way (within micro-seconds) instead of using a regex (which takes mili-seconds)
|
||||
fn _privacy_mask(value: &str) -> String {
|
||||
let mut n: u16 = 0;
|
||||
let mut colon_match = false;
|
||||
value
|
||||
.chars()
|
||||
.map(|c| {
|
||||
n += 1;
|
||||
match c {
|
||||
':' if n <= 11 => {
|
||||
colon_match = true;
|
||||
c
|
||||
}
|
||||
'/' if n <= 13 && colon_match => c,
|
||||
',' => c,
|
||||
_ => '*',
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
serde_json::Value::Object({
|
||||
let mut json = serde_json::Map::new();
|
||||
$($(
|
||||
@@ -468,7 +468,7 @@ macro_rules! make_config {
|
||||
for mask_key in PRIVACY_CONFIG {
|
||||
if let Some(value) = json.get_mut(*mask_key) {
|
||||
if let Some(s) = value.as_str() {
|
||||
*value = _privacy_mask(s).into();
|
||||
*value = privacy_mask(s).into();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,23 +502,23 @@ macro_rules! make_config {
|
||||
make_config! {
|
||||
folders {
|
||||
/// Data folder |> Main data folder
|
||||
data_folder: String, false, def, "data".to_string();
|
||||
data_folder: String, false, def, "data".to_owned();
|
||||
/// Database URL
|
||||
database_url: String, false, auto, |c| format!("{}/db.sqlite3", c.data_folder);
|
||||
database_url: String, false, auto, |c| format!("sqlite://{}", storage::join_path(&c.data_folder, "db.sqlite3"));
|
||||
/// Icon cache folder
|
||||
icon_cache_folder: String, false, auto, |c| format!("{}/icon_cache", c.data_folder);
|
||||
icon_cache_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "icon_cache");
|
||||
/// Attachments folder
|
||||
attachments_folder: String, false, auto, |c| format!("{}/attachments", c.data_folder);
|
||||
attachments_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "attachments");
|
||||
/// Sends folder
|
||||
sends_folder: String, false, auto, |c| format!("{}/sends", c.data_folder);
|
||||
sends_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "sends");
|
||||
/// Temp folder |> Used for storing temporary file uploads
|
||||
tmp_folder: String, false, auto, |c| format!("{}/tmp", c.data_folder);
|
||||
tmp_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "tmp");
|
||||
/// Templates folder
|
||||
templates_folder: String, false, auto, |c| format!("{}/templates", c.data_folder);
|
||||
templates_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "templates");
|
||||
/// Session JWT key
|
||||
rsa_key_filename: String, false, auto, |c| format!("{}/rsa_key", c.data_folder);
|
||||
rsa_key_filename: String, false, auto, |c| storage::join_path(&c.data_folder, "rsa_key");
|
||||
/// Web vault folder
|
||||
web_vault_folder: String, false, def, "web-vault/".to_string();
|
||||
web_vault_folder: String, false, def, "web-vault/".to_owned();
|
||||
},
|
||||
ws {
|
||||
/// Enable websocket notifications
|
||||
@@ -528,9 +528,9 @@ make_config! {
|
||||
/// Enable push notifications
|
||||
push_enabled: bool, false, def, false;
|
||||
/// Push relay uri
|
||||
push_relay_uri: String, false, def, "https://push.bitwarden.com".to_string();
|
||||
push_relay_uri: String, false, def, "https://push.bitwarden.com".to_owned();
|
||||
/// Push identity uri
|
||||
push_identity_uri: String, false, def, "https://identity.bitwarden.com".to_string();
|
||||
push_identity_uri: String, false, def, "https://identity.bitwarden.com".to_owned();
|
||||
/// Installation id |> The installation id from https://bitwarden.com/host
|
||||
push_installation_id: Pass, false, def, String::new();
|
||||
/// Installation key |> The installation key from https://bitwarden.com/host
|
||||
@@ -542,38 +542,38 @@ make_config! {
|
||||
job_poll_interval_ms: u64, false, def, 30_000;
|
||||
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
|
||||
/// Defaults to hourly. Set blank to disable this job.
|
||||
send_purge_schedule: String, false, def, "0 5 * * * *".to_string();
|
||||
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
|
||||
/// Trash purge schedule |> Cron schedule of the job that checks for trashed items to delete permanently.
|
||||
/// Defaults to daily. Set blank to disable this job.
|
||||
trash_purge_schedule: String, false, def, "0 5 0 * * *".to_string();
|
||||
trash_purge_schedule: String, false, def, "0 5 0 * * *".to_owned();
|
||||
/// Incomplete 2FA login schedule |> Cron schedule of the job that checks for incomplete 2FA logins.
|
||||
/// Defaults to once every minute. Set blank to disable this job.
|
||||
incomplete_2fa_schedule: String, false, def, "30 * * * * *".to_string();
|
||||
incomplete_2fa_schedule: String, false, def, "30 * * * * *".to_owned();
|
||||
/// Emergency notification reminder schedule |> Cron schedule of the job that sends expiration reminders to emergency access grantors.
|
||||
/// Defaults to hourly. (3 minutes after the hour) Set blank to disable this job.
|
||||
emergency_notification_reminder_schedule: String, false, def, "0 3 * * * *".to_string();
|
||||
emergency_notification_reminder_schedule: String, false, def, "0 3 * * * *".to_owned();
|
||||
/// Emergency request timeout schedule |> Cron schedule of the job that grants emergency access requests that have met the required wait time.
|
||||
/// Defaults to hourly. (7 minutes after the hour) Set blank to disable this job.
|
||||
emergency_request_timeout_schedule: String, false, def, "0 7 * * * *".to_string();
|
||||
emergency_request_timeout_schedule: String, false, def, "0 7 * * * *".to_owned();
|
||||
/// Event cleanup schedule |> Cron schedule of the job that cleans old events from the event table.
|
||||
/// Defaults to daily. Set blank to disable this job.
|
||||
event_cleanup_schedule: String, false, def, "0 10 0 * * *".to_string();
|
||||
event_cleanup_schedule: String, false, def, "0 10 0 * * *".to_owned();
|
||||
/// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request.
|
||||
/// Defaults to every minute. Set blank to disable this job.
|
||||
auth_request_purge_schedule: String, false, def, "30 * * * * *".to_string();
|
||||
auth_request_purge_schedule: String, false, def, "30 * * * * *".to_owned();
|
||||
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
|
||||
/// Defaults to once every minute. Set blank to disable this job.
|
||||
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string();
|
||||
/// Purge incomplete SSO nonce. |> Cron schedule of the job that cleans leftover nonce in db due to incomplete SSO login.
|
||||
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_owned();
|
||||
/// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login.
|
||||
/// Defaults to daily. Set blank to disable this job.
|
||||
purge_incomplete_sso_nonce: String, false, def, "0 20 0 * * *".to_string();
|
||||
purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_owned();
|
||||
},
|
||||
|
||||
/// General settings
|
||||
settings {
|
||||
/// Domain URL |> This needs to be set to the URL used to access the server, including 'http[s]://'
|
||||
/// and port, if it's different than the default. Some server functions don't work correctly without this value
|
||||
domain: String, true, def, "http://localhost".to_string();
|
||||
domain: String, true, def, "http://localhost".to_owned();
|
||||
/// Domain Set |> Indicates if the domain is set by the admin. Otherwise the default will be used.
|
||||
domain_set: bool, false, def, false;
|
||||
/// Domain origin |> Domain URL origin (in https://example.com:8443/path, https://example.com:8443 is the origin)
|
||||
@@ -653,7 +653,7 @@ make_config! {
|
||||
admin_token: Pass, true, option;
|
||||
|
||||
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
|
||||
invitation_org_name: String, true, def, "Vaultwarden".to_string();
|
||||
invitation_org_name: String, true, def, "Vaultwarden".to_owned();
|
||||
|
||||
/// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely.
|
||||
events_days_retain: i64, false, option;
|
||||
@@ -663,7 +663,7 @@ make_config! {
|
||||
advanced {
|
||||
/// Client IP header |> If not present, the remote IP is used.
|
||||
/// Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
||||
ip_header: String, true, def, "X-Real-IP".to_string();
|
||||
ip_header: String, true, def, "X-Real-IP".to_owned();
|
||||
/// Internal IP header property, used to avoid recomputing each time
|
||||
_ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none";
|
||||
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
||||
@@ -672,7 +672,7 @@ make_config! {
|
||||
/// `internal` refers to Vaultwarden's built-in icon fetching implementation. If an external
|
||||
/// service is set, an icon request to Vaultwarden will return an HTTP redirect to the
|
||||
/// corresponding icon at the external service.
|
||||
icon_service: String, false, def, "internal".to_string();
|
||||
icon_service: String, false, def, "internal".to_owned();
|
||||
/// _icon_service_url
|
||||
_icon_service_url: String, false, generated, |c| generate_icon_service_url(&c.icon_service);
|
||||
/// _icon_service_csp
|
||||
@@ -723,14 +723,14 @@ make_config! {
|
||||
/// Enable extended logging
|
||||
extended_logging: bool, false, def, true;
|
||||
/// Log timestamp format
|
||||
log_timestamp_format: String, true, def, "%Y-%m-%d %H:%M:%S.%3f".to_string();
|
||||
log_timestamp_format: String, true, def, "%Y-%m-%d %H:%M:%S.%3f".to_owned();
|
||||
/// Enable the log to output to Syslog
|
||||
use_syslog: bool, false, def, false;
|
||||
/// Log file path
|
||||
log_file: String, false, option;
|
||||
/// Log level |> Valid values are "trace", "debug", "info", "warn", "error" and "off"
|
||||
/// For a specific module append it as a comma separated value "info,path::to::module=debug"
|
||||
log_level: String, false, def, "info".to_string();
|
||||
log_level: String, false, def, "info".to_owned();
|
||||
|
||||
/// Enable DB WAL |> Turning this off might lead to worse performance, but might help if using vaultwarden on some exotic filesystems,
|
||||
/// that do not support WAL. Please make sure you read project wiki on the topic before changing this setting.
|
||||
@@ -789,6 +789,10 @@ make_config! {
|
||||
/// Bitwarden enforces this by default. In Vaultwarden we encouraged to use multiple organizations because groups were not available.
|
||||
/// Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy.
|
||||
enforce_single_org_with_reset_pw_policy: bool, false, def, false;
|
||||
|
||||
/// Prefer IPv6 (AAAA) resolving |> This settings configures the DNS resolver to resolve IPv6 first, and if not available try IPv4
|
||||
/// This could be useful in IPv6 only environments.
|
||||
dns_prefer_ipv6: bool, true, def, false;
|
||||
},
|
||||
|
||||
/// OpenID Connect SSO settings
|
||||
@@ -808,7 +812,7 @@ make_config! {
|
||||
/// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`)
|
||||
sso_authority: String, true, def, String::new();
|
||||
/// Authorization request scopes |> List the of the needed scope (`openid` is implicit)
|
||||
sso_scopes: String, true, def, "email profile".to_string();
|
||||
sso_scopes: String, true, def, "email profile".to_owned();
|
||||
/// Authorization request extra parameters
|
||||
sso_authorize_extra_params: String, true, def, String::new();
|
||||
/// Use PKCE during Authorization flow
|
||||
@@ -876,7 +880,7 @@ make_config! {
|
||||
/// From Address
|
||||
smtp_from: String, true, def, String::new();
|
||||
/// From Name
|
||||
smtp_from_name: String, true, def, "Vaultwarden".to_string();
|
||||
smtp_from_name: String, true, def, "Vaultwarden".to_owned();
|
||||
/// Username
|
||||
smtp_username: String, true, option;
|
||||
/// Password
|
||||
@@ -916,16 +920,19 @@ make_config! {
|
||||
},
|
||||
}
|
||||
|
||||
fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
||||
// Validate connection URL is valid and DB feature is enabled
|
||||
#[cfg(sqlite)]
|
||||
{
|
||||
use crate::db::DbConnType;
|
||||
let url = &cfg.database_url;
|
||||
if DbConnType::from_url(url)? == DbConnType::Sqlite && url.contains('/') {
|
||||
let path = std::path::Path::new(&url);
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.is_dir() {
|
||||
if DbConnType::from_url(url)? == DbConnType::Sqlite {
|
||||
let file_path = url.strip_prefix("sqlite://").unwrap_or(url);
|
||||
if file_path.contains('/') {
|
||||
let path = std::path::Path::new(file_path);
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.is_dir()
|
||||
{
|
||||
err!(format!(
|
||||
"SQLite database directory `{}` does not exist or is not a directory",
|
||||
parent.display()
|
||||
@@ -952,10 +959,10 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
err!(format!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.",));
|
||||
}
|
||||
|
||||
if let Some(log_file) = &cfg.log_file {
|
||||
if std::fs::OpenOptions::new().append(true).create(true).open(log_file).is_err() {
|
||||
err!("Unable to write to log file", log_file);
|
||||
}
|
||||
if let Some(log_file) = &cfg.log_file
|
||||
&& std::fs::OpenOptions::new().append(true).create(true).open(log_file).is_err()
|
||||
{
|
||||
err!("Unable to write to log file", log_file);
|
||||
}
|
||||
|
||||
let dom = cfg.domain.to_lowercase();
|
||||
@@ -968,7 +975,9 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
let connect_src = cfg.allowed_connect_src.to_lowercase();
|
||||
for url in connect_src.split_whitespace() {
|
||||
if !url.starts_with("https://") || Url::parse(url).is_err() {
|
||||
err!("ALLOWED_CONNECT_SRC variable contains one or more invalid URLs. Only FQDN's starting with https are allowed");
|
||||
err!(
|
||||
"ALLOWED_CONNECT_SRC variable contains one or more invalid URLs. Only FQDN's starting with https are allowed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -984,11 +993,12 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
err!("`ORG_CREATION_USERS` contains invalid email addresses");
|
||||
}
|
||||
|
||||
if let Some(ref token) = cfg.admin_token {
|
||||
if token.trim().is_empty() && !cfg.disable_admin_token {
|
||||
println!("[WARNING] `ADMIN_TOKEN` is enabled but has an empty value, so the admin page will be disabled.");
|
||||
println!("[WARNING] To enable the admin page without a token, use `DISABLE_ADMIN_TOKEN`.");
|
||||
}
|
||||
if let Some(ref token) = cfg.admin_token
|
||||
&& token.trim().is_empty()
|
||||
&& !cfg.disable_admin_token
|
||||
{
|
||||
println!("[WARNING] `ADMIN_TOKEN` is enabled but has an empty value, so the admin page will be disabled.");
|
||||
println!("[WARNING] To enable the admin page without a token, use `DISABLE_ADMIN_TOKEN`.");
|
||||
}
|
||||
|
||||
if cfg.push_enabled && (cfg.push_installation_id == String::new() || cfg.push_installation_key == String::new()) {
|
||||
@@ -1022,52 +1032,41 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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] = &[
|
||||
// Autofill Team
|
||||
"inline-menu-positioning-improvements",
|
||||
"inline-menu-totp",
|
||||
"ssh-agent",
|
||||
// Key Management Team
|
||||
"ssh-key-vault-item",
|
||||
// Tools
|
||||
"export-attachments",
|
||||
// Mobile Team
|
||||
"anon-addy-self-host-alias",
|
||||
"simple-login-self-host-alias",
|
||||
"mutual-tls",
|
||||
];
|
||||
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 = parse_experimental_client_feature_flags(
|
||||
&cfg.experimental_client_feature_flags,
|
||||
&FeatureFlagFilter::InvalidOnly,
|
||||
);
|
||||
if !invalid_flags.is_empty() {
|
||||
err!(format!("Unrecognized experimental client feature flags: {invalid_flags:?}.\n\n\
|
||||
let feature_flags_error = format!(
|
||||
"Unrecognized experimental client feature flags: {invalid_flags:?}.\n\
|
||||
Please ensure all feature flags are spelled correctly and that they are supported in this version.\n\
|
||||
Supported flags: {KNOWN_FLAGS:?}"));
|
||||
Supported flags: {SUPPORTED_FEATURE_FLAGS:?}\n"
|
||||
);
|
||||
if on_update {
|
||||
err!(feature_flags_error);
|
||||
}
|
||||
println!("[WARNING] {feature_flags_error}");
|
||||
}
|
||||
|
||||
#[expect(clippy::items_after_statements, reason = "Keep this close to where it is used")]
|
||||
const MAX_FILESIZE_KB: i64 = i64::MAX >> 10;
|
||||
|
||||
if let Some(limit) = cfg.user_attachment_limit {
|
||||
if !(0i64..=MAX_FILESIZE_KB).contains(&limit) {
|
||||
err!("`USER_ATTACHMENT_LIMIT` is out of bounds");
|
||||
}
|
||||
if let Some(limit) = cfg.user_attachment_limit
|
||||
&& !(0i64..=MAX_FILESIZE_KB).contains(&limit)
|
||||
{
|
||||
err!("`USER_ATTACHMENT_LIMIT` is out of bounds");
|
||||
}
|
||||
|
||||
if let Some(limit) = cfg.org_attachment_limit {
|
||||
if !(0i64..=MAX_FILESIZE_KB).contains(&limit) {
|
||||
err!("`ORG_ATTACHMENT_LIMIT` is out of bounds");
|
||||
}
|
||||
if let Some(limit) = cfg.org_attachment_limit
|
||||
&& !(0i64..=MAX_FILESIZE_KB).contains(&limit)
|
||||
{
|
||||
err!("`ORG_ATTACHMENT_LIMIT` is out of bounds");
|
||||
}
|
||||
|
||||
if let Some(limit) = cfg.user_send_limit {
|
||||
if !(0i64..=MAX_FILESIZE_KB).contains(&limit) {
|
||||
err!("`USER_SEND_LIMIT` is out of bounds");
|
||||
}
|
||||
if let Some(limit) = cfg.user_send_limit
|
||||
&& !(0i64..=MAX_FILESIZE_KB).contains(&limit)
|
||||
{
|
||||
err!("`USER_SEND_LIMIT` is out of bounds");
|
||||
}
|
||||
|
||||
if cfg._enable_duo
|
||||
@@ -1084,7 +1083,7 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
|
||||
validate_internal_sso_issuer_url(&cfg.sso_authority)?;
|
||||
validate_internal_sso_redirect_url(&cfg.sso_callback_path)?;
|
||||
validate_sso_master_password_policy(&cfg.sso_master_password_policy)?;
|
||||
validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?;
|
||||
}
|
||||
|
||||
if cfg._enable_yubico {
|
||||
@@ -1095,7 +1094,9 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
if let Some(yubico_server) = &cfg.yubico_server {
|
||||
let yubico_server = yubico_server.to_lowercase();
|
||||
if !yubico_server.starts_with("https://") {
|
||||
err!("`YUBICO_SERVER` must be a valid URL and start with 'https://'. Either unset this variable or provide a valid URL.")
|
||||
err!(
|
||||
"`YUBICO_SERVER` must be a valid URL and start with 'https://'. Either unset this variable or provide a valid URL."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1147,7 +1148,9 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
}
|
||||
|
||||
if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() {
|
||||
err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`")
|
||||
err!(
|
||||
"Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1279,7 +1282,7 @@ fn validate_internal_sso_redirect_url(sso_callback_path: &String) -> Result<open
|
||||
}
|
||||
|
||||
fn validate_sso_master_password_policy(
|
||||
sso_master_password_policy: &Option<String>,
|
||||
sso_master_password_policy: Option<&String>,
|
||||
) -> Result<Option<serde_json::Value>, Error> {
|
||||
let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::<serde_json::Value>(mpp));
|
||||
|
||||
@@ -1308,7 +1311,7 @@ fn extract_url_origin(url: &str) -> String {
|
||||
/// All trailing '/' chars are trimmed, even if the path is a lone '/'.
|
||||
fn extract_url_path(url: &str) -> String {
|
||||
match Url::parse(url) {
|
||||
Ok(u) => u.path().trim_end_matches('/').to_string(),
|
||||
Ok(u) => u.path().trim_end_matches('/').to_owned(),
|
||||
Err(_) => {
|
||||
// We already print it in the method above, no need to do it again
|
||||
String::new()
|
||||
@@ -1318,14 +1321,18 @@ fn extract_url_path(url: &str) -> String {
|
||||
|
||||
fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String {
|
||||
if embed_images {
|
||||
"cid:".to_string()
|
||||
"cid:".to_owned()
|
||||
} else {
|
||||
format!("{domain}/vw_static/")
|
||||
// normalize base_url
|
||||
let base_url = domain.trim_end_matches('/');
|
||||
format!("{base_url}/vw_static/")
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_sso_callback_path(domain: &str) -> String {
|
||||
format!("{domain}/identity/connect/oidc-signin")
|
||||
// normalize base_url
|
||||
let base_url = domain.trim_end_matches('/');
|
||||
format!("{base_url}/identity/connect/oidc-signin")
|
||||
}
|
||||
|
||||
/// Generate the correct URL for the icon service.
|
||||
@@ -1333,10 +1340,10 @@ fn generate_sso_callback_path(domain: &str) -> String {
|
||||
fn generate_icon_service_url(icon_service: &str) -> String {
|
||||
match icon_service {
|
||||
"internal" => String::new(),
|
||||
"bitwarden" => "https://icons.bitwarden.net/{}/icon.png".to_string(),
|
||||
"duckduckgo" => "https://icons.duckduckgo.com/ip3/{}.ico".to_string(),
|
||||
"google" => "https://www.google.com/s2/favicons?domain={}&sz=32".to_string(),
|
||||
_ => icon_service.to_string(),
|
||||
"bitwarden" => "https://icons.bitwarden.net/{}/icon.png".to_owned(),
|
||||
"duckduckgo" => "https://icons.duckduckgo.com/ip3/{}.ico".to_owned(),
|
||||
"google" => "https://www.google.com/s2/favicons?domain={}&sz=32".to_owned(),
|
||||
_ => icon_service.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1345,7 +1352,7 @@ fn generate_icon_service_csp(icon_service: &str, icon_service_url: &str) -> Stri
|
||||
// We split on the first '{', since that is the variable delimiter for an icon service URL.
|
||||
// Everything up until the first '{' should be fixed and can be used as an CSP string.
|
||||
let csp_string = match icon_service_url.split_once('{') {
|
||||
Some((c, _)) => c.to_string(),
|
||||
Some((c, _)) => c.to_owned(),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
@@ -1362,96 +1369,12 @@ fn smtp_convert_deprecated_ssl_options(smtp_ssl: Option<bool>, smtp_explicit_tls
|
||||
println!("[DEPRECATED]: `SMTP_SSL` or `SMTP_EXPLICIT_TLS` is set. Please use `SMTP_SECURITY` instead.");
|
||||
}
|
||||
if smtp_explicit_tls.is_some() && smtp_explicit_tls.unwrap() {
|
||||
return "force_tls".to_string();
|
||||
return "force_tls".to_owned();
|
||||
} else if smtp_ssl.is_some() && !smtp_ssl.unwrap() {
|
||||
return "off".to_string();
|
||||
return "off".to_owned();
|
||||
}
|
||||
// Return the default `starttls` in all other cases
|
||||
"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())
|
||||
"starttls".to_owned()
|
||||
}
|
||||
|
||||
pub enum PathType {
|
||||
@@ -1462,20 +1385,49 @@ pub enum PathType {
|
||||
RsaKey,
|
||||
}
|
||||
|
||||
// Official available feature flags can be found here:
|
||||
// Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135
|
||||
// Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12
|
||||
// Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31
|
||||
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
|
||||
pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
||||
// Architecture
|
||||
"desktop-ui-migration-milestone-1",
|
||||
"desktop-ui-migration-milestone-2",
|
||||
"desktop-ui-migration-milestone-3",
|
||||
"desktop-ui-migration-milestone-4",
|
||||
// Auth Team
|
||||
"pm-5594-safari-account-switching",
|
||||
// Autofill Team
|
||||
"ssh-agent",
|
||||
"ssh-agent-v2",
|
||||
// Key Management Team
|
||||
"ssh-key-vault-item",
|
||||
"pm-25373-windows-biometrics-v2",
|
||||
// Mobile Team
|
||||
"anon-addy-self-host-alias",
|
||||
"simple-login-self-host-alias",
|
||||
"mutual-tls",
|
||||
"cxp-import-mobile",
|
||||
"cxp-export-mobile",
|
||||
// Platform Team
|
||||
"pm-30529-webauthn-related-origins",
|
||||
];
|
||||
|
||||
impl Config {
|
||||
pub async fn load() -> Result<Self, Error> {
|
||||
// Loading from env and file
|
||||
let _env = ConfigBuilder::from_env();
|
||||
let _usr = ConfigBuilder::from_file().await.unwrap_or_default();
|
||||
let env = ConfigBuilder::from_env();
|
||||
let usr = ConfigBuilder::from_file().await.unwrap_or_default();
|
||||
|
||||
// Create merged config, config file overwrites env
|
||||
let mut _overrides = Vec::new();
|
||||
let builder = _env.merge(&_usr, true, &mut _overrides);
|
||||
let mut overrides = Vec::new();
|
||||
let builder = env.merge(&usr, true, &mut overrides);
|
||||
|
||||
// Fill any missing with defaults
|
||||
let config = builder.build();
|
||||
if !SKIP_CONFIG_VALIDATION.load(Ordering::Relaxed) {
|
||||
validate_config(&config)?;
|
||||
validate_config(&config, false)?;
|
||||
}
|
||||
|
||||
Ok(Config {
|
||||
@@ -1483,9 +1435,9 @@ impl Config {
|
||||
rocket_shutdown_handle: None,
|
||||
templates: load_templates(&config.templates_folder),
|
||||
config,
|
||||
_env,
|
||||
_usr,
|
||||
_overrides,
|
||||
_env: env,
|
||||
_usr: usr,
|
||||
_overrides: overrides,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1511,7 +1463,7 @@ impl Config {
|
||||
let env = &self.inner.read().unwrap()._env;
|
||||
env.merge(&builder, false, &mut overrides).build()
|
||||
};
|
||||
validate_config(&config)?;
|
||||
validate_config(&config, true)?;
|
||||
|
||||
// Save both the user and the combined config
|
||||
{
|
||||
@@ -1522,7 +1474,7 @@ impl Config {
|
||||
}
|
||||
|
||||
//Save to file
|
||||
let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
||||
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
||||
operator.write(&CONFIG_FILENAME, config_str).await?;
|
||||
|
||||
Ok(())
|
||||
@@ -1531,8 +1483,8 @@ impl Config {
|
||||
async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> {
|
||||
let builder = {
|
||||
let usr = &self.inner.read().unwrap()._usr;
|
||||
let mut _overrides = Vec::new();
|
||||
usr.merge(&other, false, &mut _overrides)
|
||||
let mut overrides = Vec::new();
|
||||
usr.merge(&other, false, &mut overrides)
|
||||
};
|
||||
self.update_config(builder, false).await
|
||||
}
|
||||
@@ -1555,11 +1507,11 @@ impl Config {
|
||||
/// Tests whether signup is allowed for an email address, taking into
|
||||
/// account the signups_allowed and signups_domains_whitelist settings.
|
||||
pub fn is_signup_allowed(&self, email: &str) -> bool {
|
||||
if !self.signups_domains_whitelist().is_empty() {
|
||||
if self.signups_domains_whitelist().is_empty() {
|
||||
self.signups_allowed()
|
||||
} else {
|
||||
// The whitelist setting overrides the signups_allowed setting.
|
||||
self.is_email_domain_allowed(email)
|
||||
} else {
|
||||
self.signups_allowed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1587,7 +1539,7 @@ impl Config {
|
||||
}
|
||||
|
||||
pub async fn delete_user_config(&self) -> Result<(), Error> {
|
||||
let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
||||
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
||||
operator.delete(&CONFIG_FILENAME).await?;
|
||||
|
||||
// Empty user config
|
||||
@@ -1611,7 +1563,7 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn private_rsa_key(&self) -> String {
|
||||
format!("{}.pem", self.rsa_key_filename())
|
||||
storage::with_extension(&self.rsa_key_filename(), "pem")
|
||||
}
|
||||
pub fn mail_enabled(&self) -> bool {
|
||||
let inner = &self.inner.read().unwrap().config;
|
||||
@@ -1652,15 +1604,11 @@ impl Config {
|
||||
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(),
|
||||
PathType::RsaKey => storage::parent(&self.private_rsa_key())
|
||||
.ok_or_else(|| std::io::Error::other("Failed to get directory of RSA key file"))?,
|
||||
};
|
||||
|
||||
opendal_operator_for_path(&path)
|
||||
storage::operator_for_path(&path)
|
||||
}
|
||||
|
||||
pub fn render_template<T: serde::ser::Serialize>(&self, name: &str, data: &T) -> Result<String, Error> {
|
||||
@@ -1684,10 +1632,10 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
if let Ok(mut c) = self.inner.write() {
|
||||
if let Some(handle) = c.rocket_shutdown_handle.take() {
|
||||
handle.notify();
|
||||
}
|
||||
if let Ok(mut c) = self.inner.write()
|
||||
&& let Some(handle) = c.rocket_shutdown_handle.take()
|
||||
{
|
||||
handle.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1700,11 +1648,11 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn sso_master_password_policy_value(&self) -> Option<serde_json::Value> {
|
||||
validate_sso_master_password_policy(&self.sso_master_password_policy()).ok().flatten()
|
||||
validate_sso_master_password_policy(self.sso_master_password_policy().as_ref()).ok().flatten()
|
||||
}
|
||||
|
||||
pub fn sso_scopes_vec(&self) -> Vec<String> {
|
||||
self.sso_scopes().split_whitespace().map(str::to_string).collect()
|
||||
self.sso_scopes().split_whitespace().map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
pub fn sso_authorize_extra_params_vec(&self) -> Vec<(String, String)> {
|
||||
@@ -1814,7 +1762,7 @@ fn case_helper<'reg, 'rc>(
|
||||
let value = param.value().clone();
|
||||
|
||||
if h.params().iter().skip(1).any(|x| x.value() == &value) {
|
||||
h.template().map(|t| t.render(r, ctx, rc, out)).unwrap_or_else(|| Ok(()))
|
||||
h.template().map_or(Ok(()), |t| t.render(r, ctx, rc, out))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
@@ -1840,7 +1788,7 @@ fn to_json<'reg, 'rc>(
|
||||
// Configure the web-vault version as an integer so it can be used as a comparison smaller or greater then.
|
||||
// The default is based upon the version since this feature is added.
|
||||
static WEB_VAULT_VERSION: LazyLock<semver::Version> = LazyLock::new(|| {
|
||||
let vault_version = get_web_vault_version();
|
||||
let vault_version = get_active_web_release();
|
||||
// Use a single regex capture to extract version components
|
||||
let re = regex::Regex::new(r"(\d{4})\.(\d{1,2})\.(\d{1,2})").unwrap();
|
||||
re.captures(&vault_version)
|
||||
|
||||
+9
-2
@@ -55,13 +55,13 @@ pub fn encode_random_bytes<const N: usize>(e: &Encoding) -> String {
|
||||
/// Generates a random string over a specified alphabet.
|
||||
pub fn get_random_string(alphabet: &[u8], num_chars: usize) -> String {
|
||||
// Ref: https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html
|
||||
use rand::Rng;
|
||||
use rand::RngExt;
|
||||
let mut rng = rand::rng();
|
||||
|
||||
(0..num_chars)
|
||||
.map(|_| {
|
||||
let i = rng.random_range(0..alphabet.len());
|
||||
alphabet[i] as char
|
||||
char::from(alphabet[i])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -113,3 +113,10 @@ pub fn ct_eq<T: AsRef<[u8]>, U: AsRef<[u8]>>(a: T, b: U) -> bool {
|
||||
use subtle::ConstantTimeEq;
|
||||
a.as_ref().ct_eq(b.as_ref()).into()
|
||||
}
|
||||
|
||||
//
|
||||
// SHA256
|
||||
//
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
HEXLOWER.encode(digest::digest(&digest::SHA256, data).as_ref())
|
||||
}
|
||||
|
||||
+85
-32
@@ -6,25 +6,23 @@ use std::{
|
||||
};
|
||||
|
||||
use diesel::{
|
||||
Connection, RunQueryDsl,
|
||||
connection::SimpleConnection,
|
||||
r2d2::{CustomizeConnection, Pool, PooledConnection},
|
||||
Connection, RunQueryDsl,
|
||||
};
|
||||
|
||||
use rocket::{
|
||||
Request,
|
||||
http::Status,
|
||||
request::{FromRequest, Outcome},
|
||||
Request,
|
||||
};
|
||||
|
||||
use tokio::{
|
||||
sync::{Mutex, OwnedSemaphorePermit, Semaphore},
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{Error, MapResult},
|
||||
CONFIG,
|
||||
error::{Error, MapResult},
|
||||
};
|
||||
|
||||
// These changes are based on Rocket 0.5-rc wrapper of Diesel: https://github.com/SergioBenitez/Rocket/blob/v0.5-rc/contrib/sync_db_pools
|
||||
@@ -62,7 +60,7 @@ pub struct DbConnManager {
|
||||
impl DbConnManager {
|
||||
pub fn new(database_url: &str) -> Self {
|
||||
Self {
|
||||
database_url: database_url.to_string(),
|
||||
database_url: database_url.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +222,7 @@ impl DbPool {
|
||||
|
||||
// Set a global to determine the database more easily throughout the rest of the code
|
||||
if ACTIVE_DB_TYPE.set(conn_type).is_err() {
|
||||
error!("Tried to set the active database connection type more than once.")
|
||||
error!("Tried to set the active database connection type more than once.");
|
||||
}
|
||||
|
||||
Ok(DbPool {
|
||||
@@ -272,22 +270,40 @@ impl DbConnType {
|
||||
#[cfg(not(postgresql))]
|
||||
err!("`DATABASE_URL` is a PostgreSQL URL, but the 'postgresql' feature is not enabled")
|
||||
|
||||
//Sqlite
|
||||
} else {
|
||||
// Sqlite (explicit)
|
||||
} else if url.len() > 7 && &url[..7] == "sqlite:" {
|
||||
#[cfg(sqlite)]
|
||||
return Ok(DbConnType::Sqlite);
|
||||
|
||||
#[cfg(not(sqlite))]
|
||||
err!("`DATABASE_URL` looks like a SQLite URL, but 'sqlite' feature is not enabled")
|
||||
err!("`DATABASE_URL` is a SQLite URL, but the 'sqlite' feature is not enabled")
|
||||
}
|
||||
|
||||
// No recognized scheme — assume legacy bare-path SQLite, but the database file must already exist.
|
||||
// This prevents misconfigured URLs (typos, quoted strings) from silently creating a new empty SQLite database.
|
||||
#[cfg(sqlite)]
|
||||
{
|
||||
if std::path::Path::new(url).exists() {
|
||||
return Ok(DbConnType::Sqlite);
|
||||
}
|
||||
err!(format!(
|
||||
"`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://) \
|
||||
and no existing SQLite database was found at '{url}'. \
|
||||
If you intend to use SQLite, use an explicit `sqlite://` scheme in your `DATABASE_URL`. \
|
||||
Otherwise, check your DATABASE_URL for typos or quoting issues."
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(sqlite))]
|
||||
err!("`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://)")
|
||||
}
|
||||
|
||||
pub fn get_init_stmts(&self) -> String {
|
||||
let init_stmts = CONFIG.database_conn_init();
|
||||
if !init_stmts.is_empty() {
|
||||
init_stmts
|
||||
} else {
|
||||
if init_stmts.is_empty() {
|
||||
self.default_init_stmts()
|
||||
} else {
|
||||
init_stmts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +314,7 @@ impl DbConnType {
|
||||
#[cfg(postgresql)]
|
||||
Self::Postgresql => String::new(),
|
||||
#[cfg(sqlite)]
|
||||
Self::Sqlite => "PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;".to_string(),
|
||||
Self::Sqlite => "PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,6 +353,46 @@ macro_rules! db_run {
|
||||
};
|
||||
}
|
||||
|
||||
// Write all ToSql<Text, DB> and FromSql<Text, DB> given a serializable/deserializable type.
|
||||
#[macro_export]
|
||||
macro_rules! impl_FromToSqlText {
|
||||
($name:ty) => {
|
||||
#[cfg(mysql)]
|
||||
impl ToSql<Text, diesel::mysql::Mysql> for $name {
|
||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::mysql::Mysql>) -> diesel::serialize::Result {
|
||||
serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(postgresql)]
|
||||
impl ToSql<Text, diesel::pg::Pg> for $name {
|
||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::pg::Pg>) -> diesel::serialize::Result {
|
||||
serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(sqlite)]
|
||||
impl ToSql<Text, diesel::sqlite::Sqlite> for $name {
|
||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::sqlite::Sqlite>) -> diesel::serialize::Result {
|
||||
serde_json::to_string(self).map_err(Into::into).map(|str| {
|
||||
out.set_value(str);
|
||||
diesel::serialize::IsNull::No
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<DB: diesel::backend::Backend> FromSql<Text, DB> for $name
|
||||
where
|
||||
String: FromSql<Text, DB>,
|
||||
{
|
||||
fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result<Self> {
|
||||
<String as FromSql<Text, DB>>::from_sql(bytes)
|
||||
.and_then(|str| serde_json::from_str(&str).map_err(Into::into))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub mod schema;
|
||||
|
||||
// Reexport the models, needs to be after the macros are defined so it can access them
|
||||
@@ -347,30 +403,27 @@ pub mod models;
|
||||
#[cfg(sqlite)]
|
||||
pub fn backup_sqlite() -> Result<String, Error> {
|
||||
use diesel::Connection;
|
||||
use std::{fs::File, io::Write};
|
||||
|
||||
let db_url = CONFIG.database_url();
|
||||
if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::Sqlite).unwrap_or(false) {
|
||||
// Since we do not allow any schema for sqlite database_url's like `file:` or `sqlite:` to be set, we can assume here it isn't
|
||||
// This way we can set a readonly flag on the opening mode without issues.
|
||||
let mut conn = diesel::sqlite::SqliteConnection::establish(&format!("sqlite://{db_url}?mode=ro"))?;
|
||||
if DbConnType::from_url(&CONFIG.database_url()).is_ok_and(|t| t == DbConnType::Sqlite) {
|
||||
// Strip the sqlite:// prefix if present to get the raw file path
|
||||
let file_path = db_url.strip_prefix("sqlite://").unwrap_or(&db_url);
|
||||
// Open a read-only connection for the backup
|
||||
let mut conn = diesel::sqlite::SqliteConnection::establish(&format!("sqlite://{file_path}?mode=ro"))?;
|
||||
|
||||
let db_path = std::path::Path::new(&db_url).parent().unwrap();
|
||||
let db_path = std::path::Path::new(file_path).parent().unwrap();
|
||||
let backup_file = db_path
|
||||
.join(format!("db_{}.sqlite3", chrono::Utc::now().format("%Y%m%d_%H%M%S")))
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
match File::create(backup_file.clone()) {
|
||||
Ok(mut f) => {
|
||||
let serialized_db = conn.serialize_database_to_buffer();
|
||||
f.write_all(serialized_db.as_slice()).expect("Error writing SQLite backup");
|
||||
Ok(backup_file)
|
||||
}
|
||||
Err(e) => {
|
||||
err_silent!(format!("Unable to save SQLite backup: {e:?}"))
|
||||
}
|
||||
}
|
||||
diesel::sql_query("VACUUM INTO ?")
|
||||
.bind::<diesel::sql_types::Text, _>(&backup_file)
|
||||
.execute(&mut conn)
|
||||
.map(|_| ())
|
||||
.map_res("VACUUM INTO failed")?;
|
||||
|
||||
Ok(backup_file)
|
||||
} else {
|
||||
err_silent!("The database type is not SQLite. Backups only works for SQLite databases")
|
||||
}
|
||||
@@ -387,12 +440,12 @@ pub async fn get_sql_server_version(conn: &DbConn) -> String {
|
||||
postgresql,mysql {
|
||||
diesel::select(diesel::dsl::sql::<diesel::sql_types::Text>("version();"))
|
||||
.get_result::<String>(conn)
|
||||
.unwrap_or_else(|_| "Unknown".to_string())
|
||||
.unwrap_or_else(|_| "Unknown".to_owned())
|
||||
}
|
||||
sqlite {
|
||||
diesel::select(diesel::dsl::sql::<diesel::sql_types::Text>("sqlite_version();"))
|
||||
.get_result::<String>(conn)
|
||||
.unwrap_or_else(|_| "Unknown".to_string())
|
||||
.unwrap_or_else(|_| "Unknown".to_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::{
|
||||
api::EmptyResult,
|
||||
db::{DbConn, schema::archives},
|
||||
error::MapResult,
|
||||
};
|
||||
|
||||
use super::{CipherId, User, UserId};
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable)]
|
||||
#[diesel(table_name = archives)]
|
||||
#[diesel(primary_key(user_uuid, cipher_uuid))]
|
||||
pub struct Archive {
|
||||
pub user_uuid: UserId,
|
||||
pub cipher_uuid: CipherId,
|
||||
pub archived_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl Archive {
|
||||
// Returns the date the specified cipher was archived
|
||||
pub async fn get_archived_at(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> Option<NaiveDateTime> {
|
||||
conn.run(move |conn| {
|
||||
archives::table
|
||||
.filter(archives::cipher_uuid.eq(cipher_uuid))
|
||||
.filter(archives::user_uuid.eq(user_uuid))
|
||||
.select(archives::archived_at)
|
||||
.first::<NaiveDateTime>(conn)
|
||||
.ok()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// Saves (inserts or updates) an archive record with the provided timestamp
|
||||
pub async fn save(
|
||||
user_uuid: &UserId,
|
||||
cipher_uuid: &CipherId,
|
||||
archived_at: NaiveDateTime,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
User::update_uuid_revision(user_uuid, conn).await;
|
||||
db_run! { conn:
|
||||
sqlite, mysql {
|
||||
diesel::replace_into(archives::table)
|
||||
.values((
|
||||
archives::user_uuid.eq(user_uuid),
|
||||
archives::cipher_uuid.eq(cipher_uuid),
|
||||
archives::archived_at.eq(archived_at),
|
||||
))
|
||||
.execute(conn)
|
||||
.map_res("Error saving archive")
|
||||
}
|
||||
postgresql {
|
||||
diesel::insert_into(archives::table)
|
||||
.values((
|
||||
archives::user_uuid.eq(user_uuid),
|
||||
archives::cipher_uuid.eq(cipher_uuid),
|
||||
archives::archived_at.eq(archived_at),
|
||||
))
|
||||
.on_conflict((archives::user_uuid, archives::cipher_uuid))
|
||||
.do_update()
|
||||
.set(archives::archived_at.eq(archived_at))
|
||||
.execute(conn)
|
||||
.map_res("Error saving archive")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deletes an archive record for a specific cipher
|
||||
pub async fn delete_by_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult {
|
||||
User::update_uuid_revision(user_uuid, conn).await;
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(
|
||||
archives::table.filter(archives::user_uuid.eq(user_uuid)).filter(archives::cipher_uuid.eq(cipher_uuid)),
|
||||
)
|
||||
.execute(conn)
|
||||
.map_res("Error deleting archive")
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Return a vec with (cipher_uuid, archived_at)
|
||||
/// This is used during a full sync so we only need one query for all archive matches
|
||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, NaiveDateTime)> {
|
||||
conn.run(move |conn| {
|
||||
archives::table
|
||||
.filter(archives::user_uuid.eq(user_uuid))
|
||||
.select((archives::cipher_uuid, archives::archived_at))
|
||||
.load::<(CipherId, NaiveDateTime)>(conn)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
+44
-37
@@ -1,13 +1,24 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use bigdecimal::{BigDecimal, ToPrimitive};
|
||||
use derive_more::{AsRef, Deref, Display};
|
||||
use diesel::prelude::*;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::EmptyResult,
|
||||
auth::{encode_jwt, generate_file_download_claims},
|
||||
config::PathType,
|
||||
db::{
|
||||
DbConn,
|
||||
schema::{attachments, ciphers},
|
||||
},
|
||||
error::MapResult,
|
||||
};
|
||||
use macros::IdFromParam;
|
||||
|
||||
use super::{CipherId, OrganizationId, UserId};
|
||||
use crate::db::schema::{attachments, ciphers};
|
||||
use crate::{config::PathType, CONFIG};
|
||||
use macros::IdFromParam;
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[diesel(table_name = attachments)]
|
||||
@@ -46,11 +57,11 @@ impl Attachment {
|
||||
pub async fn get_url(&self, host: &str) -> Result<String, crate::Error> {
|
||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?;
|
||||
|
||||
if operator.info().scheme() == opendal::Scheme::Fs {
|
||||
if crate::storage::is_fs_operator(&operator) {
|
||||
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())
|
||||
Ok(operator.presign_read(&self.get_file_path(), Duration::from_mins(5)).await?.uri().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,12 +78,6 @@ impl Attachment {
|
||||
}
|
||||
}
|
||||
|
||||
use crate::auth::{encode_jwt, generate_file_download_claims};
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
use crate::error::MapResult;
|
||||
|
||||
/// Database methods
|
||||
impl Attachment {
|
||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||
@@ -107,15 +112,15 @@ impl Attachment {
|
||||
}
|
||||
|
||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
crate::util::retry(||
|
||||
diesel::delete(attachments::table.filter(attachments::id.eq(&self.id)))
|
||||
.execute(conn),
|
||||
conn.run(move |conn| {
|
||||
crate::util::retry(
|
||||
|| diesel::delete(attachments::table.filter(attachments::id.eq(&self.id))).execute(conn),
|
||||
10,
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_res("Error deleting attachment")
|
||||
}}?;
|
||||
})
|
||||
.await?;
|
||||
|
||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?;
|
||||
let file_path = self.get_file_path();
|
||||
@@ -139,25 +144,22 @@ impl Attachment {
|
||||
}
|
||||
|
||||
pub async fn find_by_id(id: &AttachmentId, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
attachments::table
|
||||
.filter(attachments::id.eq(id.to_lowercase()))
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
conn.run(move |conn| attachments::table.filter(attachments::id.eq(id.to_lowercase())).first::<Self>(conn).ok())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
attachments::table
|
||||
.filter(attachments::cipher_uuid.eq(cipher_uuid))
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading attachments")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
let result: Option<BigDecimal> = attachments::table
|
||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||
.filter(ciphers::user_uuid.eq(user_uuid))
|
||||
@@ -168,24 +170,26 @@ impl Attachment {
|
||||
match result.map(|r| r.to_i64()) {
|
||||
Some(Some(r)) => r,
|
||||
Some(None) => i64::MAX,
|
||||
None => 0
|
||||
None => 0,
|
||||
}
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn count_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
attachments::table
|
||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||
.filter(ciphers::user_uuid.eq(user_uuid))
|
||||
.count()
|
||||
.first(conn)
|
||||
.unwrap_or(0)
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn size_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
let result: Option<BigDecimal> = attachments::table
|
||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||
.filter(ciphers::organization_uuid.eq(org_uuid))
|
||||
@@ -196,20 +200,22 @@ impl Attachment {
|
||||
match result.map(|r| r.to_i64()) {
|
||||
Some(Some(r)) => r,
|
||||
Some(None) => i64::MAX,
|
||||
None => 0
|
||||
None => 0,
|
||||
}
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
attachments::table
|
||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||
.filter(ciphers::organization_uuid.eq(org_uuid))
|
||||
.count()
|
||||
.first(conn)
|
||||
.unwrap_or(0)
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// This will return all attachments linked to the user or org
|
||||
@@ -220,7 +226,7 @@ impl Attachment {
|
||||
org_uuids: &Vec<OrganizationId>,
|
||||
conn: &DbConn,
|
||||
) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
attachments::table
|
||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||
.filter(ciphers::user_uuid.eq(user_uuid))
|
||||
@@ -228,7 +234,8 @@ impl Attachment {
|
||||
.select(attachments::all_columns)
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading attachments")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
use super::{DeviceId, OrganizationId, UserId};
|
||||
use crate::db::schema::auth_requests;
|
||||
use crate::{crypto::ct_eq, util::format_date};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use diesel::prelude::*;
|
||||
use macros::UuidFromParam;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
api::EmptyResult,
|
||||
crypto::ct_eq,
|
||||
db::{DbConn, schema::auth_requests},
|
||||
error::MapResult,
|
||||
util::format_date,
|
||||
};
|
||||
use macros::UuidFromParam;
|
||||
|
||||
use super::{DeviceId, OrganizationId, UserId};
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)]
|
||||
#[diesel(table_name = auth_requests)]
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
@@ -74,11 +81,6 @@ impl AuthRequest {
|
||||
}
|
||||
}
|
||||
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
use crate::error::MapResult;
|
||||
|
||||
impl AuthRequest {
|
||||
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn:
|
||||
@@ -112,31 +114,28 @@ impl AuthRequest {
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &AuthRequestId, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
auth_requests::table
|
||||
.filter(auth_requests::uuid.eq(uuid))
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
conn.run(move |conn| auth_requests::table.filter(auth_requests::uuid.eq(uuid)).first::<Self>(conn).ok()).await
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_user(uuid: &AuthRequestId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
auth_requests::table
|
||||
.filter(auth_requests::uuid.eq(uuid))
|
||||
.filter(auth_requests::user_uuid.eq(user_uuid))
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
auth_requests::table
|
||||
.filter(auth_requests::user_uuid.eq(user_uuid))
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading auth_requests")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_user_and_requested_device(
|
||||
@@ -144,7 +143,7 @@ impl AuthRequest {
|
||||
device_uuid: &DeviceId,
|
||||
conn: &DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
auth_requests::table
|
||||
.filter(auth_requests::user_uuid.eq(user_uuid))
|
||||
.filter(auth_requests::request_device_identifier.eq(device_uuid))
|
||||
@@ -152,24 +151,27 @@ impl AuthRequest {
|
||||
.order_by(auth_requests::creation_date.desc())
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_created_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
auth_requests::table
|
||||
.filter(auth_requests::creation_date.lt(dt))
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading auth_requests")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(auth_requests::table.filter(auth_requests::uuid.eq(&self.uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error deleting auth request")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn check_access_code(&self, access_code: &str) -> bool {
|
||||
@@ -177,7 +179,9 @@ impl AuthRequest {
|
||||
}
|
||||
|
||||
pub async fn purge_expired_auth_requests(conn: &DbConn) {
|
||||
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(5).unwrap(); //after 5 minutes, clients reject the request
|
||||
// delete auth requests older than 15 minutes which is functionally equivalent to upstream:
|
||||
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql
|
||||
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap();
|
||||
for auth_request in Self::find_created_before(&expiry_time, conn).await {
|
||||
auth_request.delete(conn).await.ok();
|
||||
}
|
||||
|
||||
+365
-333
File diff suppressed because it is too large
Load Diff
+414
-316
@@ -1,16 +1,25 @@
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use diesel::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::EmptyResult,
|
||||
db::{
|
||||
DbConn,
|
||||
schema::{
|
||||
ciphers_collections, collections, collections_groups, groups, groups_users, users_collections,
|
||||
users_organizations,
|
||||
},
|
||||
},
|
||||
error::MapResult,
|
||||
};
|
||||
use macros::UuidFromParam;
|
||||
|
||||
use super::{
|
||||
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
|
||||
User, UserId,
|
||||
};
|
||||
use crate::db::schema::{
|
||||
ciphers_collections, collections, collections_groups, groups, groups_users, users_collections, users_organizations,
|
||||
};
|
||||
use crate::CONFIG;
|
||||
use diesel::prelude::*;
|
||||
use macros::UuidFromParam;
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[diesel(table_name = collections)]
|
||||
@@ -74,7 +83,7 @@ impl Collection {
|
||||
if external_id.is_empty() {
|
||||
self.external_id = None;
|
||||
} else {
|
||||
self.external_id = Some(external_id)
|
||||
self.external_id = Some(external_id);
|
||||
}
|
||||
}
|
||||
None => self.external_id = None,
|
||||
@@ -147,11 +156,6 @@ impl Collection {
|
||||
}
|
||||
}
|
||||
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
use crate::error::MapResult;
|
||||
|
||||
/// Database methods
|
||||
impl Collection {
|
||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||
@@ -191,13 +195,14 @@ impl Collection {
|
||||
self.update_users_revision(conn).await;
|
||||
CollectionCipher::delete_all_by_collection(&self.uuid, conn).await?;
|
||||
CollectionUser::delete_all_by_collection(&self.uuid, conn).await?;
|
||||
CollectionGroup::delete_all_by_collection(&self.uuid, conn).await?;
|
||||
CollectionGroup::delete_all_by_collection(&self.uuid, &self.org_uuid, conn).await?;
|
||||
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error deleting collection")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||
@@ -208,90 +213,90 @@ impl Collection {
|
||||
}
|
||||
|
||||
pub async fn update_users_revision(&self, conn: &DbConn) {
|
||||
for member in Membership::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn).await.iter() {
|
||||
for member in &Membership::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn).await {
|
||||
User::update_uuid_revision(&member.user_uuid, conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &CollectionId, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
collections::table
|
||||
.filter(collections::uuid.eq(uuid))
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
conn.run(move |conn| collections::table.filter(collections::uuid.eq(uuid)).first::<Self>(conn).ok()).await
|
||||
}
|
||||
|
||||
pub async fn find_by_user_uuid(user_uuid: UserId, conn: &DbConn) -> Vec<Self> {
|
||||
if CONFIG.org_groups_enabled() {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
collections::table
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(collections::uuid).and(
|
||||
users_collections::user_uuid.eq(user_uuid.clone())
|
||||
.left_join(
|
||||
users_collections::table.on(users_collections::collection_uuid
|
||||
.eq(collections::uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
|
||||
)
|
||||
))
|
||||
.left_join(users_organizations::table.on(
|
||||
collections::org_uuid.eq(users_organizations::org_uuid).and(
|
||||
users_organizations::user_uuid.eq(user_uuid.clone())
|
||||
.left_join(
|
||||
users_organizations::table.on(collections::org_uuid
|
||||
.eq(users_organizations::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_uuid.clone()))),
|
||||
)
|
||||
))
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
||||
collections_groups::collections_uuid.eq(collections::uuid)
|
||||
.left_join(
|
||||
groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)),
|
||||
)
|
||||
))
|
||||
.filter(
|
||||
users_organizations::status.eq(MembershipStatus::Confirmed as i32)
|
||||
)
|
||||
.filter(
|
||||
users_collections::user_uuid.eq(user_uuid).or( // Directly accessed collection
|
||||
users_organizations::access_all.eq(true) // access_all in Organization
|
||||
).or(
|
||||
groups::access_all.eq(true) // access_all in groups
|
||||
).or( // access via groups
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid).and(
|
||||
collections_groups::collections_uuid.is_not_null()
|
||||
)
|
||||
.left_join(
|
||||
groups::table.on(groups::uuid
|
||||
.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
|
||||
)
|
||||
)
|
||||
.select(collections::all_columns)
|
||||
.distinct()
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading collections")
|
||||
}}
|
||||
.left_join(
|
||||
collections_groups::table.on(collections_groups::groups_uuid
|
||||
.eq(groups_users::groups_uuid)
|
||||
.and(collections_groups::collections_uuid.eq(collections::uuid))),
|
||||
)
|
||||
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||
.filter(
|
||||
users_collections::user_uuid
|
||||
.eq(user_uuid)
|
||||
.or(
|
||||
// Directly accessed collection
|
||||
users_organizations::access_all.eq(true), // access_all in Organization
|
||||
)
|
||||
.or(
|
||||
groups::access_all.eq(true), // access_all in groups
|
||||
)
|
||||
.or(
|
||||
// access via groups
|
||||
groups_users::users_organizations_uuid
|
||||
.eq(users_organizations::uuid)
|
||||
.and(collections_groups::collections_uuid.is_not_null()),
|
||||
),
|
||||
)
|
||||
.select(collections::all_columns)
|
||||
.distinct()
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading collections")
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
collections::table
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(collections::uuid).and(
|
||||
users_collections::user_uuid.eq(user_uuid.clone())
|
||||
.left_join(
|
||||
users_collections::table.on(users_collections::collection_uuid
|
||||
.eq(collections::uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
|
||||
)
|
||||
))
|
||||
.left_join(users_organizations::table.on(
|
||||
collections::org_uuid.eq(users_organizations::org_uuid).and(
|
||||
users_organizations::user_uuid.eq(user_uuid.clone())
|
||||
.left_join(
|
||||
users_organizations::table.on(collections::org_uuid
|
||||
.eq(users_organizations::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_uuid.clone()))),
|
||||
)
|
||||
))
|
||||
.filter(
|
||||
users_organizations::status.eq(MembershipStatus::Confirmed as i32)
|
||||
)
|
||||
.filter(
|
||||
users_collections::user_uuid.eq(user_uuid).or( // Directly accessed collection
|
||||
users_organizations::access_all.eq(true) // access_all in Organization
|
||||
)
|
||||
)
|
||||
.select(collections::all_columns)
|
||||
.distinct()
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading collections")
|
||||
}}
|
||||
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||
.filter(users_collections::user_uuid.eq(user_uuid).or(
|
||||
// Directly accessed collection
|
||||
users_organizations::access_all.eq(true), // access_all in Organization
|
||||
))
|
||||
.select(collections::all_columns)
|
||||
.distinct()
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading collections")
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,255 +313,315 @@ impl Collection {
|
||||
}
|
||||
|
||||
pub async fn find_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
collections::table
|
||||
.filter(collections::org_uuid.eq(org_uuid))
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading collections")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
||||
db_run! { conn: {
|
||||
collections::table
|
||||
.filter(collections::org_uuid.eq(org_uuid))
|
||||
.count()
|
||||
.first::<i64>(conn)
|
||||
.ok()
|
||||
.unwrap_or(0)
|
||||
}}
|
||||
conn.run(move |conn| {
|
||||
collections::table.filter(collections::org_uuid.eq(org_uuid)).count().first::<i64>(conn).ok().unwrap_or(0)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_org(uuid: &CollectionId, org_uuid: &OrganizationId, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
collections::table
|
||||
.filter(collections::uuid.eq(uuid))
|
||||
.filter(collections::org_uuid.eq(org_uuid))
|
||||
.select(collections::all_columns)
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_user(uuid: &CollectionId, user_uuid: UserId, conn: &DbConn) -> Option<Self> {
|
||||
if CONFIG.org_groups_enabled() {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
collections::table
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(collections::uuid).and(
|
||||
users_collections::user_uuid.eq(user_uuid.clone())
|
||||
.left_join(
|
||||
users_collections::table.on(users_collections::collection_uuid
|
||||
.eq(collections::uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
|
||||
)
|
||||
))
|
||||
.left_join(users_organizations::table.on(
|
||||
collections::org_uuid.eq(users_organizations::org_uuid).and(
|
||||
users_organizations::user_uuid.eq(user_uuid)
|
||||
.left_join(
|
||||
users_organizations::table.on(collections::org_uuid
|
||||
.eq(users_organizations::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_uuid))),
|
||||
)
|
||||
))
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
||||
collections_groups::collections_uuid.eq(collections::uuid)
|
||||
.left_join(
|
||||
groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)),
|
||||
)
|
||||
))
|
||||
.filter(collections::uuid.eq(uuid))
|
||||
.filter(
|
||||
users_collections::collection_uuid.eq(uuid).or( // Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or( // access_all in Organization
|
||||
users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
)).or(
|
||||
groups::access_all.eq(true) // access_all in groups
|
||||
).or( // access via groups
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid).and(
|
||||
collections_groups::collections_uuid.is_not_null()
|
||||
)
|
||||
.left_join(
|
||||
groups::table.on(groups::uuid
|
||||
.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
|
||||
)
|
||||
).select(collections::all_columns)
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
.left_join(
|
||||
collections_groups::table.on(collections_groups::groups_uuid
|
||||
.eq(groups_users::groups_uuid)
|
||||
.and(collections_groups::collections_uuid.eq(collections::uuid))),
|
||||
)
|
||||
.filter(collections::uuid.eq(uuid))
|
||||
.filter(
|
||||
users_collections::collection_uuid
|
||||
.eq(uuid)
|
||||
.or(
|
||||
// Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or(
|
||||
// access_all in Organization
|
||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
||||
),
|
||||
)
|
||||
.or(
|
||||
groups::access_all.eq(true), // access_all in groups
|
||||
)
|
||||
.or(
|
||||
// access via groups
|
||||
groups_users::users_organizations_uuid
|
||||
.eq(users_organizations::uuid)
|
||||
.and(collections_groups::collections_uuid.is_not_null()),
|
||||
),
|
||||
)
|
||||
.select(collections::all_columns)
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
collections::table
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(collections::uuid).and(
|
||||
users_collections::user_uuid.eq(user_uuid.clone())
|
||||
.left_join(
|
||||
users_collections::table.on(users_collections::collection_uuid
|
||||
.eq(collections::uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
|
||||
)
|
||||
))
|
||||
.left_join(users_organizations::table.on(
|
||||
collections::org_uuid.eq(users_organizations::org_uuid).and(
|
||||
users_organizations::user_uuid.eq(user_uuid)
|
||||
.left_join(
|
||||
users_organizations::table.on(collections::org_uuid
|
||||
.eq(users_organizations::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_uuid))),
|
||||
)
|
||||
))
|
||||
.filter(collections::uuid.eq(uuid))
|
||||
.filter(
|
||||
users_collections::collection_uuid.eq(uuid).or( // Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or( // access_all in Organization
|
||||
users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
.filter(collections::uuid.eq(uuid))
|
||||
.filter(users_collections::collection_uuid.eq(uuid).or(
|
||||
// Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or(
|
||||
// access_all in Organization
|
||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
||||
),
|
||||
))
|
||||
).select(collections::all_columns)
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
.select(collections::all_columns)
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_writable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||
let user_uuid = user_uuid.to_string();
|
||||
if CONFIG.org_groups_enabled() {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
collections::table
|
||||
.filter(collections::uuid.eq(&self.uuid))
|
||||
.inner_join(users_organizations::table.on(
|
||||
collections::org_uuid.eq(users_organizations::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
|
||||
))
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(collections::uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid))
|
||||
))
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
|
||||
.and(collections_groups::collections_uuid.eq(collections::uuid))
|
||||
))
|
||||
.filter(users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
||||
.or(users_collections::collection_uuid.eq(&self.uuid) // write access given to collection
|
||||
.and(users_collections::read_only.eq(false)))
|
||||
.or(groups::access_all.eq(true)) // access_all via group
|
||||
.or(collections_groups::collections_uuid.is_not_null() // write access given via group
|
||||
.and(collections_groups::read_only.eq(false)))
|
||||
.inner_join(
|
||||
users_organizations::table.on(collections::org_uuid
|
||||
.eq(users_organizations::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_uuid.clone()))),
|
||||
)
|
||||
.left_join(
|
||||
users_collections::table.on(users_collections::collection_uuid
|
||||
.eq(collections::uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid))),
|
||||
)
|
||||
.left_join(
|
||||
groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)),
|
||||
)
|
||||
.left_join(
|
||||
groups::table.on(groups::uuid
|
||||
.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
|
||||
)
|
||||
.left_join(
|
||||
collections_groups::table.on(collections_groups::groups_uuid
|
||||
.eq(groups_users::groups_uuid)
|
||||
.and(collections_groups::collections_uuid.eq(collections::uuid))),
|
||||
)
|
||||
.filter(
|
||||
users_organizations::atype
|
||||
.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
||||
.or(users_collections::collection_uuid
|
||||
.eq(&self.uuid) // write access given to collection
|
||||
.and(users_collections::read_only.eq(false)))
|
||||
.or(groups::access_all.eq(true)) // access_all via group
|
||||
.or(collections_groups::collections_uuid
|
||||
.is_not_null() // write access given via group
|
||||
.and(collections_groups::read_only.eq(false))),
|
||||
)
|
||||
.count()
|
||||
.first::<i64>(conn)
|
||||
.ok()
|
||||
.unwrap_or(0) != 0
|
||||
}}
|
||||
.unwrap_or(0)
|
||||
!= 0
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
collections::table
|
||||
.filter(collections::uuid.eq(&self.uuid))
|
||||
.inner_join(users_organizations::table.on(
|
||||
collections::org_uuid.eq(users_organizations::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
|
||||
))
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(collections::uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid))
|
||||
))
|
||||
.filter(users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
||||
.or(users_collections::collection_uuid.eq(&self.uuid) // write access given to collection
|
||||
.and(users_collections::read_only.eq(false)))
|
||||
.inner_join(
|
||||
users_organizations::table.on(collections::org_uuid
|
||||
.eq(users_organizations::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_uuid.clone()))),
|
||||
)
|
||||
.left_join(
|
||||
users_collections::table.on(users_collections::collection_uuid
|
||||
.eq(collections::uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid))),
|
||||
)
|
||||
.filter(
|
||||
users_organizations::atype
|
||||
.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
||||
.or(users_collections::collection_uuid
|
||||
.eq(&self.uuid) // write access given to collection
|
||||
.and(users_collections::read_only.eq(false))),
|
||||
)
|
||||
.count()
|
||||
.first::<i64>(conn)
|
||||
.ok()
|
||||
.unwrap_or(0) != 0
|
||||
}}
|
||||
.unwrap_or(0)
|
||||
!= 0
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn hide_passwords_for_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||
let user_uuid = user_uuid.to_string();
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
collections::table
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(collections::uuid).and(
|
||||
users_collections::user_uuid.eq(user_uuid.clone())
|
||||
.left_join(
|
||||
users_collections::table.on(users_collections::collection_uuid
|
||||
.eq(collections::uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
|
||||
)
|
||||
))
|
||||
.left_join(users_organizations::table.on(
|
||||
collections::org_uuid.eq(users_organizations::org_uuid).and(
|
||||
users_organizations::user_uuid.eq(user_uuid)
|
||||
.left_join(
|
||||
users_organizations::table.on(collections::org_uuid
|
||||
.eq(users_organizations::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_uuid))),
|
||||
)
|
||||
))
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
||||
collections_groups::collections_uuid.eq(collections::uuid)
|
||||
.left_join(groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)))
|
||||
.left_join(
|
||||
groups::table.on(groups::uuid
|
||||
.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
|
||||
)
|
||||
))
|
||||
.filter(collections::uuid.eq(&self.uuid))
|
||||
.filter(
|
||||
users_collections::collection_uuid.eq(&self.uuid).and(users_collections::hide_passwords.eq(true)).or(// Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or( // access_all in Organization
|
||||
users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
)).or(
|
||||
groups::access_all.eq(true) // access_all in groups
|
||||
).or( // access via groups
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid).and(
|
||||
collections_groups::collections_uuid.is_not_null().and(
|
||||
collections_groups::hide_passwords.eq(true))
|
||||
)
|
||||
.left_join(
|
||||
collections_groups::table.on(collections_groups::groups_uuid
|
||||
.eq(groups_users::groups_uuid)
|
||||
.and(collections_groups::collections_uuid.eq(collections::uuid))),
|
||||
)
|
||||
)
|
||||
.count()
|
||||
.first::<i64>(conn)
|
||||
.ok()
|
||||
.unwrap_or(0) != 0
|
||||
}}
|
||||
.filter(collections::uuid.eq(&self.uuid))
|
||||
.filter(
|
||||
users_collections::collection_uuid
|
||||
.eq(&self.uuid)
|
||||
.and(users_collections::hide_passwords.eq(true))
|
||||
.or(
|
||||
// Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or(
|
||||
// access_all in Organization
|
||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
||||
),
|
||||
)
|
||||
.or(
|
||||
groups::access_all.eq(true), // access_all in groups
|
||||
)
|
||||
.or(
|
||||
// access via groups
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid).and(
|
||||
collections_groups::collections_uuid
|
||||
.is_not_null()
|
||||
.and(collections_groups::hide_passwords.eq(true)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.count()
|
||||
.first::<i64>(conn)
|
||||
.ok()
|
||||
.unwrap_or(0)
|
||||
!= 0
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn is_coll_manageable_by_user(uuid: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||
let uuid = uuid.to_string();
|
||||
let user_uuid = user_uuid.to_string();
|
||||
conn.run(move |conn| {
|
||||
collections::table
|
||||
.left_join(
|
||||
users_collections::table.on(users_collections::collection_uuid
|
||||
.eq(collections::uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
|
||||
)
|
||||
.left_join(
|
||||
users_organizations::table.on(collections::org_uuid
|
||||
.eq(users_organizations::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_uuid))),
|
||||
)
|
||||
.left_join(groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)))
|
||||
.left_join(
|
||||
groups::table.on(groups::uuid
|
||||
.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
|
||||
)
|
||||
.left_join(
|
||||
collections_groups::table.on(collections_groups::groups_uuid
|
||||
.eq(groups_users::groups_uuid)
|
||||
.and(collections_groups::collections_uuid.eq(collections::uuid))),
|
||||
)
|
||||
.filter(collections::uuid.eq(&uuid))
|
||||
.filter(
|
||||
users_collections::collection_uuid
|
||||
.eq(&uuid)
|
||||
.and(users_collections::manage.eq(true))
|
||||
.or(
|
||||
// Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or(
|
||||
// access_all in Organization
|
||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
||||
),
|
||||
)
|
||||
.or(
|
||||
groups::access_all.eq(true), // access_all in groups
|
||||
)
|
||||
.or(
|
||||
// access via groups
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid).and(
|
||||
collections_groups::collections_uuid
|
||||
.is_not_null()
|
||||
.and(collections_groups::manage.eq(true)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.count()
|
||||
.first::<i64>(conn)
|
||||
.ok()
|
||||
.unwrap_or(0)
|
||||
!= 0
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||
let user_uuid = user_uuid.to_string();
|
||||
db_run! { conn: {
|
||||
collections::table
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(collections::uuid).and(
|
||||
users_collections::user_uuid.eq(user_uuid.clone())
|
||||
)
|
||||
))
|
||||
.left_join(users_organizations::table.on(
|
||||
collections::org_uuid.eq(users_organizations::org_uuid).and(
|
||||
users_organizations::user_uuid.eq(user_uuid)
|
||||
)
|
||||
))
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
||||
collections_groups::collections_uuid.eq(collections::uuid)
|
||||
)
|
||||
))
|
||||
.filter(collections::uuid.eq(&self.uuid))
|
||||
.filter(
|
||||
users_collections::collection_uuid.eq(&self.uuid).and(users_collections::manage.eq(true)).or(// Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or( // access_all in Organization
|
||||
users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
)).or(
|
||||
groups::access_all.eq(true) // access_all in groups
|
||||
).or( // access via groups
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid).and(
|
||||
collections_groups::collections_uuid.is_not_null().and(
|
||||
collections_groups::manage.eq(true))
|
||||
)
|
||||
)
|
||||
)
|
||||
.count()
|
||||
.first::<i64>(conn)
|
||||
.ok()
|
||||
.unwrap_or(0) != 0
|
||||
}}
|
||||
Self::is_coll_manageable_by_user(&self.uuid, user_uuid, conn).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,7 +632,7 @@ impl CollectionUser {
|
||||
user_uuid: &UserId,
|
||||
conn: &DbConn,
|
||||
) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
users_collections::table
|
||||
.filter(users_collections::user_uuid.eq(user_uuid))
|
||||
.inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid)))
|
||||
@@ -575,24 +640,35 @@ impl CollectionUser {
|
||||
.select(users_collections::all_columns)
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading users_collections")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_organization_swap_user_uuid_with_member_uuid(
|
||||
org_uuid: &OrganizationId,
|
||||
conn: &DbConn,
|
||||
) -> Vec<CollectionMembership> {
|
||||
let col_users = db_run! { conn: {
|
||||
users_collections::table
|
||||
.inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid)))
|
||||
.filter(collections::org_uuid.eq(org_uuid))
|
||||
.inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid)))
|
||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||
.select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading users_collections")
|
||||
}};
|
||||
col_users.into_iter().map(|c| c.into()).collect()
|
||||
let col_users = conn
|
||||
.run(move |conn| {
|
||||
users_collections::table
|
||||
.inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid)))
|
||||
.filter(collections::org_uuid.eq(org_uuid))
|
||||
.inner_join(
|
||||
users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid)),
|
||||
)
|
||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||
.select((
|
||||
users_organizations::uuid,
|
||||
users_collections::collection_uuid,
|
||||
users_collections::read_only,
|
||||
users_collections::hide_passwords,
|
||||
users_collections::manage,
|
||||
))
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading users_collections")
|
||||
})
|
||||
.await;
|
||||
col_users.into_iter().map(Into::into).collect()
|
||||
}
|
||||
|
||||
pub async fn save(
|
||||
@@ -661,7 +737,7 @@ impl CollectionUser {
|
||||
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||
User::update_uuid_revision(&self.user_uuid, conn).await;
|
||||
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(
|
||||
users_collections::table
|
||||
.filter(users_collections::user_uuid.eq(&self.user_uuid))
|
||||
@@ -669,17 +745,19 @@ impl CollectionUser {
|
||||
)
|
||||
.execute(conn)
|
||||
.map_res("Error removing user from collection")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
users_collections::table
|
||||
.filter(users_collections::collection_uuid.eq(collection_uuid))
|
||||
.select(users_collections::all_columns)
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading users_collections")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_org_and_coll_swap_user_uuid_with_member_uuid(
|
||||
@@ -687,16 +765,26 @@ impl CollectionUser {
|
||||
collection_uuid: &CollectionId,
|
||||
conn: &DbConn,
|
||||
) -> Vec<CollectionMembership> {
|
||||
let col_users = db_run! { conn: {
|
||||
users_collections::table
|
||||
.filter(users_collections::collection_uuid.eq(collection_uuid))
|
||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||
.inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid)))
|
||||
.select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading users_collections")
|
||||
}};
|
||||
col_users.into_iter().map(|c| c.into()).collect()
|
||||
let col_users = conn
|
||||
.run(move |conn| {
|
||||
users_collections::table
|
||||
.filter(users_collections::collection_uuid.eq(collection_uuid))
|
||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||
.inner_join(
|
||||
users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid)),
|
||||
)
|
||||
.select((
|
||||
users_organizations::uuid,
|
||||
users_collections::collection_uuid,
|
||||
users_collections::read_only,
|
||||
users_collections::hide_passwords,
|
||||
users_collections::manage,
|
||||
))
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading users_collections")
|
||||
})
|
||||
.await;
|
||||
col_users.into_iter().map(Into::into).collect()
|
||||
}
|
||||
|
||||
pub async fn find_by_collection_and_user(
|
||||
@@ -704,36 +792,39 @@ impl CollectionUser {
|
||||
user_uuid: &UserId,
|
||||
conn: &DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
users_collections::table
|
||||
.filter(users_collections::collection_uuid.eq(collection_uuid))
|
||||
.filter(users_collections::user_uuid.eq(user_uuid))
|
||||
.select(users_collections::all_columns)
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
users_collections::table
|
||||
.filter(users_collections::user_uuid.eq(user_uuid))
|
||||
.select(users_collections::all_columns)
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading users_collections")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult {
|
||||
for collection in CollectionUser::find_by_collection(collection_uuid, conn).await.iter() {
|
||||
for collection in &CollectionUser::find_by_collection(collection_uuid, conn).await {
|
||||
User::update_uuid_revision(&collection.user_uuid, conn).await;
|
||||
}
|
||||
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(users_collections::table.filter(users_collections::collection_uuid.eq(collection_uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error deleting users from collection")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user_and_org(
|
||||
@@ -743,17 +834,21 @@ impl CollectionUser {
|
||||
) -> EmptyResult {
|
||||
let collectionusers = Self::find_by_organization_and_user_uuid(org_uuid, user_uuid, conn).await;
|
||||
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
for user in collectionusers {
|
||||
let _: () = diesel::delete(users_collections::table.filter(
|
||||
users_collections::user_uuid.eq(user_uuid)
|
||||
.and(users_collections::collection_uuid.eq(user.collection_uuid))
|
||||
))
|
||||
.execute(conn)
|
||||
.map_res("Error removing user from collections")?;
|
||||
let _: () = diesel::delete(
|
||||
users_collections::table.filter(
|
||||
users_collections::user_uuid
|
||||
.eq(user_uuid)
|
||||
.and(users_collections::collection_uuid.eq(user.collection_uuid)),
|
||||
),
|
||||
)
|
||||
.execute(conn)
|
||||
.map_res("Error removing user from collections")?;
|
||||
}
|
||||
Ok(())
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn has_access_to_collection_by_user(col_id: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||
@@ -796,7 +891,7 @@ impl CollectionCipher {
|
||||
pub async fn delete(cipher_uuid: &CipherId, collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult {
|
||||
Self::update_users_revision(collection_uuid, conn).await;
|
||||
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(
|
||||
ciphers_collections::table
|
||||
.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid))
|
||||
@@ -804,23 +899,26 @@ impl CollectionCipher {
|
||||
)
|
||||
.execute(conn)
|
||||
.map_res("Error deleting cipher from collection")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(ciphers_collections::table.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error removing cipher from collections")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(ciphers_collections::table.filter(ciphers_collections::collection_uuid.eq(collection_uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error removing ciphers from collection")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_users_revision(collection_uuid: &CollectionId, conn: &DbConn) {
|
||||
|
||||
+92
-79
@@ -1,18 +1,20 @@
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
|
||||
use data_encoding::{BASE64, BASE64URL};
|
||||
use data_encoding::BASE64URL;
|
||||
use derive_more::{Display, From};
|
||||
use diesel::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{AuthRequest, UserId};
|
||||
use crate::db::schema::devices;
|
||||
use crate::{
|
||||
api::EmptyResult,
|
||||
crypto,
|
||||
db::{DbConn, schema::devices},
|
||||
error::MapResult,
|
||||
util::{format_date, get_uuid},
|
||||
};
|
||||
use diesel::prelude::*;
|
||||
use macros::{IdFromParam, UuidFromParam};
|
||||
|
||||
use super::{AuthRequest, UserId};
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[diesel(table_name = devices)]
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
@@ -25,7 +27,7 @@ pub struct Device {
|
||||
pub user_uuid: UserId,
|
||||
|
||||
pub name: String,
|
||||
pub atype: i32, // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs
|
||||
pub atype: i32, // https://github.com/bitwarden/server/blob/8d547dcc280babab70dd4a3c94ced6a34b12dfbf/src/Core/Enums/DeviceType.cs
|
||||
pub push_uuid: Option<PushId>,
|
||||
pub push_token: Option<String>,
|
||||
|
||||
@@ -35,6 +37,30 @@ pub struct Device {
|
||||
|
||||
/// Local methods
|
||||
impl Device {
|
||||
pub fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32) -> Self {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
Self {
|
||||
uuid,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
|
||||
user_uuid,
|
||||
name,
|
||||
atype,
|
||||
|
||||
push_uuid: Some(PushId(get_uuid())),
|
||||
push_token: None,
|
||||
refresh_token: Device::generate_refresh_token(),
|
||||
twofactor_remember: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn generate_refresh_token() -> String {
|
||||
crypto::encode_random_bytes::<64>(&BASE64URL)
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"id": self.uuid,
|
||||
@@ -48,10 +74,13 @@ impl Device {
|
||||
}
|
||||
|
||||
pub fn refresh_twofactor_remember(&mut self) -> String {
|
||||
let twofactor_remember = crypto::encode_random_bytes::<180>(&BASE64);
|
||||
self.twofactor_remember = Some(twofactor_remember.clone());
|
||||
use crate::auth::{encode_jwt, generate_2fa_remember_claims};
|
||||
|
||||
twofactor_remember
|
||||
let two_factor_remember_claim = generate_2fa_remember_claims(self.uuid.clone(), self.user_uuid.clone());
|
||||
let two_factor_remember_string = encode_jwt(&two_factor_remember_claim);
|
||||
self.twofactor_remember = Some(two_factor_remember_string.clone());
|
||||
|
||||
two_factor_remember_string
|
||||
}
|
||||
|
||||
pub fn delete_twofactor_remember(&mut self) {
|
||||
@@ -108,40 +137,19 @@ impl DeviceWithAuthRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::api::{ApiResult, EmptyResult};
|
||||
use crate::error::MapResult;
|
||||
|
||||
/// Database methods
|
||||
impl Device {
|
||||
pub async fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32, conn: &DbConn) -> ApiResult<Device> {
|
||||
let now = Utc::now().naive_utc();
|
||||
pub async fn save(&mut self, update_time: bool, conn: &DbConn) -> EmptyResult {
|
||||
if update_time {
|
||||
self.updated_at = Utc::now().naive_utc();
|
||||
}
|
||||
|
||||
let device = Self {
|
||||
uuid,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
|
||||
user_uuid,
|
||||
name,
|
||||
atype,
|
||||
|
||||
push_uuid: Some(PushId(get_uuid())),
|
||||
push_token: None,
|
||||
refresh_token: crypto::encode_random_bytes::<64>(&BASE64URL),
|
||||
twofactor_remember: None,
|
||||
};
|
||||
|
||||
device.inner_save(conn).await.map(|()| device)
|
||||
}
|
||||
|
||||
async fn inner_save(&self, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn:
|
||||
sqlite, mysql {
|
||||
crate::util::retry(||
|
||||
diesel::replace_into(devices::table)
|
||||
.values(self)
|
||||
.values(&*self)
|
||||
.execute(conn),
|
||||
10,
|
||||
).map_res("Error saving device")
|
||||
@@ -149,10 +157,10 @@ impl Device {
|
||||
postgresql {
|
||||
crate::util::retry(||
|
||||
diesel::insert_into(devices::table)
|
||||
.values(self)
|
||||
.values(&*self)
|
||||
.on_conflict((devices::uuid, devices::user_uuid))
|
||||
.do_update()
|
||||
.set(self)
|
||||
.set(&*self)
|
||||
.execute(conn),
|
||||
10,
|
||||
).map_res("Error saving device")
|
||||
@@ -160,28 +168,24 @@ impl Device {
|
||||
}
|
||||
}
|
||||
|
||||
// Should only be called after user has passed authentication
|
||||
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
||||
self.updated_at = Utc::now().naive_utc();
|
||||
self.inner_save(conn).await
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error removing devices for user")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
devices::table
|
||||
.filter(devices::uuid.eq(uuid))
|
||||
.filter(devices::user_uuid.eq(user_uuid))
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_with_auth_request_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<DeviceWithAuthRequest> {
|
||||
@@ -195,71 +199,76 @@ impl Device {
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
devices::table
|
||||
.filter(devices::user_uuid.eq(user_uuid))
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading devices")
|
||||
}}
|
||||
conn.run(move |conn| {
|
||||
devices::table.filter(devices::user_uuid.eq(user_uuid)).load::<Self>(conn).expect("Error loading devices")
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &DeviceId, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
devices::table
|
||||
.filter(devices::uuid.eq(uuid))
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
conn.run(move |conn| devices::table.filter(devices::uuid.eq(uuid)).first::<Self>(conn).ok()).await
|
||||
}
|
||||
|
||||
pub async fn clear_push_token_by_uuid(uuid: &DeviceId, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::update(devices::table)
|
||||
.filter(devices::uuid.eq(uuid))
|
||||
.set(devices::push_token.eq::<Option<String>>(None))
|
||||
.execute(conn)
|
||||
.map_res("Error removing push token")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub async fn find_by_refresh_token(refresh_token: &str, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
devices::table
|
||||
.filter(devices::refresh_token.eq(refresh_token))
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
conn.run(move |conn| devices::table.filter(devices::refresh_token.eq(refresh_token)).first::<Self>(conn).ok())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_latest_active_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
devices::table
|
||||
.filter(devices::user_uuid.eq(user_uuid))
|
||||
.order(devices::updated_at.desc())
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_push_devices_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
devices::table
|
||||
.filter(devices::user_uuid.eq(user_uuid))
|
||||
.filter(devices::push_token.is_not_null())
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading push devices")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn check_user_has_push_device(user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
devices::table
|
||||
.filter(devices::user_uuid.eq(user_uuid))
|
||||
.filter(devices::push_token.is_not_null())
|
||||
.count()
|
||||
.first::<i64>(conn)
|
||||
.ok()
|
||||
.unwrap_or(0) != 0
|
||||
}}
|
||||
.filter(devices::user_uuid.eq(user_uuid))
|
||||
.filter(devices::push_token.is_not_null())
|
||||
.count()
|
||||
.first::<i64>(conn)
|
||||
.ok()
|
||||
.unwrap_or(0)
|
||||
!= 0
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn rotate_refresh_tokens_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||
// Generate a new token per device.
|
||||
// We cannot do a single UPDATE with one value because each device needs a unique token.
|
||||
let devices = Self::find_by_user(user_uuid, conn).await;
|
||||
for mut device in devices {
|
||||
device.refresh_token = Device::generate_refresh_token();
|
||||
device.save(false, conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,9 +326,12 @@ pub enum DeviceType {
|
||||
MacOsCLI = 24,
|
||||
#[display("Linux CLI")]
|
||||
LinuxCLI = 25,
|
||||
#[display("DuckDuckGo")]
|
||||
DuckDuckGoBrowser = 26,
|
||||
}
|
||||
|
||||
impl DeviceType {
|
||||
#[expect(clippy::match_same_arms, reason = "Specifically define 14 and have a fallback for new types")]
|
||||
pub fn from_i32(value: i32) -> DeviceType {
|
||||
match value {
|
||||
0 => DeviceType::Android,
|
||||
@@ -348,6 +360,7 @@ impl DeviceType {
|
||||
23 => DeviceType::WindowsCLI,
|
||||
24 => DeviceType::MacOsCLI,
|
||||
25 => DeviceType::LinuxCLI,
|
||||
26 => DeviceType::DuckDuckGoBrowser,
|
||||
_ => DeviceType::UnknownBrowser,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use diesel::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{User, UserId};
|
||||
use crate::db::schema::emergency_access;
|
||||
use crate::{api::EmptyResult, db::DbConn, error::MapResult};
|
||||
use diesel::prelude::*;
|
||||
use crate::{
|
||||
api::EmptyResult,
|
||||
db::{DbConn, schema::emergency_access},
|
||||
error::MapResult,
|
||||
};
|
||||
use macros::UuidFromParam;
|
||||
|
||||
use super::{User, UserId};
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[diesel(table_name = emergency_access)]
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
@@ -85,17 +89,15 @@ impl EmergencyAccess {
|
||||
pub async fn to_json_grantee_details(&self, conn: &DbConn) -> Option<Value> {
|
||||
let grantee_user = if let Some(grantee_uuid) = &self.grantee_uuid {
|
||||
User::find_by_uuid(grantee_uuid, conn).await.expect("Grantee user not found.")
|
||||
} else if let Some(email) = self.email.as_deref() {
|
||||
match User::find_by_mail(email, conn).await {
|
||||
Some(user) => user,
|
||||
None => {
|
||||
// remove outstanding invitations which should not exist
|
||||
Self::delete_all_by_grantee_email(email, conn).await.ok();
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
let email = self.email.as_deref()?;
|
||||
if let Some(user) = User::find_by_mail(email, conn).await {
|
||||
user
|
||||
} else {
|
||||
// remove outstanding invitations which should not exist
|
||||
Self::delete_all_by_grantee_email(email, conn).await.ok();
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(json!({
|
||||
@@ -184,28 +186,36 @@ impl EmergencyAccess {
|
||||
self.status = status;
|
||||
date.clone_into(&mut self.updated_at);
|
||||
|
||||
db_run! { conn: {
|
||||
crate::util::retry(|| {
|
||||
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
|
||||
.set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date)))
|
||||
.execute(conn)
|
||||
}, 10)
|
||||
conn.run(move |conn| {
|
||||
crate::util::retry(
|
||||
|| {
|
||||
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
|
||||
.set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date)))
|
||||
.execute(conn)
|
||||
},
|
||||
10,
|
||||
)
|
||||
.map_res("Error updating emergency access status")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
|
||||
self.last_notification_at = Some(date.to_owned());
|
||||
date.clone_into(&mut self.updated_at);
|
||||
|
||||
db_run! { conn: {
|
||||
crate::util::retry(|| {
|
||||
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
|
||||
.set((emergency_access::last_notification_at.eq(date), emergency_access::updated_at.eq(date)))
|
||||
.execute(conn)
|
||||
}, 10)
|
||||
conn.run(move |conn| {
|
||||
crate::util::retry(
|
||||
|| {
|
||||
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
|
||||
.set((emergency_access::last_notification_at.eq(date), emergency_access::updated_at.eq(date)))
|
||||
.execute(conn)
|
||||
},
|
||||
10,
|
||||
)
|
||||
.map_res("Error updating emergency access status")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||
@@ -228,11 +238,12 @@ impl EmergencyAccess {
|
||||
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||
User::update_uuid_revision(&self.grantor_uuid, conn).await;
|
||||
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(emergency_access::table.filter(emergency_access::uuid.eq(self.uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error removing user from emergency access")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_grantor_uuid_and_grantee_uuid_or_email(
|
||||
@@ -241,23 +252,25 @@ impl EmergencyAccess {
|
||||
email: &str,
|
||||
conn: &DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||
.filter(emergency_access::grantee_uuid.eq(grantee_uuid).or(emergency_access::email.eq(email)))
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_all_recoveries_initiated(conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::status.eq(EmergencyAccessStatus::RecoveryInitiated as i32))
|
||||
.filter(emergency_access::recovery_initiated_at.is_not_null())
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading emergency_access")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_grantor_uuid(
|
||||
@@ -265,13 +278,14 @@ impl EmergencyAccess {
|
||||
grantor_uuid: &UserId,
|
||||
conn: &DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::uuid.eq(uuid))
|
||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_grantee_uuid(
|
||||
@@ -279,13 +293,14 @@ impl EmergencyAccess {
|
||||
grantee_uuid: &UserId,
|
||||
conn: &DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::uuid.eq(uuid))
|
||||
.filter(emergency_access::grantee_uuid.eq(grantee_uuid))
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_grantee_email(
|
||||
@@ -293,61 +308,67 @@ impl EmergencyAccess {
|
||||
grantee_email: &str,
|
||||
conn: &DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::uuid.eq(uuid))
|
||||
.filter(emergency_access::email.eq(grantee_email))
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_all_by_grantee_uuid(grantee_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::grantee_uuid.eq(grantee_uuid))
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading emergency_access")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::email.eq(grantee_email))
|
||||
.filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32))
|
||||
.first::<Self>(conn)
|
||||
.ok()
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_all_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::email.eq(grantee_email))
|
||||
.filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32))
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading emergency_access")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_all_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading emergency_access")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_all_confirmed_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||
.filter(emergency_access::status.ge(EmergencyAccessStatus::Confirmed as i32))
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading emergency_access")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn accept_invite(&mut self, grantee_uuid: &UserId, grantee_email: &str, conn: &DbConn) -> EmptyResult {
|
||||
|
||||
+38
-28
@@ -1,11 +1,18 @@
|
||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||
//use derive_more::{AsRef, Deref, Display, From};
|
||||
use diesel::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::EmptyResult,
|
||||
db::{
|
||||
DbConn,
|
||||
schema::{event, users_organizations},
|
||||
},
|
||||
error::MapResult,
|
||||
};
|
||||
|
||||
use super::{CipherId, CollectionId, GroupId, MembershipId, OrgPolicyId, OrganizationId, UserId};
|
||||
use crate::db::schema::{event, users_organizations};
|
||||
use crate::{api::EmptyResult, db::DbConn, error::MapResult, CONFIG};
|
||||
use diesel::prelude::*;
|
||||
|
||||
// https://bitwarden.com/help/event-logs/
|
||||
|
||||
@@ -249,11 +256,10 @@ impl Event {
|
||||
}
|
||||
|
||||
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(event::table.filter(event::uuid.eq(self.uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error deleting event")
|
||||
}}
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(event::table.filter(event::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting event")
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// ##############
|
||||
@@ -264,7 +270,7 @@ impl Event {
|
||||
end: &NaiveDateTime,
|
||||
conn: &DbConn,
|
||||
) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
event::table
|
||||
.filter(event::org_uuid.eq(org_uuid))
|
||||
.filter(event::event_date.between(start, end))
|
||||
@@ -272,18 +278,15 @@ impl Event {
|
||||
.limit(Self::PAGE_SIZE)
|
||||
.load::<Self>(conn)
|
||||
.expect("Error filtering events")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 {
|
||||
db_run! { conn: {
|
||||
event::table
|
||||
.filter(event::org_uuid.eq(org_uuid))
|
||||
.count()
|
||||
.first::<i64>(conn)
|
||||
.ok()
|
||||
.unwrap_or(0)
|
||||
}}
|
||||
conn.run(move |conn| {
|
||||
event::table.filter(event::org_uuid.eq(org_uuid)).count().first::<i64>(conn).ok().unwrap_or(0)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_org_and_member(
|
||||
@@ -293,18 +296,23 @@ impl Event {
|
||||
end: &NaiveDateTime,
|
||||
conn: &DbConn,
|
||||
) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
event::table
|
||||
.inner_join(users_organizations::table.on(users_organizations::uuid.eq(member_uuid)))
|
||||
.filter(event::org_uuid.eq(org_uuid))
|
||||
.filter(event::event_date.between(start, end))
|
||||
.filter(event::user_uuid.eq(users_organizations::user_uuid.nullable()).or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable())))
|
||||
.filter(
|
||||
event::user_uuid
|
||||
.eq(users_organizations::user_uuid.nullable())
|
||||
.or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable())),
|
||||
)
|
||||
.select(event::all_columns)
|
||||
.order_by(event::event_date.desc())
|
||||
.limit(Self::PAGE_SIZE)
|
||||
.load::<Self>(conn)
|
||||
.expect("Error filtering events")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_cipher_uuid(
|
||||
@@ -313,7 +321,7 @@ impl Event {
|
||||
end: &NaiveDateTime,
|
||||
conn: &DbConn,
|
||||
) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
event::table
|
||||
.filter(event::cipher_uuid.eq(cipher_uuid))
|
||||
.filter(event::event_date.between(start, end))
|
||||
@@ -321,17 +329,19 @@ impl Event {
|
||||
.limit(Self::PAGE_SIZE)
|
||||
.load::<Self>(conn)
|
||||
.expect("Error filtering events")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
|
||||
if let Some(days_to_retain) = CONFIG.events_days_retain() {
|
||||
let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap();
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(event::table.filter(event::event_date.lt(dt)))
|
||||
.execute(conn)
|
||||
.map_res("Error cleaning old events")
|
||||
}}
|
||||
.execute(conn)
|
||||
.map_res("Error cleaning old events")
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+32
-30
@@ -1,7 +1,13 @@
|
||||
use super::{CipherId, User, UserId};
|
||||
use crate::db::schema::favorites;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::{
|
||||
api::EmptyResult,
|
||||
db::{DbConn, schema::favorites},
|
||||
error::MapResult,
|
||||
};
|
||||
|
||||
use super::{CipherId, User, UserId};
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable)]
|
||||
#[diesel(table_name = favorites)]
|
||||
#[diesel(primary_key(user_uuid, cipher_uuid))]
|
||||
@@ -10,24 +16,18 @@ pub struct Favorite {
|
||||
pub cipher_uuid: CipherId,
|
||||
}
|
||||
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
use crate::error::MapResult;
|
||||
|
||||
impl Favorite {
|
||||
// Returns whether the specified cipher is a favorite of the specified user.
|
||||
pub async fn is_favorite(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
let query = favorites::table
|
||||
.filter(favorites::cipher_uuid.eq(cipher_uuid))
|
||||
.filter(favorites::user_uuid.eq(user_uuid))
|
||||
.count();
|
||||
|
||||
query.first::<i64>(conn)
|
||||
.ok()
|
||||
.unwrap_or(0) != 0
|
||||
}}
|
||||
query.first::<i64>(conn).ok().unwrap_or(0) != 0
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// Sets whether the specified cipher is a favorite of the specified user.
|
||||
@@ -41,27 +41,26 @@ impl Favorite {
|
||||
match (old, new) {
|
||||
(false, true) => {
|
||||
User::update_uuid_revision(user_uuid, conn).await;
|
||||
db_run! { conn: {
|
||||
diesel::insert_into(favorites::table)
|
||||
.values((
|
||||
favorites::user_uuid.eq(user_uuid),
|
||||
favorites::cipher_uuid.eq(cipher_uuid),
|
||||
))
|
||||
.execute(conn)
|
||||
.map_res("Error adding favorite")
|
||||
}}
|
||||
conn.run(move |conn| {
|
||||
diesel::insert_into(favorites::table)
|
||||
.values((favorites::user_uuid.eq(user_uuid), favorites::cipher_uuid.eq(cipher_uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error adding favorite")
|
||||
})
|
||||
.await
|
||||
}
|
||||
(true, false) => {
|
||||
User::update_uuid_revision(user_uuid, conn).await;
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(
|
||||
favorites::table
|
||||
.filter(favorites::user_uuid.eq(user_uuid))
|
||||
.filter(favorites::cipher_uuid.eq(cipher_uuid))
|
||||
.filter(favorites::cipher_uuid.eq(cipher_uuid)),
|
||||
)
|
||||
.execute(conn)
|
||||
.map_res("Error removing favorite")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
// Otherwise, the favorite status is already what it should be.
|
||||
_ => Ok(()),
|
||||
@@ -70,31 +69,34 @@ impl Favorite {
|
||||
|
||||
// Delete all favorite entries associated with the specified cipher.
|
||||
pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(favorites::table.filter(favorites::cipher_uuid.eq(cipher_uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error removing favorites by cipher")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// Delete all favorite entries associated with the specified user.
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
diesel::delete(favorites::table.filter(favorites::user_uuid.eq(user_uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error removing favorites by user")
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Return a vec with (cipher_uuid) this will only contain favorite flagged ciphers
|
||||
/// This is used during a full sync so we only need one query for all favorite cipher matches.
|
||||
pub async fn get_all_cipher_uuid_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<CipherId> {
|
||||
db_run! { conn: {
|
||||
conn.run(move |conn| {
|
||||
favorites::table
|
||||
.filter(favorites::user_uuid.eq(user_uuid))
|
||||
.select(favorites::cipher_uuid)
|
||||
.load::<CipherId>(conn)
|
||||
.unwrap_or_default()
|
||||
}}
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user