mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2025-09-09 18:25:58 +03:00
Compare commits
118 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
ad48e9ed0f | ||
|
f724addf9a | ||
|
aa20974703 | ||
|
a846f6c610 | ||
|
c218c34812 | ||
|
2626e66873 | ||
|
81e0e1b339 | ||
|
fd1354d00e | ||
|
071a3b2a32 | ||
|
32cfaab5ee | ||
|
d348f12a0e | ||
|
11845d9f5b | ||
|
de70fbf88a | ||
|
0b04caab78 | ||
|
4c78c5a9c9 | ||
|
73f0841f17 | ||
|
4559e85daa | ||
|
bbef332e25 | ||
|
1e950c7dbc | ||
|
f14e19a3d8 | ||
|
668d5c23dc | ||
|
fb6f96f5c3 | ||
|
6e6e34ff18 | ||
|
790146bfac | ||
|
af625930d6 | ||
|
a28ebcb401 | ||
|
77e47ddd1f | ||
|
5b620ba6cd | ||
|
d5f9b33f66 | ||
|
596c9b8691 | ||
|
d4357eb55a | ||
|
b37f0dfde3 | ||
|
624791e09a | ||
|
f9a73a9bbe | ||
|
35868dd72c | ||
|
979d010dc2 | ||
|
b34d548246 | ||
|
a87646b8cb | ||
|
a2411eef56 | ||
|
52ed8e4d75 | ||
|
24c914799d | ||
|
db53511855 | ||
|
325691e588 | ||
|
fac3cb687d | ||
|
afbf1db331 | ||
|
1aefaec297 | ||
|
f1d3fb5d40 | ||
|
ac2723f898 | ||
|
2fffaec226 | ||
|
5c54dfee3a | ||
|
967d2d78ec | ||
|
1aa5e0d4dc | ||
|
b47cf97409 | ||
|
5e802f8aa3 | ||
|
0bdeb02a31 | ||
|
b03698fadb | ||
|
39d1a09704 | ||
|
a447e4e7ef | ||
|
4eee6e7aee | ||
|
b6fde857a7 | ||
|
3c66deb5cc | ||
|
4146612a32 | ||
|
a314933557 | ||
|
c5d7e3f2bc | ||
|
c95a2881b5 | ||
|
4c3727b4a3 | ||
|
a1f304dff7 | ||
|
a8870eef0d | ||
|
afaebc6cf3 | ||
|
8f4a1f4fc2 | ||
|
0807783388 | ||
|
80d4061d14 | ||
|
dc2f8e5c85 | ||
|
aee1ea032b | ||
|
484e82fb9f | ||
|
322a08edfb | ||
|
08afc312c3 | ||
|
5571a5d8ed | ||
|
6a8c65493f | ||
|
dfdf4473ea | ||
|
8bbbff7567 | ||
|
42e37ebea1 | ||
|
632f4d5453 | ||
|
6c5e35ce5c | ||
|
4ff15f6dc2 | ||
|
ec8028aef2 | ||
|
63cbd9ef9c | ||
|
9cca64003a | ||
|
819d5e2dc8 | ||
|
3b06ab296b | ||
|
0de52c6c99 | ||
|
e3b00b59a7 | ||
|
5a390a973f | ||
|
1ee8e44912 | ||
|
86685c1cd2 | ||
|
e3feba2a2c | ||
|
0a68de6c24 | ||
|
4be8dae626 | ||
|
e4d08836e2 | ||
|
c2a324e5da | ||
|
77f95146d6 | ||
|
6cd8512bbd | ||
|
843604c9e7 | ||
|
7407b8326a | ||
|
adf47827c9 | ||
|
5471088e93 | ||
|
4e85a1dee1 | ||
|
ec60839064 | ||
|
d4bfa1a189 | ||
|
862d401077 | ||
|
255a06382d | ||
|
bbb0484d03 | ||
|
93346bc05d | ||
|
fdf50f0064 | ||
|
ccf6ee79d0 | ||
|
91dd19473d | ||
|
c06162b22f | ||
|
7a6a3e4160 |
@@ -44,6 +44,10 @@
|
||||
## Enable extended logging, which shows timestamps and targets in the logs
|
||||
# EXTENDED_LOGGING=true
|
||||
|
||||
## Timestamp format used in extended logging.
|
||||
## Format specifiers: https://docs.rs/chrono/latest/chrono/format/strftime
|
||||
# LOG_TIMESTAMP_FORMAT="%Y-%m-%d %H:%M:%S.%3f"
|
||||
|
||||
## Logging to file
|
||||
## It's recommended to also set 'ROCKET_CLI_COLORS=off'
|
||||
# LOG_FILE=/path/to/log
|
||||
@@ -185,6 +189,7 @@
|
||||
# SMTP_FROM_NAME=Bitwarden_RS
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SSL=true
|
||||
# SMTP_EXPLICIT_TLS=true # N.B. This variable configures Implicit TLS. It's currently mislabelled (see bug #851)
|
||||
# SMTP_USERNAME=username
|
||||
# SMTP_PASSWORD=password
|
||||
# SMTP_AUTH_MECHANISM="Plain"
|
||||
|
1163
Cargo.lock
generated
1163
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
62
Cargo.toml
62
Cargo.toml
@@ -17,6 +17,10 @@ mysql = ["diesel/mysql", "diesel_migrations/mysql"]
|
||||
postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
|
||||
sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "libsqlite3-sys"]
|
||||
|
||||
# Enable unstable features, requires nightly
|
||||
# Currently only used to enable rusts official ip support
|
||||
unstable = []
|
||||
|
||||
[target."cfg(not(windows))".dependencies]
|
||||
syslog = "4.0.1"
|
||||
|
||||
@@ -26,10 +30,10 @@ rocket = { version = "0.5.0-dev", features = ["tls"], default-features = false }
|
||||
rocket_contrib = "0.5.0-dev"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.10.4", features = ["blocking", "json"] }
|
||||
reqwest = { version = "0.10.6", features = ["blocking", "json"] }
|
||||
|
||||
# multipart/form-data support
|
||||
multipart = { version = "0.16.1", features = ["server"], default-features = false }
|
||||
multipart = { version = "0.17.0", features = ["server"], default-features = false }
|
||||
|
||||
# WebSockets library
|
||||
ws = "0.9.1"
|
||||
@@ -41,74 +45,71 @@ rmpv = "0.4.4"
|
||||
chashmap = "2.2.2"
|
||||
|
||||
# A generic serialization/deserialization framework
|
||||
serde = "1.0.104"
|
||||
serde_derive = "1.0.104"
|
||||
serde_json = "1.0.48"
|
||||
serde = "1.0.114"
|
||||
serde_derive = "1.0.114"
|
||||
serde_json = "1.0.56"
|
||||
|
||||
# Logging
|
||||
log = "0.4.8"
|
||||
log = "0.4.11"
|
||||
fern = { version = "0.6.0", features = ["syslog-4"] }
|
||||
|
||||
# A safe, extensible ORM and Query builder
|
||||
diesel = { version = "1.4.3", features = [ "chrono", "r2d2"] }
|
||||
diesel = { version = "1.4.5", features = [ "chrono", "r2d2"] }
|
||||
diesel_migrations = "1.4.0"
|
||||
|
||||
# Bundled SQLite
|
||||
libsqlite3-sys = { version = "0.16.0", features = ["bundled"], optional = true }
|
||||
libsqlite3-sys = { version = "0.18.0", features = ["bundled"], optional = true }
|
||||
|
||||
# Crypto library
|
||||
ring = "0.16.11"
|
||||
ring = "0.16.15"
|
||||
|
||||
# UUID generation
|
||||
uuid = { version = "0.8.1", features = ["v4"] }
|
||||
|
||||
# Date and time librar for Rust
|
||||
chrono = "0.4.11"
|
||||
time = "0.2.9"
|
||||
# Date and time libraries
|
||||
chrono = "0.4.13"
|
||||
chrono-tz = "0.5.2"
|
||||
time = "0.2.16"
|
||||
|
||||
# TOTP library
|
||||
oath = "0.10.2"
|
||||
|
||||
# Data encoding library
|
||||
data-encoding = "2.2.0"
|
||||
data-encoding = "2.2.1"
|
||||
|
||||
# JWT library
|
||||
jsonwebtoken = "7.1.0"
|
||||
jsonwebtoken = "7.2.0"
|
||||
|
||||
# U2F library
|
||||
u2f = "0.2.0"
|
||||
|
||||
# Yubico Library
|
||||
yubico = { version = "0.9.0", features = ["online-tokio"], default-features = false }
|
||||
yubico = { version = "0.9.1", features = ["online-tokio"], default-features = false }
|
||||
|
||||
# A `dotenv` implementation for Rust
|
||||
dotenv = { version = "0.15.0", default-features = false }
|
||||
|
||||
# Lazy initialization
|
||||
once_cell = "1.3.1"
|
||||
|
||||
# More derives
|
||||
derive_more = "0.99.3"
|
||||
once_cell = "1.4.0"
|
||||
|
||||
# Numerical libraries
|
||||
num-traits = "0.2.11"
|
||||
num-traits = "0.2.12"
|
||||
num-derive = "0.3.0"
|
||||
|
||||
# Email libraries
|
||||
lettre = "0.10.0-pre"
|
||||
lettre = { version = "0.10.0-alpha.1", features = ["smtp-transport", "builder", "serde", "native-tls", "hostname"], default-features = false }
|
||||
native-tls = "0.2.4"
|
||||
quoted_printable = "0.4.2"
|
||||
|
||||
# Template library
|
||||
handlebars = { version = "3.0.1", features = ["dir_source"] }
|
||||
handlebars = { version = "3.3.0", features = ["dir_source"] }
|
||||
|
||||
# For favicon extraction from main website
|
||||
soup = "0.5.0"
|
||||
regex = "1.3.4"
|
||||
regex = "1.3.9"
|
||||
data-url = "0.1.0"
|
||||
|
||||
# Used by U2F, JWT and Postgres
|
||||
openssl = "0.10.28"
|
||||
openssl = "0.10.30"
|
||||
|
||||
# URL encoding library
|
||||
percent-encoding = "2.1.0"
|
||||
@@ -116,18 +117,15 @@ percent-encoding = "2.1.0"
|
||||
idna = "0.2.0"
|
||||
|
||||
# CLI argument parsing
|
||||
structopt = "0.3.11"
|
||||
structopt = "0.3.15"
|
||||
|
||||
# Logging panics to logfile instead stderr only
|
||||
backtrace = "0.3.45"
|
||||
backtrace = "0.3.50"
|
||||
|
||||
[patch.crates-io]
|
||||
# Use newest ring
|
||||
rocket = { git = 'https://github.com/SergioBenitez/Rocket', rev = 'dfc9e9aab01d349da32c52db393e35b7fffea63c' }
|
||||
rocket_contrib = { git = 'https://github.com/SergioBenitez/Rocket', rev = 'dfc9e9aab01d349da32c52db393e35b7fffea63c' }
|
||||
|
||||
# Use git version for timeout fix #706
|
||||
lettre = { git = 'https://github.com/lettre/lettre', rev = '245c600c82ee18b766e8729f005ff453a55dce34' }
|
||||
rocket = { git = 'https://github.com/SergioBenitez/Rocket', rev = '1010f6a2a88fac899dec0cd2f642156908038a53' }
|
||||
rocket_contrib = { git = 'https://github.com/SergioBenitez/Rocket', rev = '1010f6a2a88fac899dec0cd2f642156908038a53' }
|
||||
|
||||
# For favicon extraction from main website
|
||||
data-url = { git = 'https://github.com/servo/rust-url', package="data-url", rev = '7f1bd6ce1c2fde599a757302a843a60e714c5f72' }
|
||||
|
16
build.rs
16
build.rs
@@ -1,4 +1,5 @@
|
||||
use std::process::Command;
|
||||
use std::env;
|
||||
|
||||
fn main() {
|
||||
#[cfg(all(feature = "sqlite", feature = "mysql"))]
|
||||
@@ -10,8 +11,13 @@ fn main() {
|
||||
|
||||
#[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))]
|
||||
compile_error!("You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite");
|
||||
|
||||
read_git_info().ok();
|
||||
|
||||
if let Ok(version) = env::var("BWRS_VERSION") {
|
||||
println!("cargo:rustc-env=BWRS_VERSION={}", version);
|
||||
println!("cargo:rustc-env=CARGO_PKG_VERSION={}", version);
|
||||
} else {
|
||||
read_git_info().ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Result<String, std::io::Error> {
|
||||
@@ -54,14 +60,16 @@ fn read_git_info() -> Result<(), std::io::Error> {
|
||||
} else {
|
||||
format!("{}-{}", last_tag, rev_short)
|
||||
};
|
||||
println!("cargo:rustc-env=GIT_VERSION={}", version);
|
||||
|
||||
println!("cargo:rustc-env=BWRS_VERSION={}", version);
|
||||
println!("cargo:rustc-env=CARGO_PKG_VERSION={}", version);
|
||||
|
||||
// To access these values, use:
|
||||
// env!("GIT_EXACT_TAG")
|
||||
// env!("GIT_LAST_TAG")
|
||||
// env!("GIT_BRANCH")
|
||||
// env!("GIT_REV")
|
||||
// env!("GIT_VERSION")
|
||||
// env!("BWRS_VERSION")
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@@ -9,13 +9,13 @@
|
||||
{% elif "amd64" in target_file %}
|
||||
{% set runtime_stage_base_image = "debian:buster-slim" %}
|
||||
{% set package_arch_name = "" %}
|
||||
{% elif "aarch64" in target_file %}
|
||||
{% elif "arm64v8" in target_file %}
|
||||
{% set runtime_stage_base_image = "balenalib/aarch64-debian:buster" %}
|
||||
{% set package_arch_name = "arm64" %}
|
||||
{% elif "armv6" in target_file %}
|
||||
{% elif "arm32v6" in target_file %}
|
||||
{% set runtime_stage_base_image = "balenalib/rpi-debian:buster" %}
|
||||
{% set package_arch_name = "armel" %}
|
||||
{% elif "armv7" in target_file %}
|
||||
{% elif "arm32v7" in target_file %}
|
||||
{% set runtime_stage_base_image = "balenalib/armv7hf-debian:buster" %}
|
||||
{% set package_arch_name = "armhf" %}
|
||||
{% endif %}
|
||||
@@ -27,17 +27,17 @@
|
||||
# https://docs.docker.com/develop/develop-images/multistage-build/
|
||||
# https://whitfin.io/speeding-up-rust-docker-builds/
|
||||
####################### VAULT BUILD IMAGE #######################
|
||||
{% set vault_image_hash = "sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c" %}
|
||||
{% set vault_image_hash = "sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c" %}
|
||||
{% raw %}
|
||||
# This hash is extracted from the docker web-vault builds and it's prefered over a simple tag because it's immutable.
|
||||
# It can be viewed in multiple ways:
|
||||
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
|
||||
# - From the console, with the following commands:
|
||||
# docker pull bitwardenrs/web-vault:v2.12.0e
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
|
||||
# docker pull bitwardenrs/web-vault:v2.15.1
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
|
||||
#
|
||||
# - To do the opposite, and get the tag from the hash, you can do:
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
|
||||
{% endraw %}
|
||||
FROM bitwardenrs/web-vault@{{ vault_image_hash }} as vault
|
||||
|
||||
@@ -73,7 +73,7 @@ RUN rustup set profile minimal
|
||||
ENV USER "root"
|
||||
ENV RUSTFLAGS='-C link-arg=-s'
|
||||
|
||||
{% elif "aarch64" in target_file or "armv" in target_file %}
|
||||
{% elif "arm32" in target_file or "arm64" in target_file %}
|
||||
# Install required build libs for {{ package_arch_name }} architecture.
|
||||
RUN sed 's/^deb/deb-src/' /etc/apt/sources.list > \
|
||||
/etc/apt/sources.list.d/deb-src.list \
|
||||
@@ -85,7 +85,7 @@ RUN sed 's/^deb/deb-src/' /etc/apt/sources.list > \
|
||||
libc6-dev{{ package_arch_prefix }}
|
||||
|
||||
{% endif -%}
|
||||
{% if "aarch64" in target_file %}
|
||||
{% if "arm64v8" in target_file %}
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
--no-install-recommends \
|
||||
@@ -97,7 +97,7 @@ RUN apt-get update \
|
||||
ENV CARGO_HOME "/root/.cargo"
|
||||
ENV USER "root"
|
||||
|
||||
{% elif "armv6" in target_file %}
|
||||
{% elif "arm32v6" in target_file %}
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
--no-install-recommends \
|
||||
@@ -109,19 +109,7 @@ RUN apt-get update \
|
||||
ENV CARGO_HOME "/root/.cargo"
|
||||
ENV USER "root"
|
||||
|
||||
{% elif "armv6" in target_file %}
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
--no-install-recommends \
|
||||
gcc-arm-linux-gnueabihf \
|
||||
&& mkdir -p ~/.cargo \
|
||||
&& echo '[target.armv7-unknown-linux-gnueabihf]' >> ~/.cargo/config \
|
||||
&& echo 'linker = "arm-linux-gnueabihf-gcc"' >> ~/.cargo/config
|
||||
|
||||
ENV CARGO_HOME "/root/.cargo"
|
||||
ENV USER "root"
|
||||
|
||||
{% elif "armv7" in target_file %}
|
||||
{% elif "arm32v7" in target_file %}
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
--no-install-recommends \
|
||||
@@ -162,17 +150,17 @@ COPY ./Cargo.* ./
|
||||
COPY ./rust-toolchain ./rust-toolchain
|
||||
COPY ./build.rs ./build.rs
|
||||
|
||||
{% if "aarch64" in target_file %}
|
||||
{% if "arm64v8" in target_file %}
|
||||
ENV CC_aarch64_unknown_linux_gnu="/usr/bin/aarch64-linux-gnu-gcc"
|
||||
ENV CROSS_COMPILE="1"
|
||||
ENV OPENSSL_INCLUDE_DIR="/usr/include/aarch64-linux-gnu"
|
||||
ENV OPENSSL_LIB_DIR="/usr/lib/aarch64-linux-gnu"
|
||||
{% elif "armv6" in target_file %}
|
||||
{% elif "arm32v6" in target_file %}
|
||||
ENV CC_arm_unknown_linux_gnueabi="/usr/bin/arm-linux-gnueabi-gcc"
|
||||
ENV CROSS_COMPILE="1"
|
||||
ENV OPENSSL_INCLUDE_DIR="/usr/include/arm-linux-gnueabi"
|
||||
ENV OPENSSL_LIB_DIR="/usr/lib/arm-linux-gnueabi"
|
||||
{% elif "armv7" in target_file %}
|
||||
{% elif "arm32v7" in target_file %}
|
||||
ENV CC_armv7_unknown_linux_gnueabihf="/usr/bin/arm-linux-gnueabihf-gcc"
|
||||
ENV CROSS_COMPILE="1"
|
||||
ENV OPENSSL_INCLUDE_DIR="/usr/include/arm-linux-gnueabihf"
|
||||
@@ -182,13 +170,13 @@ ENV OPENSSL_LIB_DIR="/usr/lib/arm-linux-gnueabihf"
|
||||
{% if "alpine" in target_file %}
|
||||
RUN rustup target add x86_64-unknown-linux-musl
|
||||
|
||||
{% elif "aarch64" in target_file %}
|
||||
{% elif "arm64v8" in target_file %}
|
||||
RUN rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
{% elif "armv6" in target_file %}
|
||||
{% elif "arm32v6" in target_file %}
|
||||
RUN rustup target add arm-unknown-linux-gnueabi
|
||||
|
||||
{% elif "armv7" in target_file %}
|
||||
{% elif "arm32v7" in target_file %}
|
||||
RUN rustup target add armv7-unknown-linux-gnueabihf
|
||||
{% endif %}
|
||||
# Builds your dependencies and removes the
|
||||
@@ -208,11 +196,11 @@ RUN touch src/main.rs
|
||||
# your actual source files being built
|
||||
{% if "amd64" in target_file %}
|
||||
RUN cargo build --features ${DB} --release
|
||||
{% elif "aarch64" in target_file %}
|
||||
{% elif "arm64v8" in target_file %}
|
||||
RUN cargo build --features ${DB} --release --target=aarch64-unknown-linux-gnu
|
||||
{% elif "armv6" in target_file %}
|
||||
{% elif "arm32v6" in target_file %}
|
||||
RUN cargo build --features ${DB} --release --target=arm-unknown-linux-gnueabi
|
||||
{% elif "armv7" in target_file %}
|
||||
{% elif "arm32v7" in target_file %}
|
||||
RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-gnueabihf
|
||||
{% endif %}
|
||||
|
||||
@@ -277,20 +265,21 @@ COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
{% if "alpine" in target_file %}
|
||||
COPY --from=build /app/target/x86_64-unknown-linux-musl/release/bitwarden_rs .
|
||||
{% elif "aarch64" in target_file %}
|
||||
{% elif "arm64v8" in target_file %}
|
||||
COPY --from=build /app/target/aarch64-unknown-linux-gnu/release/bitwarden_rs .
|
||||
{% elif "armv6" in target_file %}
|
||||
{% elif "arm32v6" in target_file %}
|
||||
COPY --from=build /app/target/arm-unknown-linux-gnueabi/release/bitwarden_rs .
|
||||
{% elif "armv7" in target_file %}
|
||||
{% elif "arm32v7" in target_file %}
|
||||
COPY --from=build /app/target/armv7-unknown-linux-gnueabihf/release/bitwarden_rs .
|
||||
{% else %}
|
||||
COPY --from=build app/target/release/bitwarden_rs .
|
||||
{% endif %}
|
||||
|
||||
COPY docker/healthcheck.sh ./healthcheck.sh
|
||||
COPY docker/healthcheck.sh /healthcheck.sh
|
||||
COPY docker/start.sh /start.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
|
||||
|
||||
# Configures the startup!
|
||||
WORKDIR /
|
||||
CMD ["/bitwarden_rs"]
|
||||
CMD ["/start.sh"]
|
||||
|
3
docker/README.md
Normal file
3
docker/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
The arch-specific directory names follow the arch identifiers used by the Docker official images:
|
||||
|
||||
https://github.com/docker-library/official-images/blob/master/README.md#architectures-other-than-amd64
|
@@ -10,12 +10,12 @@
|
||||
# It can be viewed in multiple ways:
|
||||
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
|
||||
# - From the console, with the following commands:
|
||||
# docker pull bitwardenrs/web-vault:v2.12.0e
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
|
||||
#
|
||||
# docker pull bitwardenrs/web-vault:v2.15.1
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
|
||||
#
|
||||
# - To do the opposite, and get the tag from the hash, you can do:
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c
|
||||
FROM bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c as vault
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
|
||||
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# We need to use the Rust build image, because
|
||||
@@ -92,10 +92,11 @@ COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
COPY --from=build app/target/release/bitwarden_rs .
|
||||
|
||||
COPY docker/healthcheck.sh ./healthcheck.sh
|
||||
COPY docker/healthcheck.sh /healthcheck.sh
|
||||
COPY docker/start.sh /start.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
|
||||
|
||||
# Configures the startup!
|
||||
WORKDIR /
|
||||
CMD ["/bitwarden_rs"]
|
||||
CMD ["/start.sh"]
|
||||
|
@@ -10,16 +10,16 @@
|
||||
# It can be viewed in multiple ways:
|
||||
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
|
||||
# - From the console, with the following commands:
|
||||
# docker pull bitwardenrs/web-vault:v2.12.0e
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
|
||||
#
|
||||
# docker pull bitwardenrs/web-vault:v2.15.1
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
|
||||
#
|
||||
# - To do the opposite, and get the tag from the hash, you can do:
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c
|
||||
FROM bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c as vault
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
|
||||
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# Musl build image for statically compiled binary
|
||||
FROM clux/muslrust:nightly-2019-12-19 as build
|
||||
FROM clux/muslrust:nightly-2020-03-09 as build
|
||||
|
||||
# set mysql backend
|
||||
ARG DB=mysql
|
||||
@@ -31,6 +31,7 @@ ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 TZ=UTC TERM=xterm-256color
|
||||
RUN rustup set profile minimal
|
||||
|
||||
ENV USER "root"
|
||||
ENV RUSTFLAGS='-C link-arg=-s'
|
||||
|
||||
# Install MySQL package
|
||||
RUN apt-get update && apt-get install -y \
|
||||
@@ -94,10 +95,11 @@ COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
COPY --from=build /app/target/x86_64-unknown-linux-musl/release/bitwarden_rs .
|
||||
|
||||
COPY docker/healthcheck.sh ./healthcheck.sh
|
||||
COPY docker/healthcheck.sh /healthcheck.sh
|
||||
COPY docker/start.sh /start.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
|
||||
|
||||
# Configures the startup!
|
||||
WORKDIR /
|
||||
CMD ["/bitwarden_rs"]
|
||||
CMD ["/start.sh"]
|
||||
|
@@ -10,12 +10,12 @@
|
||||
# It can be viewed in multiple ways:
|
||||
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
|
||||
# - From the console, with the following commands:
|
||||
# docker pull bitwardenrs/web-vault:v2.12.0e
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
|
||||
#
|
||||
# docker pull bitwardenrs/web-vault:v2.15.1
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
|
||||
#
|
||||
# - To do the opposite, and get the tag from the hash, you can do:
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c
|
||||
FROM bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c as vault
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
|
||||
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# We need to use the Rust build image, because
|
||||
@@ -92,10 +92,11 @@ COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
COPY --from=build app/target/release/bitwarden_rs .
|
||||
|
||||
COPY docker/healthcheck.sh ./healthcheck.sh
|
||||
COPY docker/healthcheck.sh /healthcheck.sh
|
||||
COPY docker/start.sh /start.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
|
||||
|
||||
# Configures the startup!
|
||||
WORKDIR /
|
||||
CMD ["/bitwarden_rs"]
|
||||
CMD ["/start.sh"]
|
||||
|
@@ -10,16 +10,16 @@
|
||||
# It can be viewed in multiple ways:
|
||||
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
|
||||
# - From the console, with the following commands:
|
||||
# docker pull bitwardenrs/web-vault:v2.12.0e
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
|
||||
#
|
||||
# docker pull bitwardenrs/web-vault:v2.15.1
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
|
||||
#
|
||||
# - To do the opposite, and get the tag from the hash, you can do:
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c
|
||||
FROM bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c as vault
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
|
||||
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# Musl build image for statically compiled binary
|
||||
FROM clux/muslrust:nightly-2019-12-19 as build
|
||||
FROM clux/muslrust:nightly-2020-03-09 as build
|
||||
|
||||
# set postgresql backend
|
||||
ARG DB=postgresql
|
||||
@@ -31,6 +31,7 @@ ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 TZ=UTC TERM=xterm-256color
|
||||
RUN rustup set profile minimal
|
||||
|
||||
ENV USER "root"
|
||||
ENV RUSTFLAGS='-C link-arg=-s'
|
||||
|
||||
# Install PostgreSQL package
|
||||
RUN apt-get update && apt-get install -y \
|
||||
@@ -94,10 +95,11 @@ COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
COPY --from=build /app/target/x86_64-unknown-linux-musl/release/bitwarden_rs .
|
||||
|
||||
COPY docker/healthcheck.sh ./healthcheck.sh
|
||||
COPY docker/healthcheck.sh /healthcheck.sh
|
||||
COPY docker/start.sh /start.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
|
||||
|
||||
# Configures the startup!
|
||||
WORKDIR /
|
||||
CMD ["/bitwarden_rs"]
|
||||
CMD ["/start.sh"]
|
||||
|
@@ -10,12 +10,12 @@
|
||||
# It can be viewed in multiple ways:
|
||||
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
|
||||
# - From the console, with the following commands:
|
||||
# docker pull bitwardenrs/web-vault:v2.12.0e
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
|
||||
#
|
||||
# docker pull bitwardenrs/web-vault:v2.15.1
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
|
||||
#
|
||||
# - To do the opposite, and get the tag from the hash, you can do:
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c
|
||||
FROM bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c as vault
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
|
||||
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# We need to use the Rust build image, because
|
||||
@@ -86,10 +86,11 @@ COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
COPY --from=build app/target/release/bitwarden_rs .
|
||||
|
||||
COPY docker/healthcheck.sh ./healthcheck.sh
|
||||
COPY docker/healthcheck.sh /healthcheck.sh
|
||||
COPY docker/start.sh /start.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
|
||||
|
||||
# Configures the startup!
|
||||
WORKDIR /
|
||||
CMD ["/bitwarden_rs"]
|
||||
CMD ["/start.sh"]
|
||||
|
@@ -10,16 +10,16 @@
|
||||
# It can be viewed in multiple ways:
|
||||
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
|
||||
# - From the console, with the following commands:
|
||||
# docker pull bitwardenrs/web-vault:v2.12.0e
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
|
||||
#
|
||||
# docker pull bitwardenrs/web-vault:v2.15.1
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
|
||||
#
|
||||
# - To do the opposite, and get the tag from the hash, you can do:
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c
|
||||
FROM bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c as vault
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
|
||||
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# Musl build image for statically compiled binary
|
||||
FROM clux/muslrust:nightly-2019-12-19 as build
|
||||
FROM clux/muslrust:nightly-2020-03-09 as build
|
||||
|
||||
# set sqlite as default for DB ARG for backward compatibility
|
||||
ARG DB=sqlite
|
||||
@@ -31,6 +31,7 @@ ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 TZ=UTC TERM=xterm-256color
|
||||
RUN rustup set profile minimal
|
||||
|
||||
ENV USER "root"
|
||||
ENV RUSTFLAGS='-C link-arg=-s'
|
||||
|
||||
# Creates a dummy project used to grab dependencies
|
||||
RUN USER=root cargo new --bin /app
|
||||
@@ -88,10 +89,11 @@ COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
COPY --from=build /app/target/x86_64-unknown-linux-musl/release/bitwarden_rs .
|
||||
|
||||
COPY docker/healthcheck.sh ./healthcheck.sh
|
||||
COPY docker/healthcheck.sh /healthcheck.sh
|
||||
COPY docker/start.sh /start.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
|
||||
|
||||
# Configures the startup!
|
||||
WORKDIR /
|
||||
CMD ["/bitwarden_rs"]
|
||||
CMD ["/start.sh"]
|
||||
|
@@ -10,12 +10,12 @@
|
||||
# It can be viewed in multiple ways:
|
||||
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
|
||||
# - From the console, with the following commands:
|
||||
# docker pull bitwardenrs/web-vault:v2.12.0e
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
|
||||
#
|
||||
# docker pull bitwardenrs/web-vault:v2.15.1
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
|
||||
#
|
||||
# - To do the opposite, and get the tag from the hash, you can do:
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c
|
||||
FROM bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c as vault
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
|
||||
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# We need to use the Rust build image, because
|
||||
@@ -124,10 +124,11 @@ COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
COPY --from=build /app/target/arm-unknown-linux-gnueabi/release/bitwarden_rs .
|
||||
|
||||
COPY docker/healthcheck.sh ./healthcheck.sh
|
||||
COPY docker/healthcheck.sh /healthcheck.sh
|
||||
COPY docker/start.sh /start.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
|
||||
|
||||
# Configures the startup!
|
||||
WORKDIR /
|
||||
CMD ["/bitwarden_rs"]
|
||||
CMD ["/start.sh"]
|
@@ -10,12 +10,12 @@
|
||||
# It can be viewed in multiple ways:
|
||||
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
|
||||
# - From the console, with the following commands:
|
||||
# docker pull bitwardenrs/web-vault:v2.12.0e
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
|
||||
#
|
||||
# docker pull bitwardenrs/web-vault:v2.15.1
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
|
||||
#
|
||||
# - To do the opposite, and get the tag from the hash, you can do:
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c
|
||||
FROM bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c as vault
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
|
||||
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# We need to use the Rust build image, because
|
||||
@@ -118,10 +118,11 @@ COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
COPY --from=build /app/target/arm-unknown-linux-gnueabi/release/bitwarden_rs .
|
||||
|
||||
COPY docker/healthcheck.sh ./healthcheck.sh
|
||||
COPY docker/healthcheck.sh /healthcheck.sh
|
||||
COPY docker/start.sh /start.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
|
||||
|
||||
# Configures the startup!
|
||||
WORKDIR /
|
||||
CMD ["/bitwarden_rs"]
|
||||
CMD ["/start.sh"]
|
@@ -10,12 +10,12 @@
|
||||
# It can be viewed in multiple ways:
|
||||
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
|
||||
# - From the console, with the following commands:
|
||||
# docker pull bitwardenrs/web-vault:v2.12.0e
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
|
||||
#
|
||||
# docker pull bitwardenrs/web-vault:v2.15.1
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
|
||||
#
|
||||
# - To do the opposite, and get the tag from the hash, you can do:
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c
|
||||
FROM bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c as vault
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
|
||||
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# We need to use the Rust build image, because
|
||||
@@ -123,10 +123,11 @@ COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
COPY --from=build /app/target/armv7-unknown-linux-gnueabihf/release/bitwarden_rs .
|
||||
|
||||
COPY docker/healthcheck.sh ./healthcheck.sh
|
||||
COPY docker/healthcheck.sh /healthcheck.sh
|
||||
COPY docker/start.sh /start.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
|
||||
|
||||
# Configures the startup!
|
||||
WORKDIR /
|
||||
CMD ["/bitwarden_rs"]
|
||||
CMD ["/start.sh"]
|
@@ -10,12 +10,12 @@
|
||||
# It can be viewed in multiple ways:
|
||||
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
|
||||
# - From the console, with the following commands:
|
||||
# docker pull bitwardenrs/web-vault:v2.12.0e
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
|
||||
#
|
||||
# docker pull bitwardenrs/web-vault:v2.15.1
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
|
||||
#
|
||||
# - To do the opposite, and get the tag from the hash, you can do:
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c
|
||||
FROM bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c as vault
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
|
||||
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# We need to use the Rust build image, because
|
||||
@@ -117,10 +117,11 @@ COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
COPY --from=build /app/target/armv7-unknown-linux-gnueabihf/release/bitwarden_rs .
|
||||
|
||||
COPY docker/healthcheck.sh ./healthcheck.sh
|
||||
COPY docker/healthcheck.sh /healthcheck.sh
|
||||
COPY docker/start.sh /start.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
|
||||
|
||||
# Configures the startup!
|
||||
WORKDIR /
|
||||
CMD ["/bitwarden_rs"]
|
||||
CMD ["/start.sh"]
|
@@ -10,12 +10,12 @@
|
||||
# It can be viewed in multiple ways:
|
||||
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
|
||||
# - From the console, with the following commands:
|
||||
# docker pull bitwardenrs/web-vault:v2.12.0e
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
|
||||
#
|
||||
# docker pull bitwardenrs/web-vault:v2.15.1
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
|
||||
#
|
||||
# - To do the opposite, and get the tag from the hash, you can do:
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c
|
||||
FROM bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c as vault
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
|
||||
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# We need to use the Rust build image, because
|
||||
@@ -124,10 +124,11 @@ COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
COPY --from=build /app/target/aarch64-unknown-linux-gnu/release/bitwarden_rs .
|
||||
|
||||
COPY docker/healthcheck.sh ./healthcheck.sh
|
||||
COPY docker/healthcheck.sh /healthcheck.sh
|
||||
COPY docker/start.sh /start.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
|
||||
|
||||
# Configures the startup!
|
||||
WORKDIR /
|
||||
CMD ["/bitwarden_rs"]
|
||||
CMD ["/start.sh"]
|
@@ -10,12 +10,12 @@
|
||||
# It can be viewed in multiple ways:
|
||||
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
|
||||
# - From the console, with the following commands:
|
||||
# docker pull bitwardenrs/web-vault:v2.12.0e
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
|
||||
#
|
||||
# docker pull bitwardenrs/web-vault:v2.15.1
|
||||
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
|
||||
#
|
||||
# - To do the opposite, and get the tag from the hash, you can do:
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c
|
||||
FROM bitwardenrs/web-vault@sha256:feb3f46d15738191b9043be4cdb1be2c0078ed411e7b7be73a2f4fcbca01e13c as vault
|
||||
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
|
||||
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# We need to use the Rust build image, because
|
||||
@@ -118,10 +118,11 @@ COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
COPY --from=build /app/target/aarch64-unknown-linux-gnu/release/bitwarden_rs .
|
||||
|
||||
COPY docker/healthcheck.sh ./healthcheck.sh
|
||||
COPY docker/healthcheck.sh /healthcheck.sh
|
||||
COPY docker/start.sh /start.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
|
||||
|
||||
# Configures the startup!
|
||||
WORKDIR /
|
||||
CMD ["/bitwarden_rs"]
|
||||
CMD ["/start.sh"]
|
59
docker/healthcheck.sh
Normal file → Executable file
59
docker/healthcheck.sh
Normal file → Executable file
@@ -1,8 +1,53 @@
|
||||
#!/usr/bin/env sh
|
||||
#!/bin/sh
|
||||
|
||||
if [ -z "$ROCKET_TLS"]
|
||||
then
|
||||
curl --fail http://localhost:${ROCKET_PORT:-"80"}/alive || exit 1
|
||||
else
|
||||
curl --insecure --fail https://localhost:${ROCKET_PORT:-"80"}/alive || exit 1
|
||||
fi
|
||||
# Use the value of the corresponding env var (if present),
|
||||
# or a default value otherwise.
|
||||
: ${DATA_FOLDER:="data"}
|
||||
: ${ROCKET_PORT:="80"}
|
||||
|
||||
CONFIG_FILE="${DATA_FOLDER}"/config.json
|
||||
|
||||
# Given a config key, return the corresponding config value from the
|
||||
# config file. If the key doesn't exist, return an empty string.
|
||||
get_config_val() {
|
||||
local key="$1"
|
||||
# Extract a line of the form:
|
||||
# "domain": "https://bw.example.com/path",
|
||||
grep "\"${key}\":" "${CONFIG_FILE}" |
|
||||
# To extract just the value (https://bw.example.com/path), delete:
|
||||
# (1) everything up to and including the first ':',
|
||||
# (2) whitespace and '"' from the front,
|
||||
# (3) ',' and '"' from the back.
|
||||
sed -e 's/[^:]\+://' -e 's/^[ "]\+//' -e 's/[,"]\+$//'
|
||||
}
|
||||
|
||||
# Extract the base path from a domain URL. For example:
|
||||
# - `` -> ``
|
||||
# - `https://bw.example.com` -> ``
|
||||
# - `https://bw.example.com/` -> ``
|
||||
# - `https://bw.example.com/path` -> `/path`
|
||||
# - `https://bw.example.com/multi/path` -> `/multi/path`
|
||||
get_base_path() {
|
||||
echo "$1" |
|
||||
# Delete:
|
||||
# (1) everything up to and including '://',
|
||||
# (2) everything up to '/',
|
||||
# (3) trailing '/' from the back.
|
||||
sed -e 's|.*://||' -e 's|[^/]\+||' -e 's|/*$||'
|
||||
}
|
||||
|
||||
# Read domain URL from config.json, if present.
|
||||
if [ -r "${CONFIG_FILE}" ]; then
|
||||
domain="$(get_config_val 'domain')"
|
||||
if [ -n "${domain}" ]; then
|
||||
# config.json 'domain' overrides the DOMAIN env var.
|
||||
DOMAIN="${domain}"
|
||||
fi
|
||||
fi
|
||||
|
||||
base_path="$(get_base_path "${DOMAIN}")"
|
||||
if [ -n "${ROCKET_TLS}" ]; then
|
||||
s='s'
|
||||
fi
|
||||
curl --insecure --fail --silent --show-error \
|
||||
"http${s}://localhost:${ROCKET_PORT}${base_path}/alive" || exit 1
|
||||
|
15
docker/start.sh
Executable file
15
docker/start.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ -r /etc/bitwarden_rs.sh ]; then
|
||||
. /etc/bitwarden_rs.sh
|
||||
fi
|
||||
|
||||
if [ -d /etc/bitwarden_rs.d ]; then
|
||||
for f in /etc/bitwarden_rs.d/*.sh; do
|
||||
if [ -r $f ]; then
|
||||
. $f
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
exec /bitwarden_rs "${@}"
|
20
hooks/README.md
Normal file
20
hooks/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
The hooks in this directory are used to create multi-arch images using Docker Hub automated builds.
|
||||
|
||||
Docker Hub hooks provide these predefined [environment variables](https://docs.docker.com/docker-hub/builds/advanced/#environment-variables-for-building-and-testing):
|
||||
|
||||
* `SOURCE_BRANCH`: the name of the branch or the tag that is currently being tested.
|
||||
* `SOURCE_COMMIT`: the SHA1 hash of the commit being tested.
|
||||
* `COMMIT_MSG`: the message from the commit being tested and built.
|
||||
* `DOCKER_REPO`: the name of the Docker repository being built.
|
||||
* `DOCKERFILE_PATH`: the dockerfile currently being built.
|
||||
* `DOCKER_TAG`: the Docker repository tag being built.
|
||||
* `IMAGE_NAME`: the name and tag of the Docker repository being built. (This variable is a combination of `DOCKER_REPO:DOCKER_TAG`.)
|
||||
|
||||
The current multi-arch image build relies on the original bitwarden_rs Dockerfiles, which use cross-compilation for architectures other than `amd64`, and don't yet support all arch/database/OS combinations. However, cross-compilation is much faster than QEMU-based builds (e.g., using `docker buildx`). This situation may need to be revisited at some point.
|
||||
|
||||
## References
|
||||
|
||||
* https://docs.docker.com/docker-hub/builds/advanced/
|
||||
* https://docs.docker.com/engine/reference/commandline/manifest/
|
||||
* https://www.docker.com/blog/multi-arch-build-and-images-the-simple-way/
|
||||
* https://success.docker.com/article/how-do-i-authenticate-with-the-v2-api
|
30
hooks/arches.sh
Normal file
30
hooks/arches.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
# The default Debian-based SQLite images support these arches.
|
||||
#
|
||||
# Other images (Alpine-based, or with other database backends) currently
|
||||
# support only a subset of these.
|
||||
arches=(
|
||||
amd64
|
||||
arm32v6
|
||||
arm32v7
|
||||
arm64v8
|
||||
)
|
||||
|
||||
case "${DOCKER_REPO}" in
|
||||
*-mysql)
|
||||
db=mysql
|
||||
arches=(amd64)
|
||||
;;
|
||||
*-postgresql)
|
||||
db=postgresql
|
||||
arches=(amd64)
|
||||
;;
|
||||
*)
|
||||
db=sqlite
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${DOCKER_TAG}" == *alpine ]]; then
|
||||
# The Alpine build currently only works for amd64.
|
||||
os_suffix=.alpine
|
||||
arches=(amd64)
|
||||
fi
|
14
hooks/build
Executable file
14
hooks/build
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo ">>> Building images..."
|
||||
|
||||
source ./hooks/arches.sh
|
||||
|
||||
set -ex
|
||||
|
||||
for arch in "${arches[@]}"; do
|
||||
docker build \
|
||||
-t "${DOCKER_REPO}:${DOCKER_TAG}-${arch}" \
|
||||
-f docker/${arch}/${db}/Dockerfile${os_suffix} \
|
||||
.
|
||||
done
|
112
hooks/push
Executable file
112
hooks/push
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo ">>> Pushing images..."
|
||||
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
|
||||
declare -A annotations=(
|
||||
[amd64]="--os linux --arch amd64"
|
||||
[arm32v6]="--os linux --arch arm --variant v6"
|
||||
[arm32v7]="--os linux --arch arm --variant v7"
|
||||
[arm64v8]="--os linux --arch arm64 --variant v8"
|
||||
)
|
||||
|
||||
source ./hooks/arches.sh
|
||||
|
||||
set -ex
|
||||
|
||||
declare -A images
|
||||
for arch in ${arches[@]}; do
|
||||
images[$arch]="${DOCKER_REPO}:${DOCKER_TAG}-${arch}"
|
||||
done
|
||||
|
||||
# Push the images that were just built; manifest list creation fails if the
|
||||
# images (manifests) referenced don't already exist in the Docker registry.
|
||||
for image in "${images[@]}"; do
|
||||
docker push "${image}"
|
||||
done
|
||||
|
||||
manifest_lists=("${DOCKER_REPO}:${DOCKER_TAG}")
|
||||
|
||||
# If the Docker tag starts with a version number, assume the latest release is
|
||||
# being pushed. Add an extra manifest (`latest` or `alpine`, as appropriate)
|
||||
# to make it easier for users to track the latest release.
|
||||
if [[ "${DOCKER_TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
||||
if [[ "${DOCKER_TAG}" == *alpine ]]; then
|
||||
manifest_lists+=(${DOCKER_REPO}:alpine)
|
||||
else
|
||||
manifest_lists+=(${DOCKER_REPO}:latest)
|
||||
|
||||
# Add an extra `latest-arm32v6` tag; Docker can't seem to properly
|
||||
# auto-select that image on Armv6 platforms like Raspberry Pi 1 and Zero
|
||||
# (https://github.com/moby/moby/issues/41017).
|
||||
#
|
||||
# TODO: Also add an `alpine-arm32v6` tag if multi-arch support for
|
||||
# Alpine-based bitwarden_rs images is implemented before this Docker
|
||||
# issue is fixed.
|
||||
docker tag "${DOCKER_REPO}:${DOCKER_TAG}-arm32v6" "${DOCKER_REPO}:latest-arm32v6"
|
||||
docker push "${DOCKER_REPO}:latest-arm32v6"
|
||||
fi
|
||||
fi
|
||||
|
||||
for manifest_list in "${manifest_lists[@]}"; do
|
||||
# Create the (multi-arch) manifest list of arch-specific images.
|
||||
docker manifest create ${manifest_list} ${images[@]}
|
||||
|
||||
# Make sure each image manifest is annotated with the correct arch info.
|
||||
# Docker does not auto-detect the arch of each cross-compiled image, so
|
||||
# everything would appear as `linux/amd64` otherwise.
|
||||
for arch in "${arches[@]}"; do
|
||||
docker manifest annotate ${annotations[$arch]} ${manifest_list} ${images[$arch]}
|
||||
done
|
||||
|
||||
# Push the manifest list.
|
||||
docker manifest push --purge ${manifest_list}
|
||||
done
|
||||
|
||||
# Avoid logging credentials and tokens.
|
||||
set +ex
|
||||
|
||||
# Delete the arch-specific tags, if credentials for doing so are available.
|
||||
# Note that `DOCKER_PASSWORD` must be the actual user password. Passing a JWT
|
||||
# obtained using a personal access token results in a 403 error with
|
||||
# {"detail": "access to the resource is forbidden with personal access token"}
|
||||
if [[ -z "${DOCKER_USERNAME}" || -z "${DOCKER_PASSWORD}" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Given a JSON input on stdin, extract the string value associated with the
|
||||
# specified key. This avoids an extra dependency on a tool like `jq`.
|
||||
extract() {
|
||||
local key="$1"
|
||||
# Extract "<key>":"<val>" (assumes key/val won't contain double quotes).
|
||||
# The colon may have whitespace on either side.
|
||||
grep -o "\"${key}\"[[:space:]]*:[[:space:]]*\"[^\"]\+\"" |
|
||||
# Extract just <val> by deleting the last '"', and then greedily deleting
|
||||
# everything up to '"'.
|
||||
sed -e 's/"$//' -e 's/.*"//'
|
||||
}
|
||||
|
||||
echo ">>> Getting API token..."
|
||||
jwt=$(curl -sS -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"${DOCKER_USERNAME}\",\"password\": \"${DOCKER_PASSWORD}\"}" \
|
||||
"https://hub.docker.com/v2/users/login" |
|
||||
extract 'token')
|
||||
|
||||
# Strip the registry portion from `index.docker.io/user/repo`.
|
||||
repo="${DOCKER_REPO#*/}"
|
||||
|
||||
for arch in ${arches[@]}; do
|
||||
# Don't delete the `arm32v6` tag; Docker can't seem to properly
|
||||
# auto-select that image on Armv6 platforms like Raspberry Pi 1 and Zero
|
||||
# (https://github.com/moby/moby/issues/41017).
|
||||
if [[ ${arch} == 'arm32v6' ]]; then
|
||||
continue
|
||||
fi
|
||||
tag="${DOCKER_TAG}-${arch}"
|
||||
echo ">>> Deleting '${repo}:${tag}'..."
|
||||
curl -sS -X DELETE \
|
||||
-H "Authorization: Bearer ${jwt}" \
|
||||
"https://hub.docker.com/v2/repositories/${repo}/tags/${tag}/"
|
||||
done
|
@@ -0,0 +1 @@
|
||||
|
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE ciphers
|
||||
ADD COLUMN
|
||||
deleted_at DATETIME;
|
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users_collections
|
||||
ADD COLUMN hide_passwords BOOLEAN NOT NULL DEFAULT FALSE;
|
@@ -0,0 +1 @@
|
||||
|
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE ciphers
|
||||
ADD COLUMN
|
||||
deleted_at TIMESTAMP;
|
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users_collections
|
||||
ADD COLUMN hide_passwords BOOLEAN NOT NULL DEFAULT FALSE;
|
@@ -0,0 +1 @@
|
||||
|
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE ciphers
|
||||
ADD COLUMN
|
||||
deleted_at DATETIME;
|
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users_collections
|
||||
ADD COLUMN hide_passwords BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
@@ -1 +1 @@
|
||||
nightly-2020-03-09
|
||||
nightly-2020-07-11
|
254
src/api/admin.rs
254
src/api/admin.rs
@@ -1,29 +1,35 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use std::process::Command;
|
||||
|
||||
use rocket::http::{Cookie, Cookies, SameSite};
|
||||
use rocket::request::{self, FlashMessage, Form, FromRequest, Request};
|
||||
use rocket::response::{content::Html, Flash, Redirect};
|
||||
use rocket::{Outcome, Route};
|
||||
use rocket::{
|
||||
http::{Cookie, Cookies, SameSite},
|
||||
request::{self, FlashMessage, Form, FromRequest, Request, Outcome},
|
||||
response::{content::Html, Flash, Redirect},
|
||||
Route,
|
||||
};
|
||||
use rocket_contrib::json::Json;
|
||||
|
||||
use crate::api::{ApiResult, EmptyResult, JsonResult};
|
||||
use crate::auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp};
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::db::{backup_database, models::*, DbConn};
|
||||
use crate::error::Error;
|
||||
use crate::mail;
|
||||
use crate::CONFIG;
|
||||
use crate::{
|
||||
api::{ApiResult, EmptyResult, JsonResult},
|
||||
auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp},
|
||||
config::ConfigBuilder,
|
||||
db::{backup_database, models::*, DbConn},
|
||||
error::{Error, MapResult},
|
||||
mail,
|
||||
util::get_display_size,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
if CONFIG.admin_token().is_none() && !CONFIG.disable_admin_token() {
|
||||
if !CONFIG.disable_admin_token() && !CONFIG.is_admin_token_set() {
|
||||
return routes![admin_disabled];
|
||||
}
|
||||
|
||||
routes![
|
||||
admin_login,
|
||||
get_users,
|
||||
get_users_json,
|
||||
post_admin_login,
|
||||
admin_page,
|
||||
invite_user,
|
||||
@@ -36,6 +42,9 @@ pub fn routes() -> Vec<Route> {
|
||||
delete_config,
|
||||
backup_db,
|
||||
test_smtp,
|
||||
users_overview,
|
||||
organizations_overview,
|
||||
diagnostics,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -51,12 +60,20 @@ const COOKIE_NAME: &str = "BWRS_ADMIN";
|
||||
const ADMIN_PATH: &str = "/admin";
|
||||
|
||||
const BASE_TEMPLATE: &str = "admin/base";
|
||||
const VERSION: Option<&str> = option_env!("GIT_VERSION");
|
||||
const VERSION: Option<&str> = option_env!("BWRS_VERSION");
|
||||
|
||||
fn admin_path() -> String {
|
||||
format!("{}{}", CONFIG.domain_path(), ADMIN_PATH)
|
||||
}
|
||||
|
||||
/// Used for `Location` response headers, which must specify an absolute URI
|
||||
/// (see https://tools.ietf.org/html/rfc2616#section-14.30).
|
||||
fn admin_url() -> String {
|
||||
// Don't use CONFIG.domain() directly, since the user may want to keep a
|
||||
// trailing slash there, particularly when running under a subpath.
|
||||
format!("{}{}{}", CONFIG.domain_origin(), CONFIG.domain_path(), ADMIN_PATH)
|
||||
}
|
||||
|
||||
#[get("/", rank = 2)]
|
||||
fn admin_login(flash: Option<FlashMessage>) -> ApiResult<Html<String>> {
|
||||
// If there is an error, show it
|
||||
@@ -81,7 +98,7 @@ fn post_admin_login(data: Form<LoginForm>, mut cookies: Cookies, ip: ClientIp) -
|
||||
if !_validate_token(&data.token) {
|
||||
error!("Invalid admin token. IP: {}", ip.ip);
|
||||
Err(Flash::error(
|
||||
Redirect::to(admin_path()),
|
||||
Redirect::to(admin_url()),
|
||||
"Invalid admin token, please try again.",
|
||||
))
|
||||
} else {
|
||||
@@ -97,7 +114,7 @@ fn post_admin_login(data: Form<LoginForm>, mut cookies: Cookies, ip: ClientIp) -
|
||||
.finish();
|
||||
|
||||
cookies.add(cookie);
|
||||
Ok(Redirect::to(admin_path()))
|
||||
Ok(Redirect::to(admin_url()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +129,9 @@ fn _validate_token(token: &str) -> bool {
|
||||
struct AdminTemplateData {
|
||||
page_content: String,
|
||||
version: Option<&'static str>,
|
||||
users: Vec<Value>,
|
||||
users: Option<Vec<Value>>,
|
||||
organizations: Option<Vec<Value>>,
|
||||
diagnostics: Option<Value>,
|
||||
config: Value,
|
||||
can_backup: bool,
|
||||
logged_in: bool,
|
||||
@@ -120,15 +139,59 @@ struct AdminTemplateData {
|
||||
}
|
||||
|
||||
impl AdminTemplateData {
|
||||
fn new(users: Vec<Value>) -> Self {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
page_content: String::from("admin/page"),
|
||||
page_content: String::from("admin/settings"),
|
||||
version: VERSION,
|
||||
users,
|
||||
config: CONFIG.prepare_json(),
|
||||
can_backup: *CAN_BACKUP,
|
||||
logged_in: true,
|
||||
urlpath: CONFIG.domain_path(),
|
||||
users: None,
|
||||
organizations: None,
|
||||
diagnostics: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn users(users: Vec<Value>) -> Self {
|
||||
Self {
|
||||
page_content: String::from("admin/users"),
|
||||
version: VERSION,
|
||||
users: Some(users),
|
||||
config: CONFIG.prepare_json(),
|
||||
can_backup: *CAN_BACKUP,
|
||||
logged_in: true,
|
||||
urlpath: CONFIG.domain_path(),
|
||||
organizations: None,
|
||||
diagnostics: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn organizations(organizations: Vec<Value>) -> Self {
|
||||
Self {
|
||||
page_content: String::from("admin/organizations"),
|
||||
version: VERSION,
|
||||
organizations: Some(organizations),
|
||||
config: CONFIG.prepare_json(),
|
||||
can_backup: *CAN_BACKUP,
|
||||
logged_in: true,
|
||||
urlpath: CONFIG.domain_path(),
|
||||
users: None,
|
||||
diagnostics: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostics(diagnostics: Value) -> Self {
|
||||
Self {
|
||||
page_content: String::from("admin/diagnostics"),
|
||||
version: VERSION,
|
||||
organizations: None,
|
||||
config: CONFIG.prepare_json(),
|
||||
can_backup: *CAN_BACKUP,
|
||||
logged_in: true,
|
||||
urlpath: CONFIG.domain_path(),
|
||||
users: None,
|
||||
diagnostics: Some(diagnostics),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,11 +201,8 @@ impl AdminTemplateData {
|
||||
}
|
||||
|
||||
#[get("/", rank = 1)]
|
||||
fn admin_page(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
|
||||
let users = User::get_all(&conn);
|
||||
let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect();
|
||||
|
||||
let text = AdminTemplateData::new(users_json).render()?;
|
||||
fn admin_page(_token: AdminToken, _conn: DbConn) -> ApiResult<Html<String>> {
|
||||
let text = AdminTemplateData::new().render()?;
|
||||
Ok(Html(text))
|
||||
}
|
||||
|
||||
@@ -174,10 +234,9 @@ fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> Empt
|
||||
#[post("/test/smtp", data = "<data>")]
|
||||
fn test_smtp(data: Json<InviteData>, _token: AdminToken) -> EmptyResult {
|
||||
let data: InviteData = data.into_inner();
|
||||
let email = data.email.clone();
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
mail::send_test(&email)
|
||||
mail::send_test(&data.email)
|
||||
} else {
|
||||
err!("Mail is not enabled")
|
||||
}
|
||||
@@ -186,34 +245,42 @@ fn test_smtp(data: Json<InviteData>, _token: AdminToken) -> EmptyResult {
|
||||
#[get("/logout")]
|
||||
fn logout(mut cookies: Cookies) -> Result<Redirect, ()> {
|
||||
cookies.remove(Cookie::named(COOKIE_NAME));
|
||||
Ok(Redirect::to(admin_path()))
|
||||
Ok(Redirect::to(admin_url()))
|
||||
}
|
||||
|
||||
#[get("/users")]
|
||||
fn get_users(_token: AdminToken, conn: DbConn) -> JsonResult {
|
||||
fn get_users_json(_token: AdminToken, conn: DbConn) -> JsonResult {
|
||||
let users = User::get_all(&conn);
|
||||
let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect();
|
||||
|
||||
Ok(Json(Value::Array(users_json)))
|
||||
}
|
||||
|
||||
#[get("/users/overview")]
|
||||
fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
|
||||
let users = User::get_all(&conn);
|
||||
let users_json: Vec<Value> = users.iter()
|
||||
.map(|u| {
|
||||
let mut usr = u.to_json(&conn);
|
||||
usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &conn));
|
||||
usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &conn));
|
||||
usr["attachment_size"] = json!(get_display_size(Attachment::size_by_user(&u.uuid, &conn) as i32));
|
||||
usr
|
||||
}).collect();
|
||||
|
||||
let text = AdminTemplateData::users(users_json).render()?;
|
||||
Ok(Html(text))
|
||||
}
|
||||
|
||||
#[post("/users/<uuid>/delete")]
|
||||
fn delete_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||
let user = match User::find_by_uuid(&uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("User doesn't exist"),
|
||||
};
|
||||
|
||||
let user = User::find_by_uuid(&uuid, &conn).map_res("User doesn't exist")?;
|
||||
user.delete(&conn)
|
||||
}
|
||||
|
||||
#[post("/users/<uuid>/deauth")]
|
||||
fn deauth_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||
let mut user = match User::find_by_uuid(&uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("User doesn't exist"),
|
||||
};
|
||||
|
||||
let mut user = User::find_by_uuid(&uuid, &conn).map_res("User doesn't exist")?;
|
||||
Device::delete_all_by_user(&user.uuid, &conn)?;
|
||||
user.reset_security_stamp();
|
||||
|
||||
@@ -222,11 +289,7 @@ fn deauth_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||
|
||||
#[post("/users/<uuid>/remove-2fa")]
|
||||
fn remove_2fa(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||
let mut user = match User::find_by_uuid(&uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("User doesn't exist"),
|
||||
};
|
||||
|
||||
let mut user = User::find_by_uuid(&uuid, &conn).map_res("User doesn't exist")?;
|
||||
TwoFactor::delete_all_by_user(&user.uuid, &conn)?;
|
||||
user.totp_recover = None;
|
||||
user.save(&conn)
|
||||
@@ -237,6 +300,109 @@ fn update_revision_users(_token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||
User::update_all_revisions(&conn)
|
||||
}
|
||||
|
||||
#[get("/organizations/overview")]
|
||||
fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
|
||||
let organizations = Organization::get_all(&conn);
|
||||
let organizations_json: Vec<Value> = organizations.iter().map(|o| {
|
||||
let mut org = o.to_json();
|
||||
org["user_count"] = json!(UserOrganization::count_by_org(&o.uuid, &conn));
|
||||
org["cipher_count"] = json!(Cipher::count_by_org(&o.uuid, &conn));
|
||||
org["attachment_count"] = json!(Attachment::count_by_org(&o.uuid, &conn));
|
||||
org["attachment_size"] = json!(get_display_size(Attachment::size_by_org(&o.uuid, &conn) as i32));
|
||||
org
|
||||
}).collect();
|
||||
|
||||
let text = AdminTemplateData::organizations(organizations_json).render()?;
|
||||
Ok(Html(text))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WebVaultVersion {
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitRelease {
|
||||
tag_name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitCommit {
|
||||
sha: String,
|
||||
}
|
||||
|
||||
fn get_github_api<T: DeserializeOwned>(url: &str) -> Result<T, Error> {
|
||||
use reqwest::{blocking::Client, header::USER_AGENT};
|
||||
use std::time::Duration;
|
||||
let github_api = Client::builder().build()?;
|
||||
|
||||
Ok(
|
||||
github_api.get(url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.header(USER_AGENT, "Bitwarden_RS")
|
||||
.send()?
|
||||
.error_for_status()?
|
||||
.json::<T>()?
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/diagnostics")]
|
||||
fn diagnostics(_token: AdminToken, _conn: DbConn) -> ApiResult<Html<String>> {
|
||||
use std::net::ToSocketAddrs;
|
||||
use chrono::prelude::*;
|
||||
use crate::util::read_file_string;
|
||||
|
||||
let vault_version_path = format!("{}/{}", CONFIG.web_vault_folder(), "version.json");
|
||||
let vault_version_str = read_file_string(&vault_version_path)?;
|
||||
let web_vault_version: WebVaultVersion = serde_json::from_str(&vault_version_str)?;
|
||||
|
||||
let github_ips = ("github.com", 0).to_socket_addrs().map(|mut i| i.next());
|
||||
let (dns_resolved, dns_ok) = match github_ips {
|
||||
Ok(Some(a)) => (a.ip().to_string(), true),
|
||||
_ => ("Could not resolve domain name.".to_string(), false),
|
||||
};
|
||||
|
||||
// If the DNS Check failed, do not even attempt to check for new versions since we were not able to resolve github.com
|
||||
let (latest_release, latest_commit, latest_web_build) = if dns_ok {
|
||||
(
|
||||
match get_github_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bitwarden_rs/releases/latest") {
|
||||
Ok(r) => r.tag_name,
|
||||
_ => "-".to_string()
|
||||
},
|
||||
match get_github_api::<GitCommit>("https://api.github.com/repos/dani-garcia/bitwarden_rs/commits/master") {
|
||||
Ok(mut c) => {
|
||||
c.sha.truncate(8);
|
||||
c.sha
|
||||
},
|
||||
_ => "-".to_string()
|
||||
},
|
||||
match get_github_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest") {
|
||||
Ok(r) => r.tag_name.trim_start_matches('v').to_string(),
|
||||
_ => "-".to_string()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
("-".to_string(), "-".to_string(), "-".to_string())
|
||||
};
|
||||
|
||||
// Run the date check as the last item right before filling the json.
|
||||
// This should ensure that the time difference between the browser and the server is as minimal as possible.
|
||||
let dt = Utc::now();
|
||||
let server_time = dt.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
let diagnostics_json = json!({
|
||||
"dns_resolved": dns_resolved,
|
||||
"server_time": server_time,
|
||||
"web_vault_version": web_vault_version.version,
|
||||
"latest_release": latest_release,
|
||||
"latest_commit": latest_commit,
|
||||
"latest_web_build": latest_web_build,
|
||||
});
|
||||
|
||||
let text = AdminTemplateData::diagnostics(diagnostics_json).render()?;
|
||||
Ok(Html(text))
|
||||
}
|
||||
|
||||
#[post("/config", data = "<data>")]
|
||||
fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
|
||||
let data: ConfigBuilder = data.into_inner();
|
||||
|
@@ -1,19 +1,15 @@
|
||||
use chrono::Utc;
|
||||
use rocket_contrib::json::Json;
|
||||
|
||||
use crate::db::models::*;
|
||||
use crate::db::DbConn;
|
||||
use crate::{
|
||||
api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType},
|
||||
auth::{decode_delete, decode_invite, decode_verify_email, Headers},
|
||||
crypto,
|
||||
db::{models::*, DbConn},
|
||||
mail, CONFIG,
|
||||
};
|
||||
|
||||
use crate::api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType};
|
||||
use crate::auth::{decode_delete, decode_invite, decode_verify_email, Headers};
|
||||
use crate::crypto;
|
||||
use crate::mail;
|
||||
|
||||
use crate::CONFIG;
|
||||
|
||||
use rocket::Route;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
pub fn routes() -> Vec<rocket::Route> {
|
||||
routes![
|
||||
register,
|
||||
profile,
|
||||
@@ -68,7 +64,7 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
|
||||
let mut user = match User::find_by_mail(&data.Email, &conn) {
|
||||
Some(user) => {
|
||||
if !user.password_hash.is_empty() {
|
||||
if CONFIG.signups_allowed() {
|
||||
if CONFIG.is_signup_allowed(&data.Email) {
|
||||
err!("User already exists")
|
||||
} else {
|
||||
err!("Registration not allowed or user already exists")
|
||||
@@ -89,14 +85,17 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
|
||||
}
|
||||
|
||||
user
|
||||
} else if CONFIG.signups_allowed() {
|
||||
} else if CONFIG.is_signup_allowed(&data.Email) {
|
||||
err!("Account with this email already exists")
|
||||
} else {
|
||||
err!("Registration not allowed or user already exists")
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if CONFIG.signups_allowed() || Invitation::take(&data.Email, &conn) || CONFIG.can_signup_user(&data.Email) {
|
||||
// Order is important here; the invitation check must come first
|
||||
// because the bitwarden_rs admin can invite anyone, regardless
|
||||
// of other signup restrictions.
|
||||
if Invitation::take(&data.Email, &conn) || CONFIG.is_signup_allowed(&data.Email) {
|
||||
User::new(data.Email.clone())
|
||||
} else {
|
||||
err!("Registration not allowed or user already exists")
|
||||
@@ -207,7 +206,12 @@ fn post_keys(data: JsonUpcase<KeysData>, headers: Headers, conn: DbConn) -> Json
|
||||
user.public_key = Some(data.PublicKey);
|
||||
|
||||
user.save(&conn)?;
|
||||
Ok(Json(user.to_json(&conn)))
|
||||
|
||||
Ok(Json(json!({
|
||||
"PrivateKey": user.private_key,
|
||||
"PublicKey": user.public_key,
|
||||
"Object":"keys"
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -371,8 +375,8 @@ fn post_email_token(data: JsonUpcase<EmailTokenData>, headers: Headers, conn: Db
|
||||
err!("Email already in use");
|
||||
}
|
||||
|
||||
if !CONFIG.signups_allowed() && !CONFIG.can_signup_user(&data.NewEmail) {
|
||||
err!("Email cannot be changed to this address");
|
||||
if !CONFIG.is_email_domain_allowed(&data.NewEmail) {
|
||||
err!("Email domain not allowed");
|
||||
}
|
||||
|
||||
let token = crypto::generate_token(6)?;
|
||||
|
@@ -1,26 +1,20 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use rocket::http::ContentType;
|
||||
use rocket::{request::Form, Data, Route};
|
||||
|
||||
use rocket::{http::ContentType, request::Form, Data, Route};
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json::Value;
|
||||
|
||||
use multipart::server::save::SavedData;
|
||||
use multipart::server::{Multipart, SaveResult};
|
||||
|
||||
use data_encoding::HEXLOWER;
|
||||
use multipart::server::{save::SavedData, Multipart, SaveResult};
|
||||
|
||||
use crate::db::models::*;
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::crypto;
|
||||
|
||||
use crate::api::{self, EmptyResult, JsonResult, JsonUpcase, Notify, PasswordData, UpdateType};
|
||||
use crate::auth::Headers;
|
||||
|
||||
use crate::CONFIG;
|
||||
use crate::{
|
||||
api::{self, EmptyResult, JsonResult, JsonUpcase, Notify, PasswordData, UpdateType},
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{models::*, DbConn},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![
|
||||
@@ -49,10 +43,16 @@ pub fn routes() -> Vec<Route> {
|
||||
put_cipher,
|
||||
delete_cipher_post,
|
||||
delete_cipher_post_admin,
|
||||
delete_cipher_put,
|
||||
delete_cipher_put_admin,
|
||||
delete_cipher,
|
||||
delete_cipher_admin,
|
||||
delete_cipher_selected,
|
||||
delete_cipher_selected_post,
|
||||
delete_cipher_selected_put,
|
||||
restore_cipher_put,
|
||||
restore_cipher_put_admin,
|
||||
restore_cipher_selected,
|
||||
delete_all,
|
||||
move_cipher_selected,
|
||||
move_cipher_selected_put,
|
||||
@@ -268,7 +268,10 @@ pub fn update_cipher_from_data(
|
||||
};
|
||||
|
||||
if saved_att.cipher_uuid != cipher.uuid {
|
||||
err!("Attachment is not owned by the cipher")
|
||||
// Warn and break here since cloning ciphers provides attachment data but will not be cloned.
|
||||
// If we error out here it will break the whole cloning and causes empty ciphers to appear.
|
||||
warn!("Attachment is not owned by the cipher");
|
||||
break;
|
||||
}
|
||||
|
||||
saved_att.akey = Some(attachment.Key);
|
||||
@@ -608,9 +611,8 @@ fn share_cipher_by_uuid(
|
||||
match data.Cipher.OrganizationId.clone() {
|
||||
// If we don't get an organization ID, we don't do anything
|
||||
// No error because this is used when using the Clone functionality
|
||||
None => {},
|
||||
None => {}
|
||||
Some(organization_uuid) => {
|
||||
|
||||
for uuid in &data.CollectionIds {
|
||||
match Collection::find_by_uuid_and_org(uuid, &organization_uuid, &conn) {
|
||||
None => err!("Invalid collection ID provided"),
|
||||
@@ -665,8 +667,8 @@ fn post_attachment(
|
||||
let size_limit = if let Some(ref user_uuid) = cipher.user_uuid {
|
||||
match CONFIG.user_attachment_limit() {
|
||||
Some(0) => err_discard!("Attachments are disabled", data),
|
||||
Some(limit) => {
|
||||
let left = limit - Attachment::size_by_user(user_uuid, &conn);
|
||||
Some(limit_kb) => {
|
||||
let left = (limit_kb * 1024) - Attachment::size_by_user(user_uuid, &conn);
|
||||
if left <= 0 {
|
||||
err_discard!("Attachment size limit reached! Delete some files to open space", data)
|
||||
}
|
||||
@@ -677,8 +679,8 @@ fn post_attachment(
|
||||
} else if let Some(ref org_uuid) = cipher.organization_uuid {
|
||||
match CONFIG.org_attachment_limit() {
|
||||
Some(0) => err_discard!("Attachments are disabled", data),
|
||||
Some(limit) => {
|
||||
let left = limit - Attachment::size_by_org(org_uuid, &conn);
|
||||
Some(limit_kb) => {
|
||||
let left = (limit_kb * 1024) - Attachment::size_by_org(org_uuid, &conn);
|
||||
if left <= 0 {
|
||||
err_discard!("Attachment size limit reached! Delete some files to open space", data)
|
||||
}
|
||||
@@ -761,11 +763,7 @@ fn post_attachment_admin(
|
||||
post_attachment(uuid, data, content_type, headers, conn, nt)
|
||||
}
|
||||
|
||||
#[post(
|
||||
"/ciphers/<uuid>/attachment/<attachment_id>/share",
|
||||
format = "multipart/form-data",
|
||||
data = "<data>"
|
||||
)]
|
||||
#[post("/ciphers/<uuid>/attachment/<attachment_id>/share", format = "multipart/form-data", data = "<data>")]
|
||||
fn post_attachment_share(
|
||||
uuid: String,
|
||||
attachment_id: String,
|
||||
@@ -819,48 +817,62 @@ fn delete_attachment_admin(
|
||||
|
||||
#[post("/ciphers/<uuid>/delete")]
|
||||
fn delete_cipher_post(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
|
||||
_delete_cipher_by_uuid(&uuid, &headers, &conn, &nt)
|
||||
_delete_cipher_by_uuid(&uuid, &headers, &conn, false, &nt)
|
||||
}
|
||||
|
||||
#[post("/ciphers/<uuid>/delete-admin")]
|
||||
fn delete_cipher_post_admin(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
|
||||
_delete_cipher_by_uuid(&uuid, &headers, &conn, &nt)
|
||||
_delete_cipher_by_uuid(&uuid, &headers, &conn, false, &nt)
|
||||
}
|
||||
|
||||
#[put("/ciphers/<uuid>/delete")]
|
||||
fn delete_cipher_put(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
|
||||
_delete_cipher_by_uuid(&uuid, &headers, &conn, true, &nt)
|
||||
}
|
||||
|
||||
#[put("/ciphers/<uuid>/delete-admin")]
|
||||
fn delete_cipher_put_admin(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
|
||||
_delete_cipher_by_uuid(&uuid, &headers, &conn, true, &nt)
|
||||
}
|
||||
|
||||
#[delete("/ciphers/<uuid>")]
|
||||
fn delete_cipher(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
|
||||
_delete_cipher_by_uuid(&uuid, &headers, &conn, &nt)
|
||||
_delete_cipher_by_uuid(&uuid, &headers, &conn, false, &nt)
|
||||
}
|
||||
|
||||
#[delete("/ciphers/<uuid>/admin")]
|
||||
fn delete_cipher_admin(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
|
||||
_delete_cipher_by_uuid(&uuid, &headers, &conn, &nt)
|
||||
_delete_cipher_by_uuid(&uuid, &headers, &conn, false, &nt)
|
||||
}
|
||||
|
||||
#[delete("/ciphers", data = "<data>")]
|
||||
fn delete_cipher_selected(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
|
||||
let data: Value = data.into_inner().data;
|
||||
|
||||
let uuids = match data.get("Ids") {
|
||||
Some(ids) => match ids.as_array() {
|
||||
Some(ids) => ids.iter().filter_map(Value::as_str),
|
||||
None => err!("Posted ids field is not an array"),
|
||||
},
|
||||
None => err!("Request missing ids field"),
|
||||
};
|
||||
|
||||
for uuid in uuids {
|
||||
if let error @ Err(_) = _delete_cipher_by_uuid(uuid, &headers, &conn, &nt) {
|
||||
return error;
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
_delete_multiple_ciphers(data, headers, conn, false, nt)
|
||||
}
|
||||
|
||||
#[post("/ciphers/delete", data = "<data>")]
|
||||
fn delete_cipher_selected_post(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
|
||||
delete_cipher_selected(data, headers, conn, nt)
|
||||
_delete_multiple_ciphers(data, headers, conn, false, nt)
|
||||
}
|
||||
|
||||
#[put("/ciphers/delete", data = "<data>")]
|
||||
fn delete_cipher_selected_put(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
|
||||
_delete_multiple_ciphers(data, headers, conn, true, nt)
|
||||
}
|
||||
|
||||
#[put("/ciphers/<uuid>/restore")]
|
||||
fn restore_cipher_put(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
|
||||
_restore_cipher_by_uuid(&uuid, &headers, &conn, &nt)
|
||||
}
|
||||
|
||||
#[put("/ciphers/<uuid>/restore-admin")]
|
||||
fn restore_cipher_put_admin(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
|
||||
_restore_cipher_by_uuid(&uuid, &headers, &conn, &nt)
|
||||
}
|
||||
|
||||
#[put("/ciphers/restore", data = "<data>")]
|
||||
fn restore_cipher_selected(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
|
||||
_restore_multiple_ciphers(data, headers, conn, nt)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -974,8 +986,8 @@ fn delete_all(
|
||||
}
|
||||
}
|
||||
|
||||
fn _delete_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &DbConn, nt: &Notify) -> EmptyResult {
|
||||
let cipher = match Cipher::find_by_uuid(&uuid, &conn) {
|
||||
fn _delete_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &DbConn, soft_delete: bool, nt: &Notify) -> EmptyResult {
|
||||
let mut cipher = match Cipher::find_by_uuid(&uuid, &conn) {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
};
|
||||
@@ -984,8 +996,72 @@ fn _delete_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &DbConn, nt: &Not
|
||||
err!("Cipher can't be deleted by user")
|
||||
}
|
||||
|
||||
cipher.delete(&conn)?;
|
||||
nt.send_cipher_update(UpdateType::CipherDelete, &cipher, &cipher.update_users_revision(&conn));
|
||||
if soft_delete {
|
||||
cipher.deleted_at = Some(chrono::Utc::now().naive_utc());
|
||||
cipher.save(&conn)?;
|
||||
nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(&conn));
|
||||
} else {
|
||||
cipher.delete(&conn)?;
|
||||
nt.send_cipher_update(UpdateType::CipherDelete, &cipher, &cipher.update_users_revision(&conn));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn _delete_multiple_ciphers(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, soft_delete: bool, nt: Notify) -> EmptyResult {
|
||||
let data: Value = data.into_inner().data;
|
||||
|
||||
let uuids = match data.get("Ids") {
|
||||
Some(ids) => match ids.as_array() {
|
||||
Some(ids) => ids.iter().filter_map(Value::as_str),
|
||||
None => err!("Posted ids field is not an array"),
|
||||
},
|
||||
None => err!("Request missing ids field"),
|
||||
};
|
||||
|
||||
for uuid in uuids {
|
||||
if let error @ Err(_) = _delete_cipher_by_uuid(uuid, &headers, &conn, soft_delete, &nt) {
|
||||
return error;
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn _restore_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &DbConn, nt: &Notify) -> EmptyResult {
|
||||
let mut cipher = match Cipher::find_by_uuid(&uuid, &conn) {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
};
|
||||
|
||||
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn) {
|
||||
err!("Cipher can't be restored by user")
|
||||
}
|
||||
|
||||
cipher.deleted_at = None;
|
||||
cipher.save(&conn)?;
|
||||
|
||||
nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(&conn));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn _restore_multiple_ciphers(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
|
||||
let data: Value = data.into_inner().data;
|
||||
|
||||
let uuids = match data.get("Ids") {
|
||||
Some(ids) => match ids.as_array() {
|
||||
Some(ids) => ids.iter().filter_map(Value::as_str),
|
||||
None => err!("Posted ids field is not an array"),
|
||||
},
|
||||
None => err!("Request missing ids field"),
|
||||
};
|
||||
|
||||
for uuid in uuids {
|
||||
if let error @ Err(_) = _restore_cipher_by_uuid(uuid, &headers, &conn, &nt) {
|
||||
return error;
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@@ -1,15 +1,13 @@
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::db::models::*;
|
||||
use crate::db::DbConn;
|
||||
use crate::{
|
||||
api::{EmptyResult, JsonResult, JsonUpcase, Notify, UpdateType},
|
||||
auth::Headers,
|
||||
db::{models::*, DbConn},
|
||||
};
|
||||
|
||||
use crate::api::{EmptyResult, JsonResult, JsonUpcase, Notify, UpdateType};
|
||||
use crate::auth::Headers;
|
||||
|
||||
use rocket::Route;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
pub fn routes() -> Vec<rocket::Route> {
|
||||
routes![
|
||||
get_folders,
|
||||
get_folder,
|
||||
@@ -50,7 +48,6 @@ fn get_folder(uuid: String, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
|
||||
pub struct FolderData {
|
||||
pub Name: String,
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@ mod accounts;
|
||||
mod ciphers;
|
||||
mod folders;
|
||||
mod organizations;
|
||||
pub(crate) mod two_factor;
|
||||
pub mod two_factor;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
let mut mod_routes = routes![
|
||||
@@ -29,14 +29,15 @@ pub fn routes() -> Vec<Route> {
|
||||
// Move this somewhere else
|
||||
//
|
||||
use rocket::Route;
|
||||
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::api::{EmptyResult, JsonResult, JsonUpcase};
|
||||
use crate::auth::Headers;
|
||||
use crate::db::DbConn;
|
||||
use crate::error::Error;
|
||||
use crate::{
|
||||
api::{EmptyResult, JsonResult, JsonUpcase},
|
||||
auth::Headers,
|
||||
db::DbConn,
|
||||
error::Error,
|
||||
};
|
||||
|
||||
#[put("/devices/identifier/<uuid>/clear-token")]
|
||||
fn clear_device_token(uuid: String) -> EmptyResult {
|
||||
@@ -146,7 +147,7 @@ fn hibp_breach(username: String) -> JsonResult {
|
||||
username
|
||||
);
|
||||
|
||||
use reqwest::{header::USER_AGENT, blocking::Client};
|
||||
use reqwest::{blocking::Client, header::USER_AGENT};
|
||||
|
||||
if let Some(api_key) = crate::CONFIG.hibp_api_key() {
|
||||
let hibp_client = Client::builder().build()?;
|
||||
|
@@ -1,17 +1,14 @@
|
||||
use rocket::request::Form;
|
||||
use rocket::Route;
|
||||
use num_traits::FromPrimitive;
|
||||
use rocket::{request::Form, Route};
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json::Value;
|
||||
use num_traits::FromPrimitive;
|
||||
|
||||
use crate::api::{
|
||||
EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, Notify, NumberOrString, PasswordData, UpdateType,
|
||||
use crate::{
|
||||
api::{EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, Notify, NumberOrString, PasswordData, UpdateType},
|
||||
auth::{decode_invite, AdminHeaders, Headers, OwnerHeaders},
|
||||
db::{models::*, DbConn},
|
||||
mail, CONFIG,
|
||||
};
|
||||
use crate::auth::{decode_invite, AdminHeaders, Headers, OwnerHeaders};
|
||||
use crate::db::models::*;
|
||||
use crate::db::DbConn;
|
||||
use crate::mail;
|
||||
use crate::CONFIG;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![
|
||||
@@ -374,7 +371,7 @@ fn get_collection_users(org_id: String, coll_id: String, _headers: AdminHeaders,
|
||||
.map(|col_user| {
|
||||
UserOrganization::find_by_user_and_org(&col_user.user_uuid, &org_id, &conn)
|
||||
.unwrap()
|
||||
.to_json_collection_user_details(col_user.read_only)
|
||||
.to_json_user_access_restrictions(&col_user)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -408,7 +405,9 @@ fn put_collection_users(
|
||||
continue;
|
||||
}
|
||||
|
||||
CollectionUser::save(&user.user_uuid, &coll_id, d.ReadOnly, &conn)?;
|
||||
CollectionUser::save(&user.user_uuid, &coll_id,
|
||||
d.ReadOnly, d.HidePasswords,
|
||||
&conn)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -452,6 +451,7 @@ fn get_org_users(org_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonRe
|
||||
struct CollectionData {
|
||||
Id: String,
|
||||
ReadOnly: bool,
|
||||
HidePasswords: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -485,7 +485,11 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
|
||||
let user = match User::find_by_mail(&email, &conn) {
|
||||
None => {
|
||||
if !CONFIG.invitations_allowed() {
|
||||
err!(format!("User email does not exist: {}", email))
|
||||
err!(format!("User does not exist: {}", email))
|
||||
}
|
||||
|
||||
if !CONFIG.is_email_domain_allowed(&email) {
|
||||
err!("Email domain not eligible for invitations")
|
||||
}
|
||||
|
||||
if !CONFIG.mail_enabled() {
|
||||
@@ -519,7 +523,9 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
|
||||
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
|
||||
None => err!("Collection not found in Organization"),
|
||||
Some(collection) => {
|
||||
CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, &conn)?;
|
||||
CollectionUser::save(&user.uuid, &collection.uuid,
|
||||
col.ReadOnly, col.HidePasswords,
|
||||
&conn)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -774,7 +780,9 @@ fn edit_user(
|
||||
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
|
||||
None => err!("Collection not found in Organization"),
|
||||
Some(collection) => {
|
||||
CollectionUser::save(&user_to_edit.user_uuid, &collection.uuid, col.ReadOnly, &conn)?;
|
||||
CollectionUser::save(&user_to_edit.user_uuid, &collection.uuid,
|
||||
col.ReadOnly, col.HidePasswords,
|
||||
&conn)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -924,7 +932,7 @@ fn list_policies_token(org_id: String, token: String, conn: DbConn) -> JsonResul
|
||||
if invite_org_id != org_id {
|
||||
err!("Token doesn't match request organization");
|
||||
}
|
||||
|
||||
|
||||
// TODO: We receive the invite token as ?token=<>, validate it contains the org id
|
||||
let policies = OrgPolicy::find_by_org(&org_id, &conn);
|
||||
let policies_json: Vec<Value> = policies.iter().map(OrgPolicy::to_json).collect();
|
||||
@@ -978,4 +986,4 @@ fn put_policy(org_id: String, pol_type: i32, data: Json<PolicyData>, _headers: A
|
||||
policy.save(&conn)?;
|
||||
|
||||
Ok(Json(policy.to_json()))
|
||||
}
|
||||
}
|
||||
|
@@ -2,13 +2,16 @@ use data_encoding::BASE32;
|
||||
use rocket::Route;
|
||||
use rocket_contrib::json::Json;
|
||||
|
||||
use crate::api::core::two_factor::_generate_recover_code;
|
||||
use crate::api::{EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData};
|
||||
use crate::auth::Headers;
|
||||
use crate::crypto;
|
||||
use crate::db::{
|
||||
models::{TwoFactor, TwoFactorType},
|
||||
DbConn,
|
||||
use crate::{
|
||||
api::{
|
||||
core::two_factor::_generate_recover_code, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData,
|
||||
},
|
||||
auth::{ClientIp, Headers},
|
||||
crypto,
|
||||
db::{
|
||||
models::{TwoFactor, TwoFactorType},
|
||||
DbConn,
|
||||
},
|
||||
};
|
||||
|
||||
pub use crate::config::CONFIG;
|
||||
@@ -20,6 +23,7 @@ pub fn routes() -> Vec<Route> {
|
||||
activate_authenticator_put,
|
||||
]
|
||||
}
|
||||
|
||||
#[post("/two-factor/get-authenticator", data = "<data>")]
|
||||
fn generate_authenticator(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let data: PasswordData = data.into_inner().data;
|
||||
@@ -53,7 +57,12 @@ struct EnableAuthenticatorData {
|
||||
}
|
||||
|
||||
#[post("/two-factor/authenticator", data = "<data>")]
|
||||
fn activate_authenticator(data: JsonUpcase<EnableAuthenticatorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
fn activate_authenticator(
|
||||
data: JsonUpcase<EnableAuthenticatorData>,
|
||||
headers: Headers,
|
||||
ip: ClientIp,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
let data: EnableAuthenticatorData = data.into_inner().data;
|
||||
let password_hash = data.MasterPasswordHash;
|
||||
let key = data.Key;
|
||||
@@ -76,7 +85,7 @@ fn activate_authenticator(data: JsonUpcase<EnableAuthenticatorData>, headers: He
|
||||
}
|
||||
|
||||
// Validate the token provided with the key, and save new twofactor
|
||||
validate_totp_code(&user.uuid, token, &key.to_uppercase(), &conn)?;
|
||||
validate_totp_code(&user.uuid, token, &key.to_uppercase(), &ip, &conn)?;
|
||||
|
||||
_generate_recover_code(&mut user, &conn);
|
||||
|
||||
@@ -88,20 +97,31 @@ fn activate_authenticator(data: JsonUpcase<EnableAuthenticatorData>, headers: He
|
||||
}
|
||||
|
||||
#[put("/two-factor/authenticator", data = "<data>")]
|
||||
fn activate_authenticator_put(data: JsonUpcase<EnableAuthenticatorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
activate_authenticator(data, headers, conn)
|
||||
fn activate_authenticator_put(
|
||||
data: JsonUpcase<EnableAuthenticatorData>,
|
||||
headers: Headers,
|
||||
ip: ClientIp,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
activate_authenticator(data, headers, ip, conn)
|
||||
}
|
||||
|
||||
pub fn validate_totp_code_str(user_uuid: &str, totp_code: &str, secret: &str, conn: &DbConn) -> EmptyResult {
|
||||
pub fn validate_totp_code_str(
|
||||
user_uuid: &str,
|
||||
totp_code: &str,
|
||||
secret: &str,
|
||||
ip: &ClientIp,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
let totp_code: u64 = match totp_code.parse() {
|
||||
Ok(code) => code,
|
||||
_ => err!("TOTP code is not a number"),
|
||||
};
|
||||
|
||||
validate_totp_code(user_uuid, totp_code, secret, &conn)
|
||||
validate_totp_code(user_uuid, totp_code, secret, ip, &conn)
|
||||
}
|
||||
|
||||
pub fn validate_totp_code(user_uuid: &str, totp_code: u64, secret: &str, conn: &DbConn) -> EmptyResult {
|
||||
pub fn validate_totp_code(user_uuid: &str, totp_code: u64, secret: &str, ip: &ClientIp, conn: &DbConn) -> EmptyResult {
|
||||
use oath::{totp_raw_custom_time, HashType};
|
||||
|
||||
let decoded_secret = match BASE32.decode(secret.as_bytes()) {
|
||||
@@ -143,11 +163,22 @@ pub fn validate_totp_code(user_uuid: &str, totp_code: u64, secret: &str, conn: &
|
||||
twofactor.save(&conn)?;
|
||||
return Ok(());
|
||||
} else if generated == totp_code && time_step <= twofactor.last_used as i64 {
|
||||
warn!("This or a TOTP code within {} steps back and forward has already been used!", steps);
|
||||
err!(format!("Invalid TOTP code! Server time: {}", current_time.format("%F %T UTC")));
|
||||
warn!(
|
||||
"This or a TOTP code within {} steps back and forward has already been used!",
|
||||
steps
|
||||
);
|
||||
err!(format!(
|
||||
"Invalid TOTP code! Server time: {} IP: {}",
|
||||
current_time.format("%F %T UTC"),
|
||||
ip.ip
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Else no valide code received, deny access
|
||||
err!(format!("Invalid TOTP code! Server time: {}", current_time.format("%F %T UTC")));
|
||||
err!(format!(
|
||||
"Invalid TOTP code! Server time: {} IP: {}",
|
||||
current_time.format("%F %T UTC"),
|
||||
ip.ip
|
||||
));
|
||||
}
|
||||
|
@@ -2,18 +2,18 @@ use chrono::Utc;
|
||||
use data_encoding::BASE64;
|
||||
use rocket::Route;
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json;
|
||||
|
||||
use crate::api::core::two_factor::_generate_recover_code;
|
||||
use crate::api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, PasswordData};
|
||||
use crate::auth::Headers;
|
||||
use crate::crypto;
|
||||
use crate::db::{
|
||||
models::{TwoFactor, TwoFactorType, User},
|
||||
DbConn,
|
||||
use crate::{
|
||||
api::{core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult, JsonUpcase, PasswordData},
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{
|
||||
models::{TwoFactor, TwoFactorType, User},
|
||||
DbConn,
|
||||
},
|
||||
error::MapResult,
|
||||
CONFIG,
|
||||
};
|
||||
use crate::error::MapResult;
|
||||
use crate::CONFIG;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![get_duo, activate_duo, activate_duo_put,]
|
||||
@@ -21,9 +21,9 @@ pub fn routes() -> Vec<Route> {
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct DuoData {
|
||||
host: String,
|
||||
ik: String,
|
||||
sk: String,
|
||||
host: String, // Duo API hostname
|
||||
ik: String, // integration key
|
||||
sk: String, // secret key
|
||||
}
|
||||
|
||||
impl DuoData {
|
||||
@@ -187,9 +187,10 @@ fn activate_duo_put(data: JsonUpcase<EnableDuoData>, headers: Headers, conn: DbC
|
||||
fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
|
||||
const AGENT: &str = "bitwarden_rs:Duo/1.0 (Rust)";
|
||||
|
||||
use reqwest::{header::*, Method, blocking::Client};
|
||||
use reqwest::{blocking::Client, header::*, Method};
|
||||
use std::str::FromStr;
|
||||
|
||||
// https://duo.com/docs/authapi#api-details
|
||||
let url = format!("https://{}{}", &data.host, path);
|
||||
let date = Utc::now().to_rfc2822();
|
||||
let username = &data.ik;
|
||||
@@ -268,6 +269,10 @@ fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64
|
||||
}
|
||||
|
||||
pub fn validate_duo_login(email: &str, response: &str, conn: &DbConn) -> EmptyResult {
|
||||
// email is as entered by the user, so it needs to be normalized before
|
||||
// comparison with auth_user below.
|
||||
let email = &email.to_lowercase();
|
||||
|
||||
let split: Vec<&str> = response.split(':').collect();
|
||||
if split.len() != 2 {
|
||||
err!("Invalid response length");
|
||||
|
@@ -1,21 +1,18 @@
|
||||
use chrono::{Duration, NaiveDateTime, Utc};
|
||||
use rocket::Route;
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json;
|
||||
|
||||
use crate::api::core::two_factor::_generate_recover_code;
|
||||
use crate::api::{EmptyResult, JsonResult, JsonUpcase, PasswordData};
|
||||
use crate::auth::Headers;
|
||||
use crate::crypto;
|
||||
use crate::db::{
|
||||
models::{TwoFactor, TwoFactorType},
|
||||
DbConn,
|
||||
use crate::{
|
||||
api::{core::two_factor::_generate_recover_code, EmptyResult, JsonResult, JsonUpcase, PasswordData},
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{
|
||||
models::{TwoFactor, TwoFactorType},
|
||||
DbConn,
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
mail, CONFIG,
|
||||
};
|
||||
use crate::error::Error;
|
||||
use crate::mail;
|
||||
use crate::CONFIG;
|
||||
|
||||
use chrono::{Duration, NaiveDateTime, Utc};
|
||||
use std::ops::Add;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![get_email, send_email_login, send_email, email,]
|
||||
@@ -59,7 +56,7 @@ fn send_email_login(data: JsonUpcase<SendEmailLoginData>, conn: DbConn) -> Empty
|
||||
/// Generate the token, save the data for later verification and send email to user
|
||||
pub fn send_token(user_uuid: &str, conn: &DbConn) -> EmptyResult {
|
||||
let type_ = TwoFactorType::Email as i32;
|
||||
let mut twofactor = TwoFactor::find_by_user_and_type(user_uuid, type_, &conn)?;
|
||||
let mut twofactor = TwoFactor::find_by_user_and_type(user_uuid, type_, &conn).map_res("Two factor not found")?;
|
||||
|
||||
let generated_token = crypto::generate_token(CONFIG.email_token_size())?;
|
||||
|
||||
@@ -68,7 +65,7 @@ pub fn send_token(user_uuid: &str, conn: &DbConn) -> EmptyResult {
|
||||
twofactor.data = twofactor_data.to_json();
|
||||
twofactor.save(&conn)?;
|
||||
|
||||
mail::send_token(&twofactor_data.email, &twofactor_data.last_token?)?;
|
||||
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -135,7 +132,7 @@ fn send_email(data: JsonUpcase<SendEmailData>, headers: Headers, conn: DbConn) -
|
||||
);
|
||||
twofactor.save(&conn)?;
|
||||
|
||||
mail::send_token(&twofactor_data.email, &twofactor_data.last_token?)?;
|
||||
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -159,7 +156,7 @@ fn email(data: JsonUpcase<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
|
||||
}
|
||||
|
||||
let type_ = TwoFactorType::EmailVerificationChallenge as i32;
|
||||
let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn)?;
|
||||
let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).map_res("Two factor not found")?;
|
||||
|
||||
let mut email_data = EmailTokenData::from_json(&twofactor.data)?;
|
||||
|
||||
@@ -189,7 +186,7 @@ fn email(data: JsonUpcase<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
|
||||
/// Validate the email code when used as TwoFactor token mechanism
|
||||
pub fn validate_email_code_str(user_uuid: &str, token: &str, data: &str, conn: &DbConn) -> EmptyResult {
|
||||
let mut email_data = EmailTokenData::from_json(&data)?;
|
||||
let mut twofactor = TwoFactor::find_by_user_and_type(&user_uuid, TwoFactorType::Email as i32, &conn)?;
|
||||
let mut twofactor = TwoFactor::find_by_user_and_type(&user_uuid, TwoFactorType::Email as i32, &conn).map_res("Two factor not found")?;
|
||||
let issued_token = match &email_data.last_token {
|
||||
Some(t) => t,
|
||||
_ => err!("No token available"),
|
||||
@@ -212,7 +209,7 @@ pub fn validate_email_code_str(user_uuid: &str, token: &str, data: &str, conn: &
|
||||
|
||||
let date = NaiveDateTime::from_timestamp(email_data.token_sent, 0);
|
||||
let max_time = CONFIG.email_expiration_time() as i64;
|
||||
if date.add(Duration::seconds(max_time)) < Utc::now().naive_utc() {
|
||||
if date + Duration::seconds(max_time) < Utc::now().naive_utc() {
|
||||
err!("Token has expired")
|
||||
}
|
||||
|
||||
|
@@ -1,22 +1,23 @@
|
||||
use data_encoding::BASE32;
|
||||
use rocket::Route;
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::api::{JsonResult, JsonUpcase, NumberOrString, PasswordData};
|
||||
use crate::auth::Headers;
|
||||
use crate::crypto;
|
||||
use crate::db::{
|
||||
models::{TwoFactor, User},
|
||||
DbConn,
|
||||
use crate::{
|
||||
api::{JsonResult, JsonUpcase, NumberOrString, PasswordData},
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{
|
||||
models::{TwoFactor, User},
|
||||
DbConn,
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) mod authenticator;
|
||||
pub(crate) mod duo;
|
||||
pub(crate) mod email;
|
||||
pub(crate) mod u2f;
|
||||
pub(crate) mod yubikey;
|
||||
pub mod authenticator;
|
||||
pub mod duo;
|
||||
pub mod email;
|
||||
pub mod u2f;
|
||||
pub mod yubikey;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
let mut routes = routes![
|
||||
@@ -39,7 +40,7 @@ pub fn routes() -> Vec<Route> {
|
||||
#[get("/two-factor")]
|
||||
fn get_twofactor(headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn);
|
||||
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_list).collect();
|
||||
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_provider).collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"Data": twofactors_json,
|
||||
|
@@ -1,21 +1,26 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use rocket::Route;
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json;
|
||||
use serde_json::Value;
|
||||
use u2f::messages::{RegisterResponse, SignResponse, U2fSignRequest};
|
||||
use u2f::protocol::{Challenge, U2f};
|
||||
use u2f::register::Registration;
|
||||
|
||||
use crate::api::core::two_factor::_generate_recover_code;
|
||||
use crate::api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData};
|
||||
use crate::auth::Headers;
|
||||
use crate::db::{
|
||||
models::{TwoFactor, TwoFactorType},
|
||||
DbConn,
|
||||
use u2f::{
|
||||
messages::{RegisterResponse, SignResponse, U2fSignRequest},
|
||||
protocol::{Challenge, U2f},
|
||||
register::Registration,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult, JsonUpcase, NumberOrString,
|
||||
PasswordData,
|
||||
},
|
||||
auth::Headers,
|
||||
db::{
|
||||
models::{TwoFactor, TwoFactorType},
|
||||
DbConn,
|
||||
},
|
||||
error::Error,
|
||||
CONFIG,
|
||||
};
|
||||
use crate::error::Error;
|
||||
use crate::CONFIG;
|
||||
|
||||
const U2F_VERSION: &str = "U2F_V2";
|
||||
|
||||
|
@@ -1,19 +1,18 @@
|
||||
use rocket::Route;
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json;
|
||||
use serde_json::Value;
|
||||
use yubico::config::Config;
|
||||
use yubico::verify;
|
||||
use yubico::{config::Config, verify};
|
||||
|
||||
use crate::api::core::two_factor::_generate_recover_code;
|
||||
use crate::api::{EmptyResult, JsonResult, JsonUpcase, PasswordData};
|
||||
use crate::auth::Headers;
|
||||
use crate::db::{
|
||||
models::{TwoFactor, TwoFactorType},
|
||||
DbConn,
|
||||
use crate::{
|
||||
api::{core::two_factor::_generate_recover_code, EmptyResult, JsonResult, JsonUpcase, PasswordData},
|
||||
auth::Headers,
|
||||
db::{
|
||||
models::{TwoFactor, TwoFactorType},
|
||||
DbConn,
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
CONFIG,
|
||||
};
|
||||
use crate::error::{Error, MapResult};
|
||||
use crate::CONFIG;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
|
||||
|
143
src/api/icons.rs
143
src/api/icons.rs
@@ -1,23 +1,17 @@
|
||||
use std::{
|
||||
fs::{create_dir_all, remove_file, symlink_metadata, File},
|
||||
io::prelude::*,
|
||||
net::{IpAddr, ToSocketAddrs},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use std::fs::{create_dir_all, remove_file, symlink_metadata, File};
|
||||
use std::io::prelude::*;
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use rocket::http::ContentType;
|
||||
use rocket::response::Content;
|
||||
use rocket::Route;
|
||||
|
||||
use reqwest::{Url, header::HeaderMap, blocking::Client, blocking::Response};
|
||||
|
||||
use rocket::http::Cookie;
|
||||
|
||||
use regex::Regex;
|
||||
use reqwest::{blocking::Client, blocking::Response, header::HeaderMap, Url};
|
||||
use rocket::{http::ContentType, http::Cookie, response::Content, Route};
|
||||
use soup::prelude::*;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::CONFIG;
|
||||
use crate::util::Cached;
|
||||
use crate::{error::Error, util::Cached, CONFIG};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![icon]
|
||||
@@ -36,6 +30,11 @@ static CLIENT: Lazy<Client> = Lazy::new(|| {
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
static ICON_REL_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"icon$|apple.*icon").unwrap());
|
||||
static ICON_HREF_REGEX: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"(?i)\w+\.(jpg|jpeg|png|ico)(\?.*)?$|^data:image.*base64").unwrap());
|
||||
static ICON_SIZE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap());
|
||||
|
||||
fn is_valid_domain(domain: &str) -> bool {
|
||||
// Don't allow empty or too big domains or path traversal
|
||||
if domain.is_empty() || domain.len() > 255 || domain.contains("..") {
|
||||
@@ -64,13 +63,111 @@ fn icon(domain: String) -> Cached<Content<Vec<u8>>> {
|
||||
Cached::long(Content(icon_type, get_icon(&domain)))
|
||||
}
|
||||
|
||||
/// TODO: This is extracted from IpAddr::is_global, which is unstable:
|
||||
/// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global
|
||||
/// Remove once https://github.com/rust-lang/rust/issues/27709 is merged
|
||||
#[cfg(not(feature = "unstable"))]
|
||||
fn is_global(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => {
|
||||
// check if this address is 192.0.0.9 or 192.0.0.10. These addresses are the only two
|
||||
// globally routable addresses in the 192.0.0.0/24 range.
|
||||
if u32::from(ip) == 0xc0000009 || u32::from(ip) == 0xc000000a {
|
||||
return true;
|
||||
}
|
||||
!ip.is_private()
|
||||
&& !ip.is_loopback()
|
||||
&& !ip.is_link_local()
|
||||
&& !ip.is_broadcast()
|
||||
&& !ip.is_documentation()
|
||||
&& !(ip.octets()[0] == 100 && (ip.octets()[1] & 0b1100_0000 == 0b0100_0000))
|
||||
&& !(ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0)
|
||||
&& !(ip.octets()[0] & 240 == 240 && !ip.is_broadcast())
|
||||
&& !(ip.octets()[0] == 198 && (ip.octets()[1] & 0xfe) == 18)
|
||||
// Make sure the address is not in 0.0.0.0/8
|
||||
&& ip.octets()[0] != 0
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
if ip.is_multicast() && ip.segments()[0] & 0x000f == 14 {
|
||||
true
|
||||
} else {
|
||||
!ip.is_multicast()
|
||||
&& !ip.is_loopback()
|
||||
&& !((ip.segments()[0] & 0xffc0) == 0xfe80)
|
||||
&& !((ip.segments()[0] & 0xfe00) == 0xfc00)
|
||||
&& !ip.is_unspecified()
|
||||
&& !((ip.segments()[0] == 0x2001) && (ip.segments()[1] == 0xdb8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "unstable")]
|
||||
fn is_global(ip: IpAddr) -> bool {
|
||||
ip.is_global()
|
||||
}
|
||||
|
||||
/// These are some tests to check that the implementations match
|
||||
/// The IPv4 can be all checked in 5 mins or so and they are correct as of nightly 2020-07-11
|
||||
/// The IPV6 can't be checked in a reasonable time, so we check about ten billion random ones, so far correct
|
||||
/// Note that the is_global implementation is subject to change as new IP RFCs are created
|
||||
///
|
||||
/// To run while showing progress output:
|
||||
/// cargo test --features sqlite,unstable -- --nocapture --ignored
|
||||
#[cfg(test)]
|
||||
#[cfg(feature = "unstable")]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_ipv4_global() {
|
||||
for a in 0..u8::MAX {
|
||||
println!("Iter: {}/255", a);
|
||||
for b in 0..u8::MAX {
|
||||
for c in 0..u8::MAX {
|
||||
for d in 0..u8::MAX {
|
||||
let ip = IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, d));
|
||||
assert_eq!(ip.is_global(), is_global(ip))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_ipv6_global() {
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
let mut v = [0u8; 16];
|
||||
let rand = SystemRandom::new();
|
||||
for i in 0..1_000 {
|
||||
println!("Iter: {}/1_000", i);
|
||||
for _ in 0..10_000_000 {
|
||||
rand.fill(&mut v).expect("Error generating random values");
|
||||
let ip = IpAddr::V6(std::net::Ipv6Addr::new(
|
||||
(v[14] as u16) << 8 | v[15] as u16,
|
||||
(v[12] as u16) << 8 | v[13] as u16,
|
||||
(v[10] as u16) << 8 | v[11] as u16,
|
||||
(v[8] as u16) << 8 | v[9] as u16,
|
||||
(v[6] as u16) << 8 | v[7] as u16,
|
||||
(v[4] as u16) << 8 | v[5] as u16,
|
||||
(v[2] as u16) << 8 | v[3] as u16,
|
||||
(v[0] as u16) << 8 | v[1] as u16,
|
||||
));
|
||||
assert_eq!(ip.is_global(), is_global(ip))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_icon_domain_is_blacklisted(domain: &str) -> bool {
|
||||
let mut is_blacklisted = CONFIG.icon_blacklist_non_global_ips()
|
||||
&& (domain, 0)
|
||||
.to_socket_addrs()
|
||||
.map(|x| {
|
||||
for ip_port in x {
|
||||
if !ip_port.ip().is_global() {
|
||||
if !is_global(ip_port.ip()) {
|
||||
warn!("IP {} for domain '{}' is not a global IP!", ip_port.ip(), domain);
|
||||
return true;
|
||||
}
|
||||
@@ -182,7 +279,7 @@ struct Icon {
|
||||
}
|
||||
|
||||
impl Icon {
|
||||
fn new(priority: u8, href: String) -> Self {
|
||||
const fn new(priority: u8, href: String) -> Self {
|
||||
Self { href, priority }
|
||||
}
|
||||
}
|
||||
@@ -213,7 +310,7 @@ fn get_icon_url(domain: &str) -> Result<(Vec<Icon>, String), Error> {
|
||||
let mut cookie_str = String::new();
|
||||
|
||||
let resp = get_page(&ssldomain).or_else(|_| get_page(&httpdomain));
|
||||
if let Ok(mut content) = resp {
|
||||
if let Ok(content) = resp {
|
||||
// Extract the URL from the respose in case redirects occured (like @ gitlab.com)
|
||||
let url = content.url().clone();
|
||||
|
||||
@@ -235,14 +332,14 @@ fn get_icon_url(domain: &str) -> Result<(Vec<Icon>, String), Error> {
|
||||
|
||||
// 512KB should be more than enough for the HTML, though as we only really need
|
||||
// the HTML header, it could potentially be reduced even further
|
||||
let limited_reader = crate::util::LimitedReader::new(&mut content, 512 * 1024);
|
||||
let limited_reader = content.take(512 * 1024);
|
||||
|
||||
let soup = Soup::from_reader(limited_reader)?;
|
||||
// Search for and filter
|
||||
let favicons = soup
|
||||
.tag("link")
|
||||
.attr("rel", Regex::new(r"icon$|apple.*icon")?) // Only use icon rels
|
||||
.attr("href", Regex::new(r"(?i)\w+\.(jpg|jpeg|png|ico)(\?.*)?$|^data:image.*base64")?) // Only allow specific extensions
|
||||
.attr("rel", ICON_REL_REGEX.clone()) // Only use icon rels
|
||||
.attr("href", ICON_HREF_REGEX.clone()) // Only allow specific extensions
|
||||
.find_all();
|
||||
|
||||
// Loop through all the found icons and determine it's priority
|
||||
@@ -354,7 +451,7 @@ fn parse_sizes(sizes: Option<String>) -> (u16, u16) {
|
||||
let mut height: u16 = 0;
|
||||
|
||||
if let Some(sizes) = sizes {
|
||||
match Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap().captures(sizes.trim()) {
|
||||
match ICON_SIZE_REGEX.captures(sizes.trim()) {
|
||||
None => {}
|
||||
Some(dimensions) => {
|
||||
if dimensions.len() >= 3 {
|
||||
|
@@ -1,19 +1,22 @@
|
||||
use chrono::Utc;
|
||||
use chrono::Local;
|
||||
use num_traits::FromPrimitive;
|
||||
use rocket::request::{Form, FormItems, FromForm};
|
||||
use rocket::Route;
|
||||
use rocket::{
|
||||
request::{Form, FormItems, FromForm},
|
||||
Route,
|
||||
};
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::api::core::two_factor::email::EmailTokenData;
|
||||
use crate::api::core::two_factor::{duo, email, yubikey};
|
||||
use crate::api::{ApiResult, EmptyResult, JsonResult};
|
||||
use crate::auth::ClientIp;
|
||||
use crate::db::models::*;
|
||||
use crate::db::DbConn;
|
||||
use crate::mail;
|
||||
use crate::util;
|
||||
use crate::CONFIG;
|
||||
use crate::{
|
||||
api::{
|
||||
core::two_factor::{duo, email, email::EmailTokenData, yubikey},
|
||||
ApiResult, EmptyResult, JsonResult,
|
||||
},
|
||||
auth::ClientIp,
|
||||
db::{models::*, DbConn},
|
||||
error::MapResult,
|
||||
mail, util, CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![login]
|
||||
@@ -38,7 +41,7 @@ fn login(data: Form<ConnectData>, conn: DbConn, ip: ClientIp) -> JsonResult {
|
||||
_check_is_some(&data.device_name, "device_name cannot be blank")?;
|
||||
_check_is_some(&data.device_type, "device_type cannot be blank")?;
|
||||
|
||||
_password_login(data, conn, ip)
|
||||
_password_login(data, conn, &ip)
|
||||
}
|
||||
t => err!("Invalid type", t),
|
||||
}
|
||||
@@ -49,10 +52,7 @@ fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult {
|
||||
let token = data.refresh_token.unwrap();
|
||||
|
||||
// Get device by refresh token
|
||||
let mut device = match Device::find_by_refresh_token(&token, &conn) {
|
||||
Some(device) => device,
|
||||
None => err!("Invalid refresh token"),
|
||||
};
|
||||
let mut device = Device::find_by_refresh_token(&token, &conn).map_res("Invalid refresh token")?;
|
||||
|
||||
// COMMON
|
||||
let user = User::find_by_uuid(&device.user_uuid, &conn).unwrap();
|
||||
@@ -68,10 +68,15 @@ fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult {
|
||||
"refresh_token": device.refresh_token,
|
||||
"Key": user.akey,
|
||||
"PrivateKey": user.private_key,
|
||||
|
||||
"Kdf": user.client_kdf_type,
|
||||
"KdfIterations": user.client_kdf_iter,
|
||||
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
|
||||
"scope": "api offline_access"
|
||||
})))
|
||||
}
|
||||
|
||||
fn _password_login(data: ConnectData, conn: DbConn, ip: ClientIp) -> JsonResult {
|
||||
fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult {
|
||||
// Validate scope
|
||||
let scope = data.scope.as_ref().unwrap();
|
||||
if scope != "api offline_access" {
|
||||
@@ -97,8 +102,10 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: ClientIp) -> JsonResult
|
||||
)
|
||||
}
|
||||
|
||||
let now = Local::now();
|
||||
|
||||
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = now.naive_utc();
|
||||
if user.last_verifying_at.is_none() || now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds() > CONFIG.signups_verify_resend_time() as i64 {
|
||||
let resend_limit = CONFIG.signups_verify_resend_limit() as i32;
|
||||
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
||||
@@ -127,10 +134,10 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: ClientIp) -> JsonResult
|
||||
|
||||
let (mut device, new_device) = get_device(&data, &conn, &user);
|
||||
|
||||
let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, &conn)?;
|
||||
let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, &ip, &conn)?;
|
||||
|
||||
if CONFIG.mail_enabled() && new_device {
|
||||
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &device.updated_at, &device.name) {
|
||||
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name) {
|
||||
error!("Error sending new device email: {:#?}", e);
|
||||
|
||||
if CONFIG.require_device_email() {
|
||||
@@ -154,6 +161,11 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: ClientIp) -> JsonResult
|
||||
"Key": user.akey,
|
||||
"PrivateKey": user.private_key,
|
||||
//"TwoFactorToken": "11122233333444555666777888999"
|
||||
|
||||
"Kdf": user.client_kdf_type,
|
||||
"KdfIterations": user.client_kdf_iter,
|
||||
"ResetMasterPassword": false,// TODO: Same as above
|
||||
"scope": "api offline_access"
|
||||
});
|
||||
|
||||
if let Some(token) = twofactor_token {
|
||||
@@ -197,6 +209,7 @@ fn twofactor_auth(
|
||||
user_uuid: &str,
|
||||
data: &ConnectData,
|
||||
device: &mut Device,
|
||||
ip: &ClientIp,
|
||||
conn: &DbConn,
|
||||
) -> ApiResult<Option<String>> {
|
||||
let twofactors = TwoFactor::find_by_user(user_uuid, conn);
|
||||
@@ -216,8 +229,7 @@ fn twofactor_auth(
|
||||
|
||||
let selected_twofactor = twofactors
|
||||
.into_iter()
|
||||
.filter(|tf| tf.atype == selected_id && tf.enabled)
|
||||
.nth(0);
|
||||
.find(|tf| tf.atype == selected_id && tf.enabled);
|
||||
|
||||
use crate::api::core::two_factor as _tf;
|
||||
use crate::crypto::ct_eq;
|
||||
@@ -226,7 +238,7 @@ fn twofactor_auth(
|
||||
let mut remember = data.two_factor_remember.unwrap_or(0);
|
||||
|
||||
match TwoFactorType::from_i32(selected_id) {
|
||||
Some(TwoFactorType::Authenticator) => _tf::authenticator::validate_totp_code_str(user_uuid, twofactor_code, &selected_data?, conn)?,
|
||||
Some(TwoFactorType::Authenticator) => _tf::authenticator::validate_totp_code_str(user_uuid, twofactor_code, &selected_data?, ip, conn)?,
|
||||
Some(TwoFactorType::U2f) => _tf::u2f::validate_u2f_login(user_uuid, twofactor_code, conn)?,
|
||||
Some(TwoFactorType::YubiKey) => _tf::yubikey::validate_yubikey_login(twofactor_code, &selected_data?)?,
|
||||
Some(TwoFactorType::Duo) => _tf::duo::validate_duo_login(data.username.as_ref().unwrap(), twofactor_code, conn)?,
|
||||
@@ -252,10 +264,7 @@ fn twofactor_auth(
|
||||
}
|
||||
|
||||
fn _selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
|
||||
match tf {
|
||||
Some(tf) => Ok(tf.data),
|
||||
None => err!("Two factor doesn't exist"),
|
||||
}
|
||||
tf.map(|t| t.data).map_res("Two factor doesn't exist")
|
||||
}
|
||||
|
||||
fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> ApiResult<Value> {
|
||||
@@ -347,6 +356,7 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// https://github.com/bitwarden/mobile/blob/master/src/Core/Models/Request/TokenRequest.cs
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[allow(non_snake_case)]
|
||||
struct ConnectData {
|
||||
@@ -364,6 +374,7 @@ struct ConnectData {
|
||||
device_identifier: Option<String>,
|
||||
device_name: Option<String>,
|
||||
device_type: Option<String>,
|
||||
device_push_token: Option<String>, // Unused; mobile device push not yet supported.
|
||||
|
||||
// Needed for two-factor auth
|
||||
two_factor_provider: Option<i32>,
|
||||
@@ -391,6 +402,7 @@ impl<'f> FromForm<'f> for ConnectData {
|
||||
"deviceidentifier" => form.device_identifier = Some(value),
|
||||
"devicename" => form.device_name = Some(value),
|
||||
"devicetype" => form.device_type = Some(value),
|
||||
"devicepushtoken" => form.device_push_token = Some(value),
|
||||
"twofactorprovider" => form.two_factor_provider = value.parse().ok(),
|
||||
"twofactortoken" => form.two_factor_token = Some(value),
|
||||
"twofactorremember" => form.two_factor_remember = value.parse().ok(),
|
||||
|
@@ -1,27 +1,29 @@
|
||||
mod admin;
|
||||
pub(crate) mod core;
|
||||
pub mod core;
|
||||
mod icons;
|
||||
mod identity;
|
||||
mod notifications;
|
||||
mod web;
|
||||
|
||||
pub use self::admin::routes as admin_routes;
|
||||
pub use self::core::routes as core_routes;
|
||||
pub use self::icons::routes as icons_routes;
|
||||
pub use self::identity::routes as identity_routes;
|
||||
pub use self::notifications::routes as notifications_routes;
|
||||
pub use self::notifications::{start_notification_server, Notify, UpdateType};
|
||||
pub use self::web::routes as web_routes;
|
||||
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json::Value;
|
||||
|
||||
pub use crate::api::{
|
||||
admin::routes as admin_routes,
|
||||
core::routes as core_routes,
|
||||
icons::routes as icons_routes,
|
||||
identity::routes as identity_routes,
|
||||
notifications::routes as notifications_routes,
|
||||
notifications::{start_notification_server, Notify, UpdateType},
|
||||
web::routes as web_routes,
|
||||
};
|
||||
use crate::util;
|
||||
|
||||
// Type aliases for API methods results
|
||||
type ApiResult<T> = Result<T, crate::error::Error>;
|
||||
pub type JsonResult = ApiResult<Json<Value>>;
|
||||
pub type EmptyResult = ApiResult<()>;
|
||||
|
||||
use crate::util;
|
||||
type JsonUpcase<T> = Json<util::UpCase<T>>;
|
||||
type JsonUpcaseVec<T> = Json<Vec<util::UpCase<T>>>;
|
||||
|
||||
|
@@ -4,11 +4,12 @@ use rocket::Route;
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::api::{EmptyResult, JsonResult};
|
||||
use crate::auth::Headers;
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::{Error, CONFIG};
|
||||
use crate::{
|
||||
api::{EmptyResult, JsonResult},
|
||||
auth::Headers,
|
||||
db::DbConn,
|
||||
Error, CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![negotiate, websockets_err]
|
||||
@@ -152,15 +153,19 @@ impl WSHandler {
|
||||
impl Handler for WSHandler {
|
||||
fn on_open(&mut self, hs: Handshake) -> ws::Result<()> {
|
||||
// Path == "/notifications/hub?id=<id>==&access_token=<access_token>"
|
||||
//
|
||||
// We don't use `id`, and as of around 2020-03-25, the official clients
|
||||
// no longer seem to pass `id` (only `access_token`).
|
||||
let path = hs.request.resource();
|
||||
|
||||
let (_id, access_token) = match path.split('?').nth(1) {
|
||||
Some(params) => {
|
||||
let mut params_iter = params.split('&').take(2);
|
||||
let params_iter = params.split('&').take(2);
|
||||
|
||||
let mut id = None;
|
||||
let mut access_token = None;
|
||||
while let Some(val) = params_iter.next() {
|
||||
|
||||
for val in params_iter {
|
||||
if val.starts_with(ID_KEY) {
|
||||
id = Some(&val[ID_KEY.len()..]);
|
||||
} else if val.starts_with(ACCESS_TOKEN_KEY) {
|
||||
@@ -170,10 +175,11 @@ impl Handler for WSHandler {
|
||||
|
||||
match (id, access_token) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
_ => return self.err("Missing id or access token"),
|
||||
(None, Some(b)) => ("", b), // Ignore missing `id`.
|
||||
_ => return self.err("Missing access token"),
|
||||
}
|
||||
}
|
||||
None => return self.err("Missing query path"),
|
||||
None => return self.err("Missing query parameters"),
|
||||
};
|
||||
|
||||
// Validate the user
|
||||
@@ -256,7 +262,9 @@ impl Factory for WSFactory {
|
||||
// Remove handler
|
||||
if let Some(user_uuid) = &handler.user_uuid {
|
||||
if let Some(mut user_conn) = self.users.map.get_mut(user_uuid) {
|
||||
user_conn.remove_item(&handler.out);
|
||||
if let Some(pos) = user_conn.iter().position(|x| x == &handler.out) {
|
||||
user_conn.remove(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,15 +1,10 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rocket::http::ContentType;
|
||||
use rocket::response::content::Content;
|
||||
use rocket::response::NamedFile;
|
||||
use rocket::Route;
|
||||
use rocket::{http::ContentType, response::content::Content, response::NamedFile, Route};
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::util::Cached;
|
||||
use crate::CONFIG;
|
||||
use crate::{error::Error, util::Cached, CONFIG};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
// If addding more routes here, consider also adding them to
|
||||
@@ -78,6 +73,7 @@ fn static_files(filename: String) -> Result<Content<&'static [u8]>, Error> {
|
||||
match filename.as_ref() {
|
||||
"mail-github.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/mail-github.png"))),
|
||||
"logo-gray.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))),
|
||||
"shield-white.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/shield-white.png"))),
|
||||
"error-x.svg" => Ok(Content(ContentType::SVG, include_bytes!("../static/images/error-x.svg"))),
|
||||
"hibp.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/hibp.png"))),
|
||||
|
||||
|
43
src/auth.rs
43
src/auth.rs
@@ -1,17 +1,19 @@
|
||||
//
|
||||
// JWT Handling
|
||||
//
|
||||
use crate::util::read_file;
|
||||
use chrono::{Duration, Utc};
|
||||
use once_cell::sync::Lazy;
|
||||
use num_traits::FromPrimitive;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use jsonwebtoken::{self, Algorithm, Header, EncodingKey, DecodingKey};
|
||||
use jsonwebtoken::{self, Algorithm, DecodingKey, EncodingKey, Header};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::ser::Serialize;
|
||||
|
||||
use crate::error::{Error, MapResult};
|
||||
use crate::CONFIG;
|
||||
use crate::{
|
||||
error::{Error, MapResult},
|
||||
util::read_file,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
||||
|
||||
@@ -213,11 +215,14 @@ pub fn generate_admin_claims() -> AdminJWTClaims {
|
||||
//
|
||||
// Bearer token authentication
|
||||
//
|
||||
use rocket::request::{self, FromRequest, Request};
|
||||
use rocket::Outcome;
|
||||
use rocket::{
|
||||
request::{FromRequest, Request, Outcome},
|
||||
};
|
||||
|
||||
use crate::db::models::{Device, User, UserOrgStatus, UserOrgType, UserOrganization};
|
||||
use crate::db::DbConn;
|
||||
use crate::db::{
|
||||
models::{Device, User, UserOrgStatus, UserOrgType, UserOrganization},
|
||||
DbConn,
|
||||
};
|
||||
|
||||
pub struct Headers {
|
||||
pub host: String,
|
||||
@@ -228,7 +233,7 @@ pub struct Headers {
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for Headers {
|
||||
type Error = &'static str;
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
|
||||
fn from_request(request: &'a Request<'r>) -> Outcome<Self, Self::Error> {
|
||||
let headers = request.headers();
|
||||
|
||||
// Get host
|
||||
@@ -329,7 +334,7 @@ fn get_org_id(request: &Request) -> Option<String> {
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for OrgHeaders {
|
||||
type Error = &'static str;
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
|
||||
fn from_request(request: &'a Request<'r>) -> Outcome<Self, Self::Error> {
|
||||
match request.guard::<Headers>() {
|
||||
Outcome::Forward(_) => Outcome::Forward(()),
|
||||
Outcome::Failure(f) => Outcome::Failure(f),
|
||||
@@ -366,7 +371,7 @@ impl<'a, 'r> FromRequest<'a, 'r> for OrgHeaders {
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
_ => err_handler!("Error getting the organization id"),
|
||||
}
|
||||
}
|
||||
@@ -384,7 +389,7 @@ pub struct AdminHeaders {
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for AdminHeaders {
|
||||
type Error = &'static str;
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
|
||||
fn from_request(request: &'a Request<'r>) -> Outcome<Self, Self::Error> {
|
||||
match request.guard::<OrgHeaders>() {
|
||||
Outcome::Forward(_) => Outcome::Forward(()),
|
||||
Outcome::Failure(f) => Outcome::Failure(f),
|
||||
@@ -404,14 +409,14 @@ impl<'a, 'r> FromRequest<'a, 'r> for AdminHeaders {
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<Headers> for AdminHeaders {
|
||||
fn into(self) -> Headers {
|
||||
impl Into<Headers> for AdminHeaders {
|
||||
fn into(self) -> Headers {
|
||||
Headers {
|
||||
host: self.host,
|
||||
device: self.device,
|
||||
user: self.user
|
||||
user: self.user,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OwnerHeaders {
|
||||
@@ -423,7 +428,7 @@ pub struct OwnerHeaders {
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for OwnerHeaders {
|
||||
type Error = &'static str;
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
|
||||
fn from_request(request: &'a Request<'r>) -> Outcome<Self, Self::Error> {
|
||||
match request.guard::<OrgHeaders>() {
|
||||
Outcome::Forward(_) => Outcome::Forward(()),
|
||||
Outcome::Failure(f) => Outcome::Failure(f),
|
||||
@@ -454,7 +459,7 @@ pub struct ClientIp {
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for ClientIp {
|
||||
type Error = ();
|
||||
|
||||
fn from_request(req: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
|
||||
fn from_request(req: &'a Request<'r>) -> Outcome<Self, Self::Error> {
|
||||
let ip = if CONFIG._ip_header_enabled() {
|
||||
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
|
||||
match ip.find(',') {
|
||||
|
@@ -1,11 +1,13 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use std::process::exit;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::Url;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::util::{get_env, get_env_bool};
|
||||
use crate::{
|
||||
error::Error,
|
||||
util::{get_env, get_env_bool},
|
||||
};
|
||||
|
||||
static CONFIG_FILE: Lazy<String> = Lazy::new(|| {
|
||||
let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data"));
|
||||
@@ -112,6 +114,8 @@ macro_rules! make_config {
|
||||
)+)+
|
||||
config.domain_set = _domain_set;
|
||||
|
||||
config.signups_domains_whitelist = config.signups_domains_whitelist.trim().to_lowercase();
|
||||
|
||||
config
|
||||
}
|
||||
}
|
||||
@@ -133,7 +137,6 @@ macro_rules! make_config {
|
||||
(inner._env.build(), inner.config.clone())
|
||||
};
|
||||
|
||||
|
||||
fn _get_form_type(rust_type: &str) -> &'static str {
|
||||
match rust_type {
|
||||
"Pass" => "password",
|
||||
@@ -263,7 +266,7 @@ make_config! {
|
||||
/// $ICON_CACHE_FOLDER, but it won't produce any external network request. Needs to set $ICON_CACHE_TTL to 0,
|
||||
/// otherwise it will delete them and they won't be downloaded again.
|
||||
disable_icon_download: bool, true, def, false;
|
||||
/// Allow new signups |> Controls if new users can register. Note that while this is disabled, users could still be invited
|
||||
/// Allow new signups |> Controls whether new users can register. Users can be invited by the bitwarden_rs admin even if this is disabled
|
||||
signups_allowed: bool, true, def, true;
|
||||
/// Require email verification on signups. This will prevent logins from succeeding until the address has been verified
|
||||
signups_verify: bool, true, def, false;
|
||||
@@ -271,9 +274,9 @@ make_config! {
|
||||
signups_verify_resend_time: u64, true, def, 3_600;
|
||||
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
|
||||
signups_verify_resend_limit: u32, true, def, 6;
|
||||
/// Allow signups only from this list of comma-separated domains
|
||||
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
|
||||
signups_domains_whitelist: String, true, def, "".to_string();
|
||||
/// Allow invitations |> Controls whether users can be invited by organization admins, even when signups are disabled
|
||||
/// Allow invitations |> Controls whether users can be invited by organization admins, even when signups are otherwise disabled
|
||||
invitations_allowed: bool, true, def, true;
|
||||
/// Password iterations |> Number of server-side passwords hashing iterations.
|
||||
/// The changes only apply when a user changes their password. Not recommended to lower the value
|
||||
@@ -326,6 +329,8 @@ make_config! {
|
||||
reload_templates: bool, true, def, false;
|
||||
/// 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();
|
||||
/// Enable the log to output to Syslog
|
||||
use_syslog: bool, false, def, false;
|
||||
/// Log file path
|
||||
@@ -393,7 +398,9 @@ make_config! {
|
||||
/// Json form auth mechanism |> Defaults for ssl is "Plain" and "Login" and nothing for non-ssl connections. Possible values: ["Plain", "Login", "Xoauth2"]
|
||||
smtp_auth_mechanism: String, true, option;
|
||||
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
|
||||
smtp_timeout: u64, true, def, 15;
|
||||
smtp_timeout: u64, true, def, 15;
|
||||
/// Server name sent during HELO |> By default this value should be is on the machine's hostname, but might need to be changed in case it trips some anti-spam filters
|
||||
helo_name: String, true, option;
|
||||
},
|
||||
|
||||
/// Email 2FA Settings
|
||||
@@ -411,7 +418,9 @@ make_config! {
|
||||
|
||||
fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
let db_url = cfg.database_url.to_lowercase();
|
||||
if cfg!(feature = "sqlite") && (db_url.starts_with("mysql:") || db_url.starts_with("postgresql:")) {
|
||||
if cfg!(feature = "sqlite")
|
||||
&& (db_url.starts_with("mysql:") || db_url.starts_with("postgresql:") || db_url.starts_with("postgres:"))
|
||||
{
|
||||
err!("`DATABASE_URL` is meant for MySQL or Postgres, while this server is meant for SQLite")
|
||||
}
|
||||
|
||||
@@ -419,7 +428,7 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
err!("`DATABASE_URL` should start with mysql: when using the MySQL server")
|
||||
}
|
||||
|
||||
if cfg!(feature = "postgresql") && !db_url.starts_with("postgresql:") {
|
||||
if cfg!(feature = "postgresql") && !(db_url.starts_with("postgresql:") || db_url.starts_with("postgres:")) {
|
||||
err!("`DATABASE_URL` should start with postgresql: when using the PostgreSQL server")
|
||||
}
|
||||
|
||||
@@ -428,9 +437,15 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
err!("DOMAIN variable needs to contain the protocol (http, https). Use 'http[s]://bw.example.com' instead of 'bw.example.com'");
|
||||
}
|
||||
|
||||
let whitelist = &cfg.signups_domains_whitelist;
|
||||
if !whitelist.is_empty() && whitelist.split(',').any(|d| d.trim().is_empty()) {
|
||||
err!("`SIGNUPS_DOMAINS_WHITELIST` contains empty tokens");
|
||||
}
|
||||
|
||||
if let Some(ref token) = cfg.admin_token {
|
||||
if token.trim().is_empty() && !cfg.disable_admin_token {
|
||||
err!("`ADMIN_TOKEN` is enabled but has an empty value. To enable the admin page without token, use `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`.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,18 +566,30 @@ impl Config {
|
||||
self.update_config(builder)
|
||||
}
|
||||
|
||||
pub fn can_signup_user(&self, email: &str) -> bool {
|
||||
/// Tests whether an email's domain is allowed. A domain is allowed if it
|
||||
/// is in signups_domains_whitelist, or if no whitelist is set (so there
|
||||
/// are no domain restrictions in effect).
|
||||
pub fn is_email_domain_allowed(&self, email: &str) -> bool {
|
||||
let e: Vec<&str> = email.rsplitn(2, '@').collect();
|
||||
if e.len() != 2 || e[0].is_empty() || e[1].is_empty() {
|
||||
warn!("Failed to parse email address '{}'", email);
|
||||
return false;
|
||||
}
|
||||
let email_domain = e[0].to_lowercase();
|
||||
let whitelist = self.signups_domains_whitelist();
|
||||
|
||||
// Allow signups if the whitelist is empty/not configured
|
||||
// (it doesn't contain any domains), or if it matches at least
|
||||
// one domain.
|
||||
let whitelist_str = self.signups_domains_whitelist();
|
||||
( whitelist_str.is_empty() && CONFIG.signups_allowed() )|| whitelist_str.split(',').filter(|s| !s.is_empty()).any(|d| d == e[0])
|
||||
whitelist.is_empty() || whitelist.split(',').any(|d| d.trim() == email_domain)
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
// The whitelist setting overrides the signups_allowed setting.
|
||||
self.is_email_domain_allowed(email)
|
||||
} else {
|
||||
self.signups_allowed()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_user_config(&self) -> Result<(), Error> {
|
||||
@@ -617,6 +644,13 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests whether the admin token is set to a non-empty value.
|
||||
pub fn is_admin_token_set(&self) -> bool {
|
||||
let token = self.admin_token();
|
||||
|
||||
token.is_some() && !token.unwrap().trim().is_empty()
|
||||
}
|
||||
|
||||
pub fn render_template<T: serde::ser::Serialize>(
|
||||
&self,
|
||||
name: &str,
|
||||
@@ -674,7 +708,10 @@ where
|
||||
|
||||
reg!("admin/base");
|
||||
reg!("admin/login");
|
||||
reg!("admin/page");
|
||||
reg!("admin/settings");
|
||||
reg!("admin/users");
|
||||
reg!("admin/organizations");
|
||||
reg!("admin/diagnostics");
|
||||
|
||||
// And then load user templates to overwrite the defaults
|
||||
// Use .hbs extension for the files
|
||||
|
@@ -1,10 +1,11 @@
|
||||
//
|
||||
// PBKDF2 derivation
|
||||
//
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use ring::{digest, hmac, pbkdf2};
|
||||
|
||||
use crate::error::Error;
|
||||
use ring::{digest, hmac, pbkdf2};
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
static DIGEST_ALG: pbkdf2::Algorithm = pbkdf2::PBKDF2_HMAC_SHA256;
|
||||
const OUTPUT_LEN: usize = digest::SHA256_OUTPUT_LEN;
|
||||
|
@@ -1,18 +1,14 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use diesel::r2d2;
|
||||
use diesel::r2d2::ConnectionManager;
|
||||
use diesel::{Connection as DieselConnection, ConnectionError};
|
||||
|
||||
use rocket::http::Status;
|
||||
use rocket::request::{self, FromRequest};
|
||||
use rocket::{Outcome, Request, State};
|
||||
|
||||
use crate::error::Error;
|
||||
use chrono::prelude::*;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::CONFIG;
|
||||
use chrono::prelude::*;
|
||||
use diesel::{r2d2, r2d2::ConnectionManager, Connection as DieselConnection, ConnectionError};
|
||||
use rocket::{
|
||||
http::Status,
|
||||
request::{FromRequest, Outcome},
|
||||
Request, State,
|
||||
};
|
||||
|
||||
use crate::{error::Error, CONFIG};
|
||||
|
||||
/// An alias to the database connection used
|
||||
#[cfg(feature = "sqlite")]
|
||||
@@ -75,7 +71,7 @@ pub fn backup_database() -> Result<(), Error> {
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for DbConn {
|
||||
type Error = ();
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<DbConn, ()> {
|
||||
fn from_request(request: &'a Request<'r>) -> Outcome<DbConn, ()> {
|
||||
// https://github.com/SergioBenitez/Rocket/commit/e3c1a4ad3ab9b840482ec6de4200d30df43e357c
|
||||
let pool = try_outcome!(request.guard::<State<Pool>>());
|
||||
match pool.get() {
|
||||
@@ -86,7 +82,7 @@ impl<'a, 'r> FromRequest<'a, 'r> for DbConn {
|
||||
}
|
||||
|
||||
// For the convenience of using an &DbConn as a &Database.
|
||||
impl Deref for DbConn {
|
||||
impl std::ops::Deref for DbConn {
|
||||
type Target = Connection;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
|
@@ -5,6 +5,7 @@ use crate::CONFIG;
|
||||
|
||||
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)]
|
||||
#[table_name = "attachments"]
|
||||
#[changeset_options(treat_none_as_null="true")]
|
||||
#[belongs_to(Cipher, foreign_key = "cipher_uuid")]
|
||||
#[primary_key(id)]
|
||||
pub struct Attachment {
|
||||
@@ -17,7 +18,7 @@ pub struct Attachment {
|
||||
|
||||
/// Local methods
|
||||
impl Attachment {
|
||||
pub fn new(id: String, cipher_uuid: String, file_name: String, file_size: i32) -> Self {
|
||||
pub const fn new(id: String, cipher_uuid: String, file_name: String, file_size: i32) -> Self {
|
||||
Self {
|
||||
id,
|
||||
cipher_uuid,
|
||||
@@ -51,7 +52,6 @@ impl Attachment {
|
||||
|
||||
use crate::db::schema::{attachments, ciphers};
|
||||
use crate::db::DbConn;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
@@ -130,6 +130,16 @@ impl Attachment {
|
||||
result.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn count_by_user(user_uuid: &str, conn: &DbConn) -> i64 {
|
||||
attachments::table
|
||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||
.filter(ciphers::user_uuid.eq(user_uuid))
|
||||
.count()
|
||||
.first::<i64>(&**conn)
|
||||
.ok()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn size_by_org(org_uuid: &str, conn: &DbConn) -> i64 {
|
||||
let result: Option<i64> = attachments::table
|
||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||
@@ -140,4 +150,14 @@ impl Attachment {
|
||||
|
||||
result.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn count_by_org(org_uuid: &str, conn: &DbConn) -> i64 {
|
||||
attachments::table
|
||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||
.filter(ciphers::organization_uuid.eq(org_uuid))
|
||||
.count()
|
||||
.first(&**conn)
|
||||
.ok()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
@@ -7,6 +7,7 @@ use super::{
|
||||
|
||||
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)]
|
||||
#[table_name = "ciphers"]
|
||||
#[changeset_options(treat_none_as_null="true")]
|
||||
#[belongs_to(User, foreign_key = "user_uuid")]
|
||||
#[belongs_to(Organization, foreign_key = "organization_uuid")]
|
||||
#[primary_key(uuid)]
|
||||
@@ -33,6 +34,7 @@ pub struct Cipher {
|
||||
|
||||
pub favorite: bool,
|
||||
pub password_history: Option<String>,
|
||||
pub deleted_at: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
@@ -57,13 +59,13 @@ impl Cipher {
|
||||
|
||||
data: String::new(),
|
||||
password_history: None,
|
||||
deleted_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use crate::db::schema::*;
|
||||
use crate::db::DbConn;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
@@ -80,7 +82,28 @@ impl Cipher {
|
||||
let fields_json = self.fields.as_ref().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(Value::Null);
|
||||
let password_history_json = self.password_history.as_ref().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(Value::Null);
|
||||
|
||||
let mut data_json: Value = serde_json::from_str(&self.data).unwrap_or(Value::Null);
|
||||
let (read_only, hide_passwords) =
|
||||
match self.get_access_restrictions(&user_uuid, &conn) {
|
||||
Some((ro, hp)) => (ro, hp),
|
||||
None => {
|
||||
error!("Cipher ownership assertion failure");
|
||||
(true, true)
|
||||
},
|
||||
};
|
||||
|
||||
// Get the data or a default empty value to avoid issues with the mobile apps
|
||||
let mut data_json: Value = serde_json::from_str(&self.data).unwrap_or_else(|_| json!({
|
||||
"Fields":null,
|
||||
"Name": self.name,
|
||||
"Notes":null,
|
||||
"Password":null,
|
||||
"PasswordHistory":null,
|
||||
"PasswordRevisionDate":null,
|
||||
"Response":null,
|
||||
"Totp":null,
|
||||
"Uris":null,
|
||||
"Username":null
|
||||
}));
|
||||
|
||||
// TODO: ******* Backwards compat start **********
|
||||
// To remove backwards compatibility, just remove this entire section
|
||||
@@ -91,15 +114,26 @@ impl Cipher {
|
||||
}
|
||||
// TODO: ******* Backwards compat end **********
|
||||
|
||||
// There are three types of cipher response models in upstream
|
||||
// Bitwarden: "cipherMini", "cipher", and "cipherDetails" (in order
|
||||
// of increasing level of detail). bitwarden_rs currently only
|
||||
// supports the "cipherDetails" type, though it seems like the
|
||||
// Bitwarden clients will ignore extra fields.
|
||||
//
|
||||
// Ref: https://github.com/bitwarden/server/blob/master/src/Core/Models/Api/Response/CipherResponseModel.cs
|
||||
let mut json_object = json!({
|
||||
"Object": "cipherDetails",
|
||||
"Id": self.uuid,
|
||||
"Type": self.atype,
|
||||
"RevisionDate": format_date(&self.updated_at),
|
||||
"DeletedDate": self.deleted_at.map_or(Value::Null, |d| Value::String(format_date(&d))),
|
||||
"FolderId": self.get_folder_uuid(&user_uuid, &conn),
|
||||
"Favorite": self.favorite,
|
||||
"OrganizationId": self.organization_uuid,
|
||||
"Attachments": attachments_json,
|
||||
"OrganizationUseTotp": true,
|
||||
|
||||
// This field is specific to the cipherDetails type.
|
||||
"CollectionIds": self.get_collections(user_uuid, &conn),
|
||||
|
||||
"Name": self.name,
|
||||
@@ -108,8 +142,11 @@ impl Cipher {
|
||||
|
||||
"Data": data_json,
|
||||
|
||||
"Object": "cipher",
|
||||
"Edit": true,
|
||||
// These values are true by default, but can be false if the
|
||||
// cipher belongs to a collection where the org owner has enabled
|
||||
// the "Read Only" or "Hide Passwords" restrictions for the user.
|
||||
"Edit": !read_only,
|
||||
"ViewPassword": !hide_passwords,
|
||||
|
||||
"PasswordHistory": password_history_json,
|
||||
});
|
||||
@@ -226,64 +263,78 @@ impl Cipher {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_write_accessible_to_user(&self, user_uuid: &str, conn: &DbConn) -> bool {
|
||||
ciphers::table
|
||||
/// Returns whether this cipher is directly owned by the user.
|
||||
pub fn is_owned_by_user(&self, user_uuid: &str) -> bool {
|
||||
self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid
|
||||
}
|
||||
|
||||
/// Returns whether this cipher is owned by an org in which the user has full access.
|
||||
pub fn is_in_full_access_org(&self, user_uuid: &str, conn: &DbConn) -> bool {
|
||||
if let Some(ref org_uuid) = self.organization_uuid {
|
||||
if let Some(user_org) = UserOrganization::find_by_user_and_org(&user_uuid, &org_uuid, &conn) {
|
||||
return user_org.has_full_access();
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns the user's access restrictions to this cipher. A return value
|
||||
/// of None means that this cipher does not belong to the user, and is
|
||||
/// not in any collection the user has access to. Otherwise, the user has
|
||||
/// access to this cipher, and Some(read_only, hide_passwords) represents
|
||||
/// the access restrictions.
|
||||
pub fn get_access_restrictions(&self, user_uuid: &str, conn: &DbConn) -> Option<(bool, bool)> {
|
||||
// Check whether this cipher is directly owned by the user, or is in
|
||||
// a collection that the user has full access to. If so, there are no
|
||||
// access restrictions.
|
||||
if self.is_owned_by_user(&user_uuid) || self.is_in_full_access_org(&user_uuid, &conn) {
|
||||
return Some((false, false));
|
||||
}
|
||||
|
||||
// Check whether this cipher is in any collections accessible to the
|
||||
// user. If so, retrieve the access flags for each collection.
|
||||
let query = ciphers::table
|
||||
.filter(ciphers::uuid.eq(&self.uuid))
|
||||
.left_join(
|
||||
users_organizations::table.on(ciphers::organization_uuid
|
||||
.eq(users_organizations::org_uuid.nullable())
|
||||
.and(users_organizations::user_uuid.eq(user_uuid))),
|
||||
)
|
||||
.left_join(ciphers_collections::table)
|
||||
.left_join(
|
||||
users_collections::table
|
||||
.on(ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)),
|
||||
)
|
||||
.filter(ciphers::user_uuid.eq(user_uuid).or(
|
||||
// Cipher owner
|
||||
users_organizations::access_all.eq(true).or(
|
||||
// access_all in Organization
|
||||
users_organizations::atype.le(UserOrgType::Admin as i32).or(
|
||||
// Org admin or owner
|
||||
users_collections::user_uuid.eq(user_uuid).and(
|
||||
users_collections::read_only.eq(false), //R/W access to collection
|
||||
),
|
||||
),
|
||||
),
|
||||
))
|
||||
.select(ciphers::all_columns)
|
||||
.first::<Self>(&**conn)
|
||||
.ok()
|
||||
.is_some()
|
||||
.inner_join(ciphers_collections::table.on(
|
||||
ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
|
||||
.inner_join(users_collections::table.on(
|
||||
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid))))
|
||||
.select((users_collections::read_only, users_collections::hide_passwords));
|
||||
|
||||
// There's an edge case where a cipher can be in multiple collections
|
||||
// with inconsistent access flags. For example, a cipher could be in
|
||||
// one collection where the user has read-only access, but also in
|
||||
// another collection where the user has read/write access. To handle
|
||||
// this, we do a boolean OR of all values in each of the `read_only`
|
||||
// and `hide_passwords` columns. This could ideally be done as part
|
||||
// of the query, but Diesel doesn't support a max() or bool_or()
|
||||
// function on booleans and this behavior isn't portable anyway.
|
||||
if let Some(vec) = query.load::<(bool, bool)>(&**conn).ok() {
|
||||
let mut read_only = false;
|
||||
let mut hide_passwords = false;
|
||||
for (ro, hp) in vec.iter() {
|
||||
read_only |= ro;
|
||||
hide_passwords |= hp;
|
||||
}
|
||||
|
||||
Some((read_only, hide_passwords))
|
||||
} else {
|
||||
// This cipher isn't in any collections accessible to the user.
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_write_accessible_to_user(&self, user_uuid: &str, conn: &DbConn) -> bool {
|
||||
match self.get_access_restrictions(&user_uuid, &conn) {
|
||||
Some((read_only, _hide_passwords)) => !read_only,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_accessible_to_user(&self, user_uuid: &str, conn: &DbConn) -> bool {
|
||||
ciphers::table
|
||||
.filter(ciphers::uuid.eq(&self.uuid))
|
||||
.left_join(
|
||||
users_organizations::table.on(ciphers::organization_uuid
|
||||
.eq(users_organizations::org_uuid.nullable())
|
||||
.and(users_organizations::user_uuid.eq(user_uuid))),
|
||||
)
|
||||
.left_join(ciphers_collections::table)
|
||||
.left_join(
|
||||
users_collections::table
|
||||
.on(ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)),
|
||||
)
|
||||
.filter(ciphers::user_uuid.eq(user_uuid).or(
|
||||
// Cipher owner
|
||||
users_organizations::access_all.eq(true).or(
|
||||
// access_all in Organization
|
||||
users_organizations::atype.le(UserOrgType::Admin as i32).or(
|
||||
// Org admin or owner
|
||||
users_collections::user_uuid.eq(user_uuid), // Access to Collection
|
||||
),
|
||||
),
|
||||
))
|
||||
.select(ciphers::all_columns)
|
||||
.first::<Self>(&**conn)
|
||||
.ok()
|
||||
.is_some()
|
||||
self.get_access_restrictions(&user_uuid, &conn).is_some()
|
||||
}
|
||||
|
||||
pub fn get_folder_uuid(&self, user_uuid: &str, conn: &DbConn) -> Option<String> {
|
||||
@@ -340,12 +391,30 @@ impl Cipher {
|
||||
.load::<Self>(&**conn).expect("Error loading ciphers")
|
||||
}
|
||||
|
||||
pub fn count_owned_by_user(user_uuid: &str, conn: &DbConn) -> i64 {
|
||||
ciphers::table
|
||||
.filter(ciphers::user_uuid.eq(user_uuid))
|
||||
.count()
|
||||
.first::<i64>(&**conn)
|
||||
.ok()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec<Self> {
|
||||
ciphers::table
|
||||
.filter(ciphers::organization_uuid.eq(org_uuid))
|
||||
.load::<Self>(&**conn).expect("Error loading ciphers")
|
||||
}
|
||||
|
||||
pub fn count_by_org(org_uuid: &str, conn: &DbConn) -> i64 {
|
||||
ciphers::table
|
||||
.filter(ciphers::organization_uuid.eq(org_uuid))
|
||||
.count()
|
||||
.first::<i64>(&**conn)
|
||||
.ok()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn find_by_folder(folder_uuid: &str, conn: &DbConn) -> Vec<Self> {
|
||||
folders_ciphers::table.inner_join(ciphers::table)
|
||||
.filter(folders_ciphers::folder_uuid.eq(folder_uuid))
|
||||
|
@@ -35,7 +35,6 @@ impl Collection {
|
||||
|
||||
use crate::db::schema::*;
|
||||
use crate::db::DbConn;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
@@ -200,6 +199,7 @@ pub struct CollectionUser {
|
||||
pub user_uuid: String,
|
||||
pub collection_uuid: String,
|
||||
pub read_only: bool,
|
||||
pub hide_passwords: bool,
|
||||
}
|
||||
|
||||
/// Database methods
|
||||
@@ -215,7 +215,7 @@ impl CollectionUser {
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgresql")]
|
||||
pub fn save(user_uuid: &str, collection_uuid: &str, read_only: bool, conn: &DbConn) -> EmptyResult {
|
||||
pub fn save(user_uuid: &str, collection_uuid: &str, read_only: bool, hide_passwords: bool, conn: &DbConn) -> EmptyResult {
|
||||
User::update_uuid_revision(&user_uuid, conn);
|
||||
|
||||
diesel::insert_into(users_collections::table)
|
||||
@@ -223,16 +223,20 @@ impl CollectionUser {
|
||||
users_collections::user_uuid.eq(user_uuid),
|
||||
users_collections::collection_uuid.eq(collection_uuid),
|
||||
users_collections::read_only.eq(read_only),
|
||||
users_collections::hide_passwords.eq(hide_passwords),
|
||||
))
|
||||
.on_conflict((users_collections::user_uuid, users_collections::collection_uuid))
|
||||
.do_update()
|
||||
.set(users_collections::read_only.eq(read_only))
|
||||
.set((
|
||||
users_collections::read_only.eq(read_only),
|
||||
users_collections::hide_passwords.eq(hide_passwords),
|
||||
))
|
||||
.execute(&**conn)
|
||||
.map_res("Error adding user to collection")
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "postgresql"))]
|
||||
pub fn save(user_uuid: &str, collection_uuid: &str, read_only: bool, conn: &DbConn) -> EmptyResult {
|
||||
pub fn save(user_uuid: &str, collection_uuid: &str, read_only: bool, hide_passwords: bool, conn: &DbConn) -> EmptyResult {
|
||||
User::update_uuid_revision(&user_uuid, conn);
|
||||
|
||||
diesel::replace_into(users_collections::table)
|
||||
@@ -240,6 +244,7 @@ impl CollectionUser {
|
||||
users_collections::user_uuid.eq(user_uuid),
|
||||
users_collections::collection_uuid.eq(collection_uuid),
|
||||
users_collections::read_only.eq(read_only),
|
||||
users_collections::hide_passwords.eq(hide_passwords),
|
||||
))
|
||||
.execute(&**conn)
|
||||
.map_res("Error adding user to collection")
|
||||
|
@@ -5,6 +5,7 @@ use crate::CONFIG;
|
||||
|
||||
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)]
|
||||
#[table_name = "devices"]
|
||||
#[changeset_options(treat_none_as_null="true")]
|
||||
#[belongs_to(User, foreign_key = "user_uuid")]
|
||||
#[primary_key(uuid)]
|
||||
pub struct Device {
|
||||
@@ -76,7 +77,6 @@ impl Device {
|
||||
let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect();
|
||||
let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
||||
|
||||
|
||||
// Create the JWT claims struct, to send to the client
|
||||
use crate::auth::{encode_jwt, LoginJWTClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER};
|
||||
let claims = LoginJWTClaims {
|
||||
@@ -107,7 +107,6 @@ impl Device {
|
||||
|
||||
use crate::db::schema::devices;
|
||||
use crate::db::DbConn;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
|
@@ -63,7 +63,6 @@ impl FolderCipher {
|
||||
|
||||
use crate::db::schema::{folders, folders_ciphers};
|
||||
use crate::db::DbConn;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
|
@@ -1,4 +1,3 @@
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -22,7 +21,7 @@ pub struct OrgPolicy {
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(FromPrimitive)]
|
||||
#[derive(num_derive::FromPrimitive)]
|
||||
pub enum OrgPolicyType {
|
||||
TwoFactorAuthentication = 0,
|
||||
MasterPassword = 1,
|
||||
|
@@ -34,7 +34,7 @@ pub enum UserOrgStatus {
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
#[derive(FromPrimitive)]
|
||||
#[derive(num_derive::FromPrimitive)]
|
||||
pub enum UserOrgType {
|
||||
Owner = 0,
|
||||
Admin = 1,
|
||||
@@ -165,9 +165,9 @@ impl Organization {
|
||||
"UsePolicies": true,
|
||||
|
||||
"BusinessName": null,
|
||||
"BusinessAddress1": null,
|
||||
"BusinessAddress2": null,
|
||||
"BusinessAddress3": null,
|
||||
"BusinessAddress1": null,
|
||||
"BusinessAddress2": null,
|
||||
"BusinessAddress3": null,
|
||||
"BusinessCountry": null,
|
||||
"BusinessTaxNumber": null,
|
||||
|
||||
@@ -198,7 +198,6 @@ impl UserOrganization {
|
||||
|
||||
use crate::db::schema::{ciphers_collections, organizations, users_collections, users_organizations};
|
||||
use crate::db::DbConn;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
@@ -256,6 +255,10 @@ impl Organization {
|
||||
.first::<Self>(&**conn)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn get_all(conn: &DbConn) -> Vec<Self> {
|
||||
organizations::table.load::<Self>(&**conn).expect("Error loading organizations")
|
||||
}
|
||||
}
|
||||
|
||||
impl UserOrganization {
|
||||
@@ -275,6 +278,8 @@ impl UserOrganization {
|
||||
"UseGroups": false,
|
||||
"UseTotp": true,
|
||||
"UsePolicies": true,
|
||||
"UseApi": false,
|
||||
"SelfHost": true,
|
||||
|
||||
"MaxStorageGb": 10, // The value doesn't matter, we don't check server-side
|
||||
|
||||
@@ -305,10 +310,11 @@ impl UserOrganization {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_json_collection_user_details(&self, read_only: bool) -> Value {
|
||||
pub fn to_json_user_access_restrictions(&self, col_user: &CollectionUser) -> Value {
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"ReadOnly": read_only
|
||||
"ReadOnly": col_user.read_only,
|
||||
"HidePasswords": col_user.hide_passwords,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -319,7 +325,11 @@ impl UserOrganization {
|
||||
let collections = CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn);
|
||||
collections
|
||||
.iter()
|
||||
.map(|c| json!({"Id": c.collection_uuid, "ReadOnly": c.read_only}))
|
||||
.map(|c| json!({
|
||||
"Id": c.collection_uuid,
|
||||
"ReadOnly": c.read_only,
|
||||
"HidePasswords": c.hide_passwords,
|
||||
}))
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -383,8 +393,13 @@ impl UserOrganization {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn has_status(self, status: UserOrgStatus) -> bool {
|
||||
self.status == status as i32
|
||||
}
|
||||
|
||||
pub fn has_full_access(self) -> bool {
|
||||
self.access_all || self.atype >= UserOrgType::Admin
|
||||
(self.access_all || self.atype >= UserOrgType::Admin) &&
|
||||
self.has_status(UserOrgStatus::Confirmed)
|
||||
}
|
||||
|
||||
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
|
||||
@@ -432,6 +447,15 @@ impl UserOrganization {
|
||||
.expect("Error loading user organizations")
|
||||
}
|
||||
|
||||
pub fn count_by_org(org_uuid: &str, conn: &DbConn) -> i64 {
|
||||
users_organizations::table
|
||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||
.count()
|
||||
.first::<i64>(&**conn)
|
||||
.ok()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn find_by_org_and_type(org_uuid: &str, atype: i32, conn: &DbConn) -> Vec<Self> {
|
||||
users_organizations::table
|
||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||
|
@@ -1,4 +1,3 @@
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -23,7 +22,7 @@ pub struct TwoFactor {
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(FromPrimitive)]
|
||||
#[derive(num_derive::FromPrimitive)]
|
||||
pub enum TwoFactorType {
|
||||
Authenticator = 0,
|
||||
Email = 1,
|
||||
@@ -60,7 +59,7 @@ impl TwoFactor {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_json_list(&self) -> Value {
|
||||
pub fn to_json_provider(&self) -> Value {
|
||||
json!({
|
||||
"Enabled": self.enabled,
|
||||
"Type": self.atype,
|
||||
|
@@ -6,6 +6,7 @@ use crate::CONFIG;
|
||||
|
||||
#[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[table_name = "users"]
|
||||
#[changeset_options(treat_none_as_null="true")]
|
||||
#[primary_key(uuid)]
|
||||
pub struct User {
|
||||
pub uuid: String,
|
||||
@@ -120,7 +121,6 @@ impl User {
|
||||
use super::{Cipher, Device, Folder, TwoFactor, UserOrgType, UserOrganization};
|
||||
use crate::db::schema::{invitations, users};
|
||||
use crate::db::DbConn;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
@@ -274,7 +274,7 @@ pub struct Invitation {
|
||||
}
|
||||
|
||||
impl Invitation {
|
||||
pub fn new(email: String) -> Self {
|
||||
pub const fn new(email: String) -> Self {
|
||||
Self { email }
|
||||
}
|
||||
|
||||
|
@@ -22,6 +22,7 @@ table! {
|
||||
data -> Text,
|
||||
favorite -> Bool,
|
||||
password_history -> Nullable<Text>,
|
||||
deleted_at -> Nullable<Datetime>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +141,7 @@ table! {
|
||||
user_uuid -> Varchar,
|
||||
collection_uuid -> Varchar,
|
||||
read_only -> Bool,
|
||||
hide_passwords -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -22,6 +22,7 @@ table! {
|
||||
data -> Text,
|
||||
favorite -> Bool,
|
||||
password_history -> Nullable<Text>,
|
||||
deleted_at -> Nullable<Timestamp>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +141,7 @@ table! {
|
||||
user_uuid -> Text,
|
||||
collection_uuid -> Text,
|
||||
read_only -> Bool,
|
||||
hide_passwords -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -22,6 +22,7 @@ table! {
|
||||
data -> Text,
|
||||
favorite -> Bool,
|
||||
password_history -> Nullable<Text>,
|
||||
deleted_at -> Nullable<Timestamp>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +141,7 @@ table! {
|
||||
user_uuid -> Text,
|
||||
collection_uuid -> Text,
|
||||
read_only -> Bool,
|
||||
hide_passwords -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
|
29
src/error.rs
29
src/error.rs
@@ -7,7 +7,6 @@ macro_rules! make_error {
|
||||
( $( $name:ident ( $ty:ty ): $src_fn:expr, $usr_msg_fun:expr ),+ $(,)? ) => {
|
||||
const BAD_REQUEST: u16 = 400;
|
||||
|
||||
#[derive(Display)]
|
||||
pub enum ErrorKind { $($name( $ty )),+ }
|
||||
pub struct Error { message: String, error: ErrorKind, error_code: u16 }
|
||||
|
||||
@@ -42,13 +41,16 @@ use reqwest::Error as ReqErr;
|
||||
use serde_json::{Error as SerdeErr, Value};
|
||||
use std::io::Error as IOErr;
|
||||
|
||||
use std::option::NoneError as NoneErr;
|
||||
use std::time::SystemTimeError as TimeErr;
|
||||
use u2f::u2ferror::U2fError as U2fErr;
|
||||
use yubico::yubicoerror::YubicoError as YubiErr;
|
||||
use lettre::smtp::error::Error as LettreErr;
|
||||
|
||||
#[derive(Display, Serialize)]
|
||||
use lettre::address::AddressError as AddrErr;
|
||||
use lettre::error::Error as LettreErr;
|
||||
use lettre::message::mime::FromStrError as FromStrErr;
|
||||
use lettre::transport::smtp::error::Error as SmtpErr;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Empty {}
|
||||
|
||||
// Error struct
|
||||
@@ -74,14 +76,11 @@ make_error! {
|
||||
ReqError(ReqErr): _has_source, _api_error,
|
||||
RegexError(RegexErr): _has_source, _api_error,
|
||||
YubiError(YubiErr): _has_source, _api_error,
|
||||
LetreErr(LettreErr): _has_source, _api_error,
|
||||
}
|
||||
|
||||
// This is implemented by hand because NoneError doesn't implement neither Display nor Error
|
||||
impl From<NoneErr> for Error {
|
||||
fn from(_: NoneErr) -> Self {
|
||||
Error::from(("NoneError", String::new()))
|
||||
}
|
||||
LetreError(LettreErr): _has_source, _api_error,
|
||||
AddressError(AddrErr): _has_source, _api_error,
|
||||
SmtpError(SmtpErr): _has_source, _api_error,
|
||||
FromStrError(FromStrErr): _has_source, _api_error,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Error {
|
||||
@@ -118,7 +117,7 @@ impl Error {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_code(mut self, code: u16) -> Self {
|
||||
pub const fn with_code(mut self, code: u16) -> Self {
|
||||
self.error_code = code;
|
||||
self
|
||||
}
|
||||
@@ -146,7 +145,7 @@ impl<S> MapResult<S> for Option<S> {
|
||||
}
|
||||
}
|
||||
|
||||
fn _has_source<T>(e: T) -> Option<T> {
|
||||
const fn _has_source<T>(e: T) -> Option<T> {
|
||||
Some(e)
|
||||
}
|
||||
fn _no_source<T, S>(_: T) -> Option<S> {
|
||||
@@ -234,10 +233,10 @@ macro_rules! err_json {
|
||||
macro_rules! err_handler {
|
||||
($expr:expr) => {{
|
||||
error!(target: "auth", "Unauthorized Error: {}", $expr);
|
||||
return rocket::Outcome::Failure((rocket::http::Status::Unauthorized, $expr));
|
||||
return ::rocket::request::Outcome::Failure((rocket::http::Status::Unauthorized, $expr));
|
||||
}};
|
||||
($usr_msg:expr, $log_value:expr) => {{
|
||||
error!(target: "auth", "Unauthorized Error: {}. {}", $usr_msg, $log_value);
|
||||
return rocket::Outcome::Failure((rocket::http::Status::Unauthorized, $usr_msg));
|
||||
return ::rocket::request::Outcome::Failure((rocket::http::Status::Unauthorized, $usr_msg));
|
||||
}};
|
||||
}
|
||||
|
163
src/mail.rs
163
src/mail.rs
@@ -1,19 +1,23 @@
|
||||
use lettre::smtp::authentication::Credentials;
|
||||
use lettre::smtp::authentication::Mechanism as SmtpAuthMechanism;
|
||||
use lettre::smtp::ConnectionReuseParameters;
|
||||
use lettre::{
|
||||
builder::{EmailBuilder, MimeMultipartType, PartBuilder},
|
||||
ClientSecurity, ClientTlsParameters, SmtpClient, SmtpTransport, Transport,
|
||||
};
|
||||
use std::{env, str::FromStr};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use chrono_tz::Tz;
|
||||
use native_tls::{Protocol, TlsConnector};
|
||||
use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
|
||||
use quoted_printable::encode_to_str;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
use crate::auth::{encode_jwt, generate_delete_claims, generate_invite_claims, generate_verify_email_claims};
|
||||
use crate::error::Error;
|
||||
use crate::CONFIG;
|
||||
use chrono::NaiveDateTime;
|
||||
use lettre::{
|
||||
message::{header, Mailbox, Message, MultiPart, SinglePart},
|
||||
transport::smtp::authentication::{Credentials, Mechanism as SmtpAuthMechanism},
|
||||
transport::smtp::extension::ClientId,
|
||||
Address, SmtpTransport, Tls, TlsParameters, Transport,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api::EmptyResult,
|
||||
auth::{encode_jwt, generate_delete_claims, generate_invite_claims, generate_verify_email_claims},
|
||||
error::Error,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
fn mailer() -> SmtpTransport {
|
||||
let host = CONFIG.smtp_host().unwrap();
|
||||
@@ -24,43 +28,45 @@ fn mailer() -> SmtpTransport {
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let params = ClientTlsParameters::new(host.clone(), tls);
|
||||
let params = TlsParameters::new(host.clone(), tls);
|
||||
|
||||
if CONFIG.smtp_explicit_tls() {
|
||||
ClientSecurity::Wrapper(params)
|
||||
Tls::Wrapper(params)
|
||||
} else {
|
||||
ClientSecurity::Required(params)
|
||||
Tls::Required(params)
|
||||
}
|
||||
} else {
|
||||
ClientSecurity::None
|
||||
Tls::None
|
||||
};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
let smtp_client = SmtpClient::new((host.as_str(), CONFIG.smtp_port()), client_security).unwrap();
|
||||
let smtp_client = SmtpTransport::builder(host).port(CONFIG.smtp_port()).tls(client_security);
|
||||
|
||||
let smtp_client = match (&CONFIG.smtp_username(), &CONFIG.smtp_password()) {
|
||||
(Some(user), Some(pass)) => smtp_client.credentials(Credentials::new(user.clone(), pass.clone())),
|
||||
let smtp_client = match (CONFIG.smtp_username(), CONFIG.smtp_password()) {
|
||||
(Some(user), Some(pass)) => smtp_client.credentials(Credentials::new(user, pass)),
|
||||
_ => smtp_client,
|
||||
};
|
||||
|
||||
let smtp_client = match CONFIG.helo_name() {
|
||||
Some(helo_name) => smtp_client.hello_name(ClientId::new(helo_name)),
|
||||
None => smtp_client,
|
||||
};
|
||||
|
||||
let smtp_client = match CONFIG.smtp_auth_mechanism() {
|
||||
Some(mechanism) => {
|
||||
let correct_mechanism = format!("\"{}\"", crate::util::upcase_first(&mechanism.trim_matches('"')));
|
||||
let correct_mechanism = format!("\"{}\"", crate::util::upcase_first(mechanism.trim_matches('"')));
|
||||
|
||||
// TODO: Allow more than one mechanism
|
||||
match serde_json::from_str::<SmtpAuthMechanism>(&correct_mechanism) {
|
||||
Ok(auth_mechanism) => smtp_client.authentication_mechanism(auth_mechanism),
|
||||
Ok(auth_mechanism) => smtp_client.authentication(vec![auth_mechanism]),
|
||||
_ => panic!("Failure to parse mechanism. Is it proper Json? Eg. `\"Plain\"` not `Plain`"),
|
||||
}
|
||||
}
|
||||
_ => smtp_client,
|
||||
};
|
||||
|
||||
smtp_client
|
||||
.smtp_utf8(true)
|
||||
.timeout(Some(Duration::from_secs(CONFIG.smtp_timeout())))
|
||||
.connection_reuse(ConnectionReuseParameters::NoReuse)
|
||||
.transport()
|
||||
smtp_client.timeout(Some(Duration::from_secs(CONFIG.smtp_timeout()))).build()
|
||||
}
|
||||
|
||||
fn get_text(template_name: &'static str, data: serde_json::Value) -> Result<(String, String, String), Error> {
|
||||
@@ -86,6 +92,22 @@ fn get_template(template_name: &str, data: &serde_json::Value) -> Result<(String
|
||||
Ok((subject, body))
|
||||
}
|
||||
|
||||
pub fn format_datetime(dt: &DateTime<Local>) -> String {
|
||||
let fmt = "%A, %B %_d, %Y at %r %Z";
|
||||
|
||||
// With a DateTime<Local>, `%Z` formats as the time zone's UTC offset
|
||||
// (e.g., `+00:00`). If the `TZ` environment variable is set, try to
|
||||
// format as a time zone abbreviation instead (e.g., `UTC`).
|
||||
if let Ok(tz) = env::var("TZ") {
|
||||
if let Ok(tz) = tz.parse::<Tz>() {
|
||||
return dt.with_timezone(&tz).format(fmt).to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, fall back to just displaying the UTC offset.
|
||||
dt.format(fmt).to_string()
|
||||
}
|
||||
|
||||
pub fn send_password_hint(address: &str, hint: Option<String>) -> EmptyResult {
|
||||
let template_name = if hint.is_some() {
|
||||
"email/pw_hint_some"
|
||||
@@ -95,7 +117,7 @@ pub fn send_password_hint(address: &str, hint: Option<String>) -> EmptyResult {
|
||||
|
||||
let (subject, body_html, body_text) = get_text(template_name, json!({ "hint": hint, "url": CONFIG.domain() }))?;
|
||||
|
||||
send_email(&address, &subject, &body_html, &body_text)
|
||||
send_email(address, &subject, &body_html, &body_text)
|
||||
}
|
||||
|
||||
pub fn send_delete_account(address: &str, uuid: &str) -> EmptyResult {
|
||||
@@ -112,7 +134,7 @@ pub fn send_delete_account(address: &str, uuid: &str) -> EmptyResult {
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(&address, &subject, &body_html, &body_text)
|
||||
send_email(address, &subject, &body_html, &body_text)
|
||||
}
|
||||
|
||||
pub fn send_verify_email(address: &str, uuid: &str) -> EmptyResult {
|
||||
@@ -129,7 +151,7 @@ pub fn send_verify_email(address: &str, uuid: &str) -> EmptyResult {
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(&address, &subject, &body_html, &body_text)
|
||||
send_email(address, &subject, &body_html, &body_text)
|
||||
}
|
||||
|
||||
pub fn send_welcome(address: &str) -> EmptyResult {
|
||||
@@ -140,7 +162,7 @@ pub fn send_welcome(address: &str) -> EmptyResult {
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(&address, &subject, &body_html, &body_text)
|
||||
send_email(address, &subject, &body_html, &body_text)
|
||||
}
|
||||
|
||||
pub fn send_welcome_must_verify(address: &str, uuid: &str) -> EmptyResult {
|
||||
@@ -156,7 +178,7 @@ pub fn send_welcome_must_verify(address: &str, uuid: &str) -> EmptyResult {
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(&address, &subject, &body_html, &body_text)
|
||||
send_email(address, &subject, &body_html, &body_text)
|
||||
}
|
||||
|
||||
pub fn send_invite(
|
||||
@@ -188,7 +210,7 @@ pub fn send_invite(
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(&address, &subject, &body_html, &body_text)
|
||||
send_email(address, &subject, &body_html, &body_text)
|
||||
}
|
||||
|
||||
pub fn send_invite_accepted(new_user_email: &str, address: &str, org_name: &str) -> EmptyResult {
|
||||
@@ -201,7 +223,7 @@ pub fn send_invite_accepted(new_user_email: &str, address: &str, org_name: &str)
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(&address, &subject, &body_html, &body_text)
|
||||
send_email(address, &subject, &body_html, &body_text)
|
||||
}
|
||||
|
||||
pub fn send_invite_confirmed(address: &str, org_name: &str) -> EmptyResult {
|
||||
@@ -213,26 +235,24 @@ pub fn send_invite_confirmed(address: &str, org_name: &str) -> EmptyResult {
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(&address, &subject, &body_html, &body_text)
|
||||
send_email(address, &subject, &body_html, &body_text)
|
||||
}
|
||||
|
||||
pub fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTime, device: &str) -> EmptyResult {
|
||||
pub fn send_new_device_logged_in(address: &str, ip: &str, dt: &DateTime<Local>, device: &str) -> EmptyResult {
|
||||
use crate::util::upcase_first;
|
||||
let device = upcase_first(device);
|
||||
|
||||
let datetime = dt.format("%A, %B %_d, %Y at %H:%M").to_string();
|
||||
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/new_device_logged_in",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"ip": ip,
|
||||
"device": device,
|
||||
"datetime": datetime,
|
||||
"datetime": format_datetime(dt),
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(&address, &subject, &body_html, &body_text)
|
||||
send_email(address, &subject, &body_html, &body_text)
|
||||
}
|
||||
|
||||
pub fn send_token(address: &str, token: &str) -> EmptyResult {
|
||||
@@ -244,7 +264,7 @@ pub fn send_token(address: &str, token: &str) -> EmptyResult {
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(&address, &subject, &body_html, &body_text)
|
||||
send_email(address, &subject, &body_html, &body_text)
|
||||
}
|
||||
|
||||
pub fn send_change_email(address: &str, token: &str) -> EmptyResult {
|
||||
@@ -256,7 +276,7 @@ pub fn send_change_email(address: &str, token: &str) -> EmptyResult {
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(&address, &subject, &body_html, &body_text)
|
||||
send_email(address, &subject, &body_html, &body_text)
|
||||
}
|
||||
|
||||
pub fn send_test(address: &str) -> EmptyResult {
|
||||
@@ -267,7 +287,7 @@ pub fn send_test(address: &str) -> EmptyResult {
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(&address, &subject, &body_html, &body_text)
|
||||
send_email(address, &subject, &body_html, &body_text)
|
||||
}
|
||||
|
||||
fn send_email(address: &str, subject: &str, body_html: &str, body_text: &str) -> EmptyResult {
|
||||
@@ -283,38 +303,35 @@ fn send_email(address: &str, subject: &str, body_html: &str, body_text: &str) ->
|
||||
|
||||
let address = format!("{}@{}", address_split[1], domain_puny);
|
||||
|
||||
let html = PartBuilder::new()
|
||||
.body(encode_to_str(body_html))
|
||||
.header(("Content-Type", "text/html; charset=utf-8"))
|
||||
.header(("Content-Transfer-Encoding", "quoted-printable"))
|
||||
.build();
|
||||
let data = MultiPart::mixed()
|
||||
.multipart(
|
||||
MultiPart::alternative()
|
||||
.singlepart(
|
||||
SinglePart::quoted_printable()
|
||||
.header(header::ContentType("text/plain; charset=utf-8".parse()?))
|
||||
.body(body_text),
|
||||
)
|
||||
.multipart(
|
||||
MultiPart::related().singlepart(
|
||||
SinglePart::quoted_printable()
|
||||
.header(header::ContentType("text/html; charset=utf-8".parse()?))
|
||||
.body(body_html),
|
||||
)
|
||||
// .singlepart(SinglePart::base64() -- Inline files would go here
|
||||
),
|
||||
)
|
||||
// .singlepart(SinglePart::base64() -- Attachments would go here
|
||||
;
|
||||
|
||||
let text = PartBuilder::new()
|
||||
.body(encode_to_str(body_text))
|
||||
.header(("Content-Type", "text/plain; charset=utf-8"))
|
||||
.header(("Content-Transfer-Encoding", "quoted-printable"))
|
||||
.build();
|
||||
|
||||
let alternative = PartBuilder::new()
|
||||
.message_type(MimeMultipartType::Alternative)
|
||||
.child(text)
|
||||
.child(html);
|
||||
|
||||
let email = EmailBuilder::new()
|
||||
.to(address)
|
||||
.from((CONFIG.smtp_from().as_str(), CONFIG.smtp_from_name().as_str()))
|
||||
let email = Message::builder()
|
||||
.to(Mailbox::new(None, Address::from_str(&address)?))
|
||||
.from(Mailbox::new(
|
||||
Some(CONFIG.smtp_from_name()),
|
||||
Address::from_str(&CONFIG.smtp_from())?,
|
||||
))
|
||||
.subject(subject)
|
||||
.child(alternative.build())
|
||||
.build()
|
||||
.map_err(|e| Error::new("Error building email", e.to_string()))?;
|
||||
.multipart(data)?;
|
||||
|
||||
let mut transport = mailer();
|
||||
|
||||
let result = transport.send(email);
|
||||
|
||||
// Explicitly close the connection, in case of error
|
||||
transport.close();
|
||||
|
||||
result?;
|
||||
let _ = mailer().send(&email)?;
|
||||
Ok(())
|
||||
}
|
||||
|
52
src/main.rs
52
src/main.rs
@@ -1,4 +1,5 @@
|
||||
#![feature(proc_macro_hygiene, vec_remove_item, try_trait, ip)]
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(feature = "unstable", feature(ip))]
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
extern crate openssl;
|
||||
@@ -14,19 +15,15 @@ extern crate log;
|
||||
extern crate diesel;
|
||||
#[macro_use]
|
||||
extern crate diesel_migrations;
|
||||
#[macro_use]
|
||||
extern crate derive_more;
|
||||
#[macro_use]
|
||||
extern crate num_derive;
|
||||
|
||||
extern crate backtrace;
|
||||
|
||||
use std::{
|
||||
fmt, // For panic logging
|
||||
fs::create_dir_all,
|
||||
panic,
|
||||
path::Path,
|
||||
process::{exit, Command},
|
||||
str::FromStr,
|
||||
panic, thread, fmt // For panic logging
|
||||
thread,
|
||||
};
|
||||
|
||||
#[macro_use]
|
||||
@@ -88,7 +85,7 @@ fn main() {
|
||||
fn parse_args() {
|
||||
let opt = Opt::from_args();
|
||||
if opt.version {
|
||||
if let Some(version) = option_env!("GIT_VERSION") {
|
||||
if let Some(version) = option_env!("BWRS_VERSION") {
|
||||
println!("bitwarden_rs {}", version);
|
||||
} else {
|
||||
println!("bitwarden_rs (Version info from Git not present)");
|
||||
@@ -101,14 +98,17 @@ fn launch_info() {
|
||||
println!("/--------------------------------------------------------------------\\");
|
||||
println!("| Starting Bitwarden_RS |");
|
||||
|
||||
if let Some(version) = option_env!("GIT_VERSION") {
|
||||
if let Some(version) = option_env!("BWRS_VERSION") {
|
||||
println!("|{:^68}|", format!("Version {}", version));
|
||||
}
|
||||
|
||||
println!("|--------------------------------------------------------------------|");
|
||||
println!("| This is an *unofficial* Bitwarden implementation, DO NOT use the |");
|
||||
println!("| official channels to report bugs/features, regardless of client. |");
|
||||
println!("| Report URL: https://github.com/dani-garcia/bitwarden_rs/issues/new |");
|
||||
println!("| Send usage/configuration questions or feature requests to: |");
|
||||
println!("| https://bitwardenrs.discourse.group/ |");
|
||||
println!("| Report suspected bugs/issues in the software itself at: |");
|
||||
println!("| https://github.com/dani-garcia/bitwarden_rs/issues/new |");
|
||||
println!("\\--------------------------------------------------------------------/\n");
|
||||
}
|
||||
|
||||
@@ -130,8 +130,8 @@ fn init_logging(level: log::LevelFilter) -> Result<(), fern::InitError> {
|
||||
if CONFIG.extended_logging() {
|
||||
logger = logger.format(|out, message, record| {
|
||||
out.finish(format_args!(
|
||||
"{}[{}][{}] {}",
|
||||
chrono::Local::now().format("[%Y-%m-%d %H:%M:%S]"),
|
||||
"[{}][{}][{}] {}",
|
||||
chrono::Local::now().format(&CONFIG.log_timestamp_format()),
|
||||
record.target(),
|
||||
record.level(),
|
||||
message
|
||||
@@ -180,15 +180,13 @@ fn init_logging(level: log::LevelFilter) -> Result<(), fern::InitError> {
|
||||
Shim(backtrace)
|
||||
);
|
||||
}
|
||||
None => {
|
||||
error!(
|
||||
target: "panic",
|
||||
"thread '{}' panicked at '{}'{:?}",
|
||||
thread,
|
||||
msg,
|
||||
Shim(backtrace)
|
||||
)
|
||||
}
|
||||
None => error!(
|
||||
target: "panic",
|
||||
"thread '{}' panicked at '{}'{:?}",
|
||||
thread,
|
||||
msg,
|
||||
Shim(backtrace)
|
||||
),
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -338,14 +336,11 @@ mod migrations {
|
||||
}
|
||||
|
||||
fn launch_rocket(extra_debug: bool) {
|
||||
// Create Rocket object, this stores current log level and sets its own
|
||||
let rocket = rocket::ignite();
|
||||
|
||||
let basepath = &CONFIG.domain_path();
|
||||
|
||||
// If adding more paths here, consider also adding them to
|
||||
// crate::utils::LOGGED_ROUTES to make sure they appear in the log
|
||||
let rocket = rocket
|
||||
let result = rocket::ignite()
|
||||
.mount(&[basepath, "/"].concat(), api::web_routes())
|
||||
.mount(&[basepath, "/api"].concat(), api::core_routes())
|
||||
.mount(&[basepath, "/admin"].concat(), api::admin_routes())
|
||||
@@ -356,9 +351,10 @@ fn launch_rocket(extra_debug: bool) {
|
||||
.manage(api::start_notification_server())
|
||||
.attach(util::AppHeaders())
|
||||
.attach(util::CORS())
|
||||
.attach(util::BetterLogging(extra_debug));
|
||||
.attach(util::BetterLogging(extra_debug))
|
||||
.launch();
|
||||
|
||||
// Launch and print error if there is one
|
||||
// The launch will restore the original logging level
|
||||
error!("Launch error {:#?}", rocket.launch());
|
||||
error!("Launch error {:#?}", result);
|
||||
}
|
||||
|
@@ -108,7 +108,9 @@
|
||||
"microsoftonline.com",
|
||||
"office365.com",
|
||||
"microsoftstore.com",
|
||||
"xbox.com"
|
||||
"xbox.com",
|
||||
"azure.com",
|
||||
"windowsazure.com"
|
||||
],
|
||||
"Excluded": false
|
||||
},
|
||||
@@ -126,8 +128,7 @@
|
||||
"Type": 12,
|
||||
"Domains": [
|
||||
"overture.com",
|
||||
"yahoo.com",
|
||||
"flickr.com"
|
||||
"yahoo.com"
|
||||
],
|
||||
"Excluded": false
|
||||
},
|
||||
@@ -192,7 +193,6 @@
|
||||
"amazon.it",
|
||||
"amazon.com.au",
|
||||
"amazon.co.nz",
|
||||
"amazon.co.jp",
|
||||
"amazon.in"
|
||||
],
|
||||
"Excluded": false
|
||||
@@ -777,69 +777,69 @@
|
||||
"Excluded": false
|
||||
},
|
||||
{
|
||||
"Type": 76,
|
||||
"Domains": [
|
||||
"docusign.com",
|
||||
"docusign.net"
|
||||
],
|
||||
"Excluded": false
|
||||
"Type": 76,
|
||||
"Domains": [
|
||||
"docusign.com",
|
||||
"docusign.net"
|
||||
],
|
||||
"Excluded": false
|
||||
},
|
||||
{
|
||||
"Type": 77,
|
||||
"Domains": [
|
||||
"envato.com",
|
||||
"themeforest.net",
|
||||
"codecanyon.net",
|
||||
"videohive.net",
|
||||
"audiojungle.net",
|
||||
"graphicriver.net",
|
||||
"photodune.net",
|
||||
"3docean.net"
|
||||
],
|
||||
"Excluded": false
|
||||
"Type": 77,
|
||||
"Domains": [
|
||||
"envato.com",
|
||||
"themeforest.net",
|
||||
"codecanyon.net",
|
||||
"videohive.net",
|
||||
"audiojungle.net",
|
||||
"graphicriver.net",
|
||||
"photodune.net",
|
||||
"3docean.net"
|
||||
],
|
||||
"Excluded": false
|
||||
},
|
||||
{
|
||||
"Type": 78,
|
||||
"Domains": [
|
||||
"x10hosting.com",
|
||||
"x10premium.com"
|
||||
],
|
||||
"Excluded": false
|
||||
"Type": 78,
|
||||
"Domains": [
|
||||
"x10hosting.com",
|
||||
"x10premium.com"
|
||||
],
|
||||
"Excluded": false
|
||||
},
|
||||
{
|
||||
"Type": 79,
|
||||
"Domains": [
|
||||
"dnsomatic.com",
|
||||
"opendns.com",
|
||||
"umbrella.com"
|
||||
],
|
||||
"Excluded": false
|
||||
"Type": 79,
|
||||
"Domains": [
|
||||
"dnsomatic.com",
|
||||
"opendns.com",
|
||||
"umbrella.com"
|
||||
],
|
||||
"Excluded": false
|
||||
},
|
||||
{
|
||||
"Type": 80,
|
||||
"Domains": [
|
||||
"cagreatamerica.com",
|
||||
"canadaswonderland.com",
|
||||
"carowinds.com",
|
||||
"cedarfair.com",
|
||||
"cedarpoint.com",
|
||||
"dorneypark.com",
|
||||
"kingsdominion.com",
|
||||
"knotts.com",
|
||||
"miadventure.com",
|
||||
"schlitterbahn.com",
|
||||
"valleyfair.com",
|
||||
"visitkingsisland.com",
|
||||
"worldsoffun.com"
|
||||
],
|
||||
"Excluded": false
|
||||
"Type": 80,
|
||||
"Domains": [
|
||||
"cagreatamerica.com",
|
||||
"canadaswonderland.com",
|
||||
"carowinds.com",
|
||||
"cedarfair.com",
|
||||
"cedarpoint.com",
|
||||
"dorneypark.com",
|
||||
"kingsdominion.com",
|
||||
"knotts.com",
|
||||
"miadventure.com",
|
||||
"schlitterbahn.com",
|
||||
"valleyfair.com",
|
||||
"visitkingsisland.com",
|
||||
"worldsoffun.com"
|
||||
],
|
||||
"Excluded": false
|
||||
},
|
||||
{
|
||||
"Type": 81,
|
||||
"Domains": [
|
||||
"ubnt.com",
|
||||
"ui.com"
|
||||
],
|
||||
"Excluded": false
|
||||
"Type": 81,
|
||||
"Domains": [
|
||||
"ubnt.com",
|
||||
"ui.com"
|
||||
],
|
||||
"Excluded": false
|
||||
}
|
||||
]
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 5.8 KiB |
BIN
src/static/images/shield-white.png
Normal file
BIN
src/static/images/shield-white.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
3513
src/static/scripts/bootstrap-native-v4.js
vendored
3513
src/static/scripts/bootstrap-native-v4.js
vendored
File diff suppressed because it is too large
Load Diff
746
src/static/scripts/bootstrap.css
vendored
746
src/static/scripts/bootstrap.css
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,67 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>Bitwarden_rs Admin Panel</title>
|
||||
|
||||
<link rel="stylesheet" href="{{urlpath}}/bwrs_static/bootstrap.css" />
|
||||
<script src="{{urlpath}}/bwrs_static/bootstrap-native-v4.js"></script>
|
||||
<script src="{{urlpath}}/bwrs_static/md5.js"></script>
|
||||
<script src="{{urlpath}}/bwrs_static/identicon.js"></script>
|
||||
<style>
|
||||
body {
|
||||
padding-top: 70px;
|
||||
padding-top: 75px;
|
||||
}
|
||||
|
||||
@media (max-width:768px) {
|
||||
body {
|
||||
padding-top: 190px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.navbar img {
|
||||
height: 24px;
|
||||
width: auto;
|
||||
}
|
||||
</style>
|
||||
<script src="{{urlpath}}/bwrs_static/md5.js"></script>
|
||||
<script src="{{urlpath}}/bwrs_static/identicon.js"></script>
|
||||
<script>
|
||||
function reload() { window.location.reload(); }
|
||||
function msg(text, reload_page = true) {
|
||||
text && alert(text);
|
||||
reload_page && reload();
|
||||
}
|
||||
function identicon(email) {
|
||||
const data = new Identicon(md5(email), { size: 48, format: 'svg' });
|
||||
return "data:image/svg+xml;base64," + data.toString();
|
||||
}
|
||||
function toggleVis(input_id) {
|
||||
const elem = document.getElementById(input_id);
|
||||
const type = elem.getAttribute("type");
|
||||
if (type === "text") {
|
||||
elem.setAttribute("type", "password");
|
||||
} else {
|
||||
elem.setAttribute("type", "text");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function _post(url, successMsg, errMsg, body, reload_page = true) {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
mode: "same-origin",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}).then( resp => {
|
||||
if (resp.ok) { msg(successMsg, reload_page); return Promise.reject({error: false}); }
|
||||
respStatus = resp.status;
|
||||
respStatusText = resp.statusText;
|
||||
return resp.text();
|
||||
}).then( respText => {
|
||||
try {
|
||||
const respJson = JSON.parse(respText);
|
||||
return respJson ? respJson.ErrorModel.Message : "Unknown error";
|
||||
} catch (e) {
|
||||
return Promise.reject({body:respStatus + ' - ' + respStatusText, error: true});
|
||||
}
|
||||
}).then( apiMsg => {
|
||||
msg(errMsg + "\n" + apiMsg, reload_page);
|
||||
}).catch( e => {
|
||||
if (e.error === false) { return true; }
|
||||
else { msg(errMsg + "\n" + e.body, reload_page); }
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body class="bg-light">
|
||||
<nav class="navbar navbar-expand-sm navbar-dark bg-dark fixed-top shadow">
|
||||
<a class="navbar-brand" href="#">Bitwarden_rs</a>
|
||||
<div class="navbar-collapse">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item active">
|
||||
<a class="nav-link" href="{{urlpath}}/admin">Admin Panel</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{urlpath}}/">Vault</a>
|
||||
</li>
|
||||
</ul>
|
||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4 shadow fixed-top">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{urlpath}}/admin"><img class="pr-1" src="{{urlpath}}/bwrs_static/shield-white.png">Bitwarden_rs Admin</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse"
|
||||
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarCollapse">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
{{#if logged_in}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{urlpath}}/admin">Settings</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{urlpath}}/admin/users/overview">Users</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{urlpath}}/admin/organizations/overview">Organizations</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{urlpath}}/admin/diagnostics">Diagnostics</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{urlpath}}/">Vault</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{#if logged_in}}
|
||||
<a class="btn btn-sm btn-secondary" href="{{urlpath}}/admin/logout">Log Out</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="navbar-nav">
|
||||
{{#if version}}
|
||||
<li class="nav-item">
|
||||
<span class="navbar-text mr-2">Version: {{version}}</span>
|
||||
</li>
|
||||
{{/if}}
|
||||
|
||||
{{#if logged_in}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{urlpath}}/admin/logout">Log Out</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{{> (page_content) }}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<!-- This script needs to be at the bottom, else it will fail! -->
|
||||
<script>
|
||||
// get current URL path and assign 'active' class to the correct nav-item
|
||||
(function () {
|
||||
var pathname = window.location.pathname;
|
||||
if (pathname === "") return;
|
||||
var navItem = document.querySelectorAll('.navbar-nav .nav-item a[href="'+pathname+'"]');
|
||||
if (navItem.length === 1) {
|
||||
navItem[0].parentElement.className = navItem[0].parentElement.className + ' active';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<!-- This script needs to be at the bottom, else it will fail! -->
|
||||
<script src="{{urlpath}}/bwrs_static/bootstrap-native-v4.js"></script>
|
||||
</body>
|
||||
</html>
|
150
src/static/templates/admin/diagnostics.hbs
Normal file
150
src/static/templates/admin/diagnostics.hbs
Normal file
@@ -0,0 +1,150 @@
|
||||
<main class="container">
|
||||
<div id="diagnostics-block" class="my-3 p-3 bg-white rounded shadow">
|
||||
<h6 class="border-bottom pb-2 mb-2">Diagnostics</h6>
|
||||
|
||||
<h3>Version</h3>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-5">Server Installed
|
||||
<span class="badge badge-success d-none" id="server-success" title="Latest version is installed.">Ok</span>
|
||||
<span class="badge badge-warning d-none" id="server-warning" title="There seems to be an update available.">Update</span>
|
||||
<span class="badge badge-info d-none" id="server-branch" title="This is a branched version.">Branched</span>
|
||||
</dt>
|
||||
<dd class="col-sm-7">
|
||||
<span id="server-installed">{{version}}</span>
|
||||
</dd>
|
||||
<dt class="col-sm-5">Server Latest
|
||||
<span class="badge badge-danger d-none" id="server-failed" title="Unable to determine latest version.">Unknown</span>
|
||||
</dt>
|
||||
<dd class="col-sm-7">
|
||||
<span id="server-latest">{{diagnostics.latest_release}}<span id="server-latest-commit" class="d-none">-{{diagnostics.latest_commit}}</span></span>
|
||||
</dd>
|
||||
<dt class="col-sm-5">Web Installed
|
||||
<span class="badge badge-success d-none" id="web-success" title="Latest version is installed.">Ok</span>
|
||||
<span class="badge badge-warning d-none" id="web-warning" title="There seems to be an update available.">Update</span>
|
||||
</dt>
|
||||
<dd class="col-sm-7">
|
||||
<span id="web-installed">{{diagnostics.web_vault_version}}</span>
|
||||
</dd>
|
||||
<dt class="col-sm-5">Web Latest
|
||||
<span class="badge badge-danger d-none" id="web-failed" title="Unable to determine latest version.">Unknown</span>
|
||||
</dt>
|
||||
<dd class="col-sm-7">
|
||||
<span id="web-latest">{{diagnostics.latest_web_build}}</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Checks</h3>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-5">DNS (github.com)
|
||||
<span class="badge badge-success d-none" id="dns-success" title="DNS Resolving works!">Ok</span>
|
||||
<span class="badge badge-danger d-none" id="dns-warning" title="DNS Resolving failed. Please fix.">Error</span>
|
||||
</dt>
|
||||
<dd class="col-sm-7">
|
||||
<span id="dns-resolved">{{diagnostics.dns_resolved}}</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-5">Date & Time (UTC)
|
||||
<span class="badge badge-success d-none" id="time-success" title="Time offsets seem to be correct.">Ok</span>
|
||||
<span class="badge badge-danger d-none" id="time-warning" title="Time offsets are too mouch at drift.">Error</span>
|
||||
</dt>
|
||||
<dd class="col-sm-7">
|
||||
<span id="time-server" class="d-block"><b>Server:</b> <span id="time-server-string">{{diagnostics.server_time}}</span></span>
|
||||
<span id="time-browser" class="d-block"><b>Browser:</b> <span id="time-browser-string"></span></span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const d = new Date();
|
||||
const year = d.getUTCFullYear();
|
||||
const month = String(d.getUTCMonth()+1).padStart(2, '0');
|
||||
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||
const hour = String(d.getUTCHours()).padStart(2, '0');
|
||||
const minute = String(d.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(d.getUTCSeconds()).padStart(2, '0');
|
||||
const browserUTC = year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + seconds;
|
||||
document.getElementById("time-browser-string").innerText = browserUTC;
|
||||
|
||||
const serverUTC = document.getElementById("time-server-string").innerText;
|
||||
const timeDrift = (Date.parse(serverUTC) - Date.parse(browserUTC)) / 1000;
|
||||
if (timeDrift > 30 || timeDrift < -30) {
|
||||
document.getElementById('time-warning').classList.remove('d-none');
|
||||
} else {
|
||||
document.getElementById('time-success').classList.remove('d-none');
|
||||
}
|
||||
|
||||
// Check if the output is a valid IP
|
||||
const isValidIp = value => (/^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$/.test(value) ? true : false);
|
||||
if (isValidIp(document.getElementById('dns-resolved').innerText)) {
|
||||
document.getElementById('dns-success').classList.remove('d-none');
|
||||
} else {
|
||||
document.getElementById('dns-warning').classList.remove('d-none');
|
||||
}
|
||||
|
||||
let serverInstalled = document.getElementById('server-installed').innerText;
|
||||
let serverLatest = document.getElementById('server-latest').innerText;
|
||||
let serverLatestCommit = document.getElementById('server-latest-commit').innerText.replace('-', '');
|
||||
if (serverInstalled.indexOf('-') !== -1 && serverLatest !== '-' && serverLatestCommit !== '-') {
|
||||
document.getElementById('server-latest-commit').classList.remove('d-none');
|
||||
}
|
||||
|
||||
const webInstalled = document.getElementById('web-installed').innerText;
|
||||
const webLatest = document.getElementById('web-latest').innerText;
|
||||
|
||||
checkVersions('server', serverInstalled, serverLatest, serverLatestCommit);
|
||||
checkVersions('web', webInstalled, webLatest);
|
||||
|
||||
function checkVersions(platform, installed, latest, commit=null) {
|
||||
if (installed === '-' || latest === '-') {
|
||||
document.getElementById(platform + '-failed').classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check basic versions, no commit revisions
|
||||
if (commit === null || installed.indexOf('-') === -1) {
|
||||
if (installed !== latest) {
|
||||
document.getElementById(platform + '-warning').classList.remove('d-none');
|
||||
} else {
|
||||
document.getElementById(platform + '-success').classList.remove('d-none');
|
||||
}
|
||||
} else {
|
||||
// Check if this is a branched version.
|
||||
const branchRegex = /(?:\s)\((.*?)\)/;
|
||||
const branchMatch = installed.match(branchRegex);
|
||||
if (branchMatch !== null) {
|
||||
document.getElementById(platform + '-branch').classList.remove('d-none');
|
||||
}
|
||||
|
||||
// This will remove branch info and check if there is a commit hash
|
||||
const installedRegex = /(\d+\.\d+\.\d+)-(\w+)/;
|
||||
const instMatch = installed.match(installedRegex);
|
||||
|
||||
// It could be that a new tagged version has the same commit hash.
|
||||
// In this case the version is the same but only the number is different
|
||||
if (instMatch !== null) {
|
||||
if (instMatch[2] === commit) {
|
||||
// The commit hashes are the same, so latest version is installed
|
||||
document.getElementById(platform + '-success').classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (installed === latest) {
|
||||
document.getElementById(platform + '-success').classList.remove('d-none');
|
||||
} else {
|
||||
document.getElementById(platform + '-warning').classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
51
src/static/templates/admin/organizations.hbs
Normal file
51
src/static/templates/admin/organizations.hbs
Normal file
@@ -0,0 +1,51 @@
|
||||
<main class="container">
|
||||
<div id="organizations-block" class="my-3 p-3 bg-white rounded shadow">
|
||||
<h6 class="border-bottom pb-2 mb-0">Organizations</h6>
|
||||
|
||||
<div class="table-responsive-xl small">
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 24px;" colspan="2">Organization</th>
|
||||
<th>Users</th>
|
||||
<th>Items</th>
|
||||
<th>Attachments</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each organizations}}
|
||||
<tr>
|
||||
<td><img class="rounded identicon" data-src="{{Id}}"></td>
|
||||
<td>
|
||||
<strong>{{Name}}</strong>
|
||||
<span class="mr-2">({{BillingEmail}})</span>
|
||||
<span class="d-block">
|
||||
<span class="badge badge-success">{{Id}}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="d-block">{{user_count}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="d-block">{{cipher_count}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="d-block"><strong>Amount:</strong> {{attachment_count}}</span>
|
||||
{{#if attachment_count}}
|
||||
<span class="d-block"><strong>Size:</strong> {{attachment_size}}</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll("img.identicon").forEach(function (e, i) {
|
||||
e.src = identicon(e.dataset.src);
|
||||
});
|
||||
</script>
|
@@ -1,68 +1,4 @@
|
||||
<main class="container">
|
||||
<div id="users-block" class="my-3 p-3 bg-white rounded shadow">
|
||||
<h6 class="border-bottom pb-2 mb-0">Registered Users</h6>
|
||||
|
||||
<div id="users-list">
|
||||
{{#each users}}
|
||||
<div class="media pt-3">
|
||||
<img class="mr-2 rounded identicon" data-src="{{Email}}">
|
||||
<div class="media-body pb-3 mb-0 small border-bottom">
|
||||
<div class="row justify-content-between">
|
||||
<div class="col">
|
||||
<strong>{{Name}}</strong>
|
||||
{{#if TwoFactorEnabled}}
|
||||
<span class="badge badge-success ml-2">2FA</span>
|
||||
{{/if}}
|
||||
{{#case _Status 1}}
|
||||
<span class="badge badge-warning ml-2">Invited</span>
|
||||
{{/case}}
|
||||
<span class="d-block">{{Email}}</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<strong> Organizations: </strong>
|
||||
<span class="d-block">
|
||||
{{#each Organizations}}
|
||||
<span class="badge badge-primary" data-orgtype="{{Type}}">{{Name}}</span>
|
||||
{{/each}}
|
||||
</span>
|
||||
</div>
|
||||
<div style="flex: 0 0 300px; font-size: 90%; text-align: right; padding-right: 15px">
|
||||
{{#if TwoFactorEnabled}}
|
||||
<a class="mr-2" href="#" onclick='remove2fa({{jsesc Id}})'>Remove all 2FA</a>
|
||||
{{/if}}
|
||||
|
||||
<a class="mr-2" href="#" onclick='deauthUser({{jsesc Id}})'>Deauthorize sessions</a>
|
||||
<a class="mr-2" href="#" onclick='deleteUser({{jsesc Id}}, {{jsesc Email}})'>Delete User</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-sm btn-link" onclick="updateRevisions();"
|
||||
title="Force all clients to fetch new data next time they connect. Useful after restoring a backup to remove any stale data.">
|
||||
Force clients to resync
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-primary float-right" onclick="reload();">Reload users</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="invite-form-block" class="align-items-center p-3 mb-3 text-white-50 bg-secondary rounded shadow">
|
||||
<div>
|
||||
<h6 class="mb-0 text-white">Invite User</h6>
|
||||
<small>Email:</small>
|
||||
|
||||
<form class="form-inline" id="invite-form" onsubmit="inviteUser(); return false;">
|
||||
<input type="email" class="form-control w-50 mr-2" id="email-invite" placeholder="Enter email">
|
||||
<button type="submit" class="btn btn-primary">Invite</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="config-block" class="align-items-center p-3 mb-3 bg-secondary rounded shadow">
|
||||
<div>
|
||||
<h6 class="text-white mb-3">Configuration</h6>
|
||||
@@ -202,90 +138,6 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function reload() { window.location.reload(); }
|
||||
function msg(text, reload_page = true) {
|
||||
text && alert(text);
|
||||
reload_page && reload();
|
||||
}
|
||||
function identicon(email) {
|
||||
const data = new Identicon(md5(email), { size: 48, format: 'svg' });
|
||||
return "data:image/svg+xml;base64," + data.toString();
|
||||
}
|
||||
function toggleVis(input_id) {
|
||||
const elem = document.getElementById(input_id);
|
||||
const type = elem.getAttribute("type");
|
||||
if (type === "text") {
|
||||
elem.setAttribute("type", "password");
|
||||
} else {
|
||||
elem.setAttribute("type", "text");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function _post(url, successMsg, errMsg, body, reload_page = true) {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
mode: "same-origin",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}).then( resp => {
|
||||
if (resp.ok) { msg(successMsg, reload_page); return Promise.reject({error: false}); }
|
||||
respStatus = resp.status;
|
||||
respStatusText = resp.statusText;
|
||||
return resp.text();
|
||||
}).then( respText => {
|
||||
try {
|
||||
const respJson = JSON.parse(respText);
|
||||
return respJson ? respJson.ErrorModel.Message : "Unknown error";
|
||||
} catch (e) {
|
||||
return Promise.reject({body:respStatus + ' - ' + respStatusText, error: true});
|
||||
}
|
||||
}).then( apiMsg => {
|
||||
msg(errMsg + "\n" + apiMsg, reload_page);
|
||||
}).catch( e => {
|
||||
if (e.error === false) { return true; }
|
||||
else { msg(errMsg + "\n" + e.body, reload_page); }
|
||||
});
|
||||
}
|
||||
function deleteUser(id, mail) {
|
||||
var input_mail = prompt("To delete user '" + mail + "', please type the email below")
|
||||
if (input_mail != null) {
|
||||
if (input_mail == mail) {
|
||||
_post("{{urlpath}}/admin/users/" + id + "/delete",
|
||||
"User deleted correctly",
|
||||
"Error deleting user");
|
||||
} else {
|
||||
alert("Wrong email, please try again")
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function remove2fa(id) {
|
||||
_post("{{urlpath}}/admin/users/" + id + "/remove-2fa",
|
||||
"2FA removed correctly",
|
||||
"Error removing 2FA");
|
||||
return false;
|
||||
}
|
||||
function deauthUser(id) {
|
||||
_post("{{urlpath}}/admin/users/" + id + "/deauth",
|
||||
"Sessions deauthorized correctly",
|
||||
"Error deauthorizing sessions");
|
||||
return false;
|
||||
}
|
||||
function updateRevisions() {
|
||||
_post("{{urlpath}}/admin/users/update_revision",
|
||||
"Success, clients will sync next time they connect",
|
||||
"Error forcing clients to sync");
|
||||
return false;
|
||||
}
|
||||
function inviteUser() {
|
||||
inv = document.getElementById("email-invite");
|
||||
data = JSON.stringify({ "email": inv.value });
|
||||
inv.value = "";
|
||||
_post("{{urlpath}}/admin/invite/", "User invited correctly",
|
||||
"Error inviting user", data);
|
||||
return false;
|
||||
}
|
||||
function smtpTest() {
|
||||
test_email = document.getElementById("smtp-test-email");
|
||||
data = JSON.stringify({ "email": test_email.value });
|
||||
@@ -348,23 +200,6 @@
|
||||
onChange(); // Trigger the event initially
|
||||
checkbox.addEventListener("change", onChange);
|
||||
}
|
||||
let OrgTypes = {
|
||||
"0": { "name": "Owner", "color": "orange" },
|
||||
"1": { "name": "Admin", "color": "blueviolet" },
|
||||
"2": { "name": "User", "color": "blue" },
|
||||
"3": { "name": "Manager", "color": "green" },
|
||||
};
|
||||
|
||||
document.querySelectorAll("img.identicon").forEach(function (e, i) {
|
||||
e.src = identicon(e.dataset.src);
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-orgtype]").forEach(function (e, i) {
|
||||
let orgtype = OrgTypes[e.dataset.orgtype];
|
||||
e.style.backgroundColor = orgtype.color;
|
||||
e.title = orgtype.name;
|
||||
});
|
||||
|
||||
// These are formatted because otherwise the
|
||||
// VSCode formatter breaks But they still work
|
||||
// {{#each config}} {{#if grouptoggle}}
|
143
src/static/templates/admin/users.hbs
Normal file
143
src/static/templates/admin/users.hbs
Normal file
@@ -0,0 +1,143 @@
|
||||
<main class="container">
|
||||
<div id="users-block" class="my-3 p-3 bg-white rounded shadow">
|
||||
<h6 class="border-bottom pb-2 mb-0">Registered Users</h6>
|
||||
|
||||
<div class="table-responsive-xl small">
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 24px;">User</th>
|
||||
<th></th>
|
||||
<th style="width:60px; min-width: 60px;">Items</th>
|
||||
<th>Attachments</th>
|
||||
<th style="min-width: 140px;">Organizations</th>
|
||||
<th style="width: 140px; min-width: 140px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each users}}
|
||||
<tr>
|
||||
<td><img class="mr-2 rounded identicon" data-src="{{Email}}"></td>
|
||||
<td>
|
||||
<strong>{{Name}}</strong>
|
||||
<span class="d-block">{{Email}}</span>
|
||||
<span class="d-block">
|
||||
{{#if TwoFactorEnabled}}
|
||||
<span class="badge badge-success mr-2" title="2FA is enabled">2FA</span>
|
||||
{{/if}}
|
||||
{{#case _Status 1}}
|
||||
<span class="badge badge-warning mr-2" title="User is invited">Invited</span>
|
||||
{{/case}}
|
||||
{{#if EmailVerified}}
|
||||
<span class="badge badge-success mr-2" title="Email has been verified">Verified</span>
|
||||
{{/if}}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="d-block">{{cipher_count}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="d-block"><strong>Amount:</strong> {{attachment_count}}</span>
|
||||
{{#if attachment_count}}
|
||||
<span class="d-block"><strong>Size:</strong> {{attachment_size}}</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td>
|
||||
{{#each Organizations}}
|
||||
<span class="badge badge-primary" data-orgtype="{{Type}}">{{Name}}</span>
|
||||
{{/each}}
|
||||
</td>
|
||||
<td style="font-size: 90%; text-align: right; padding-right: 15px">
|
||||
{{#if TwoFactorEnabled}}
|
||||
<a class="d-block" href="#" onclick='remove2fa({{jsesc Id}})'>Remove all 2FA</a>
|
||||
{{/if}}
|
||||
<a class="d-block" href="#" onclick='deauthUser({{jsesc Id}})'>Deauthorize sessions</a>
|
||||
<a class="d-block" href="#" onclick='deleteUser({{jsesc Id}}, {{jsesc Email}})'>Delete User</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="updateRevisions();"
|
||||
title="Force all clients to fetch new data next time they connect. Useful after restoring a backup to remove any stale data.">
|
||||
Force clients to resync
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-primary float-right" onclick="reload();">Reload users</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="invite-form-block" class="align-items-center p-3 mb-3 text-white-50 bg-secondary rounded shadow">
|
||||
<div>
|
||||
<h6 class="mb-0 text-white">Invite User</h6>
|
||||
<small>Email:</small>
|
||||
|
||||
<form class="form-inline" id="invite-form" onsubmit="inviteUser(); return false;">
|
||||
<input type="email" class="form-control w-50 mr-2" id="email-invite" placeholder="Enter email">
|
||||
<button type="submit" class="btn btn-primary">Invite</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function deleteUser(id, mail) {
|
||||
var input_mail = prompt("To delete user '" + mail + "', please type the email below")
|
||||
if (input_mail != null) {
|
||||
if (input_mail == mail) {
|
||||
_post("{{urlpath}}/admin/users/" + id + "/delete",
|
||||
"User deleted correctly",
|
||||
"Error deleting user");
|
||||
} else {
|
||||
alert("Wrong email, please try again")
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function remove2fa(id) {
|
||||
_post("{{urlpath}}/admin/users/" + id + "/remove-2fa",
|
||||
"2FA removed correctly",
|
||||
"Error removing 2FA");
|
||||
return false;
|
||||
}
|
||||
function deauthUser(id) {
|
||||
_post("{{urlpath}}/admin/users/" + id + "/deauth",
|
||||
"Sessions deauthorized correctly",
|
||||
"Error deauthorizing sessions");
|
||||
return false;
|
||||
}
|
||||
function updateRevisions() {
|
||||
_post("{{urlpath}}/admin/users/update_revision",
|
||||
"Success, clients will sync next time they connect",
|
||||
"Error forcing clients to sync");
|
||||
return false;
|
||||
}
|
||||
function inviteUser() {
|
||||
inv = document.getElementById("email-invite");
|
||||
data = JSON.stringify({ "email": inv.value });
|
||||
inv.value = "";
|
||||
_post("{{urlpath}}/admin/invite/", "User invited correctly",
|
||||
"Error inviting user", data);
|
||||
return false;
|
||||
}
|
||||
|
||||
let OrgTypes = {
|
||||
"0": { "name": "Owner", "color": "orange" },
|
||||
"1": { "name": "Admin", "color": "blueviolet" },
|
||||
"2": { "name": "User", "color": "blue" },
|
||||
"3": { "name": "Manager", "color": "green" },
|
||||
};
|
||||
|
||||
document.querySelectorAll("img.identicon").forEach(function (e, i) {
|
||||
e.src = identicon(e.dataset.src);
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-orgtype]").forEach(function (e, i) {
|
||||
let orgtype = OrgTypes[e.dataset.orgtype];
|
||||
e.style.backgroundColor = orgtype.color;
|
||||
e.title = orgtype.name;
|
||||
});
|
||||
</script>
|
@@ -87,7 +87,7 @@ Your Email Change
|
||||
</tr>
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: max-content;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
|
||||
|
@@ -87,7 +87,7 @@ Delete Your Account
|
||||
</tr>
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: max-content;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
|
||||
|
@@ -1,4 +1,4 @@
|
||||
Invitation accepted
|
||||
Invitation to {{{org_name}}} accepted
|
||||
<!---------------->
|
||||
<html>
|
||||
<p>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
Invitation accepted
|
||||
Invitation to {{{org_name}}} accepted
|
||||
<!---------------->
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<head>
|
||||
@@ -87,7 +87,7 @@ Invitation accepted
|
||||
</tr>
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: max-content;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
|
||||
|
@@ -1,4 +1,4 @@
|
||||
Invitation to {{org_name}} confirmed
|
||||
Invitation to {{{org_name}}} confirmed
|
||||
<!---------------->
|
||||
<html>
|
||||
<p>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
Invitation to {{org_name}} confirmed
|
||||
Invitation to {{{org_name}}} confirmed
|
||||
<!---------------->
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<head>
|
||||
@@ -87,7 +87,7 @@ Invitation to {{org_name}} confirmed
|
||||
</tr>
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: max-content;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
|
||||
|
@@ -1,4 +1,4 @@
|
||||
New Device Logged In From {{device}}
|
||||
New Device Logged In From {{{device}}}
|
||||
<!---------------->
|
||||
<html>
|
||||
<p>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
New Device Logged In From {{device}}
|
||||
New Device Logged In From {{{device}}}
|
||||
<!---------------->
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<head>
|
||||
@@ -87,7 +87,7 @@ New Device Logged In From {{device}}
|
||||
</tr>
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: max-content;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
|
||||
|
@@ -87,7 +87,7 @@ Sorry, you have no password hint...
|
||||
</tr>
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: max-content;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
|
||||
|
@@ -87,7 +87,7 @@ Your master password hint
|
||||
</tr>
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: max-content;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
|
||||
|
@@ -1,4 +1,4 @@
|
||||
Join {{org_name}}
|
||||
Join {{{org_name}}}
|
||||
<!---------------->
|
||||
<html>
|
||||
<p>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
Join {{org_name}}
|
||||
Join {{{org_name}}}
|
||||
<!---------------->
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<head>
|
||||
@@ -87,7 +87,7 @@ Join {{org_name}}
|
||||
</tr>
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: max-content;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
|
||||
|
@@ -87,7 +87,7 @@ Bitwarden_rs SMTP Test
|
||||
</tr>
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: max-content;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
|
||||
|
@@ -42,7 +42,7 @@ Your Two-step Login Verification Code
|
||||
body {
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
@media only screen and (max-width: 600px) {
|
||||
@media only screen and (max-width: 410px) {
|
||||
body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
@@ -86,10 +86,10 @@ Your Two-step Login Verification Code
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 410px !important; width: 410px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 410px !important; width: max-content;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 410px; padding-bottom: 20px;" valign="top">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-wrap" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 20px; -webkit-text-size-adjust: none;" valign="top">
|
||||
|
@@ -87,7 +87,7 @@ Verify Your Email
|
||||
</tr>
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: max-content;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
|
||||
|
@@ -87,7 +87,7 @@ Welcome
|
||||
</tr>
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: max-content;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
|
||||
|
@@ -87,7 +87,7 @@ Welcome
|
||||
</tr>
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
|
||||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: max-content;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user