From 42136a70973f60086749c62439c6a965d4589c02 Mon Sep 17 00:00:00 2001 From: BlackDex Date: Tue, 22 Feb 2022 20:48:00 +0100 Subject: [PATCH] Favicon, SMTP and misc updates Favicon: - Replaced HTML tokenizer, much faster now. - Caching the domain blacklist function. - Almost all functions are async now. - Fixed bug on minimizing data to parse - Changed maximum icon download size to 5MB to match Bitwarden - Added `apple-touch-icon.png` as a second fallback besides `favicon.ico` SMTP: - Deprecated SMTP_SSL and SMTP_EXPLICIT_TLS, replaced with SMTP_SECURITY Misc: - Fixed issue when `resolv.conf` contains errors and trust-dns panics (Fixes #2283) - Updated Javscript and CSS files for admin interface - Fixed an issue with the /admin interface which did not cleared the login cookie correctly - Prevent websocket notifications during org import, this caused a lot of traffic, and slowed down the import. This is also the same as Bitwarden which does not trigger this refresh via websockets. Rust: - Updated to use v1.59 - Use the new `strip` option and enabled to strip `debuginfo` - Enabled `lto` with `thin` - Removed the strip RUN from the alpine armv7, this is now done automatically --- .env.template | 3 +- .pre-commit-config.yaml | 2 +- Cargo.lock | 356 +- Cargo.toml | 14 +- docker/Dockerfile.j2 | 6 - docker/armv7/Dockerfile.alpine | 2 - docker/armv7/Dockerfile.buildx.alpine | 2 - src/api/admin.rs | 4 +- src/api/core/organizations.rs | 4 +- src/api/icons.rs | 534 ++- src/config.rs | 33 +- src/mail.rs | 4 +- src/static/scripts/bootstrap-native.js | 5491 ++++++++++++++++-------- src/static/scripts/datatables.css | 38 +- src/static/scripts/datatables.js | 69 +- src/util.rs | 8 +- 16 files changed, 4390 insertions(+), 2180 deletions(-) diff --git a/.env.template b/.env.template index 8da88cdc..2d0ea32b 100644 --- a/.env.template +++ b/.env.template @@ -331,9 +331,8 @@ # SMTP_HOST=smtp.domain.tld # SMTP_FROM=vaultwarden@domain.tld # SMTP_FROM_NAME=Vaultwarden +# SMTP_SECURITY=starttls # ("starttls", "force_tls", "off") Enable a secure connection. Default is "starttls" (Explicit - ports 587 or 25), "force_tls" (Implicit - port 465) or "off", no encryption (port 25) # SMTP_PORT=587 # Ports 587 (submission) and 25 (smtp) are standard without encryption and with encryption via STARTTLS (Explicit TLS). Port 465 is outdated and used with Implicit TLS. -# SMTP_SSL=true # (Explicit) - This variable by default configures Explicit STARTTLS, it will upgrade an insecure connection to a secure one. Unless SMTP_EXPLICIT_TLS is set to true. Either port 587 or 25 are default. -# SMTP_EXPLICIT_TLS=true # (Implicit) - N.B. This variable configures Implicit TLS. It's currently mislabelled (see bug #851) - SMTP_SSL Needs to be set to true for this option to work. Usually port 465 is used here. # SMTP_USERNAME=username # SMTP_PASSWORD=password # SMTP_TIMEOUT=15 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b26d8445..f18ddbf1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ --- repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.1.0 hooks: - id: check-yaml - id: check-json diff --git a/Cargo.lock b/Cargo.lock index 9516efe5..9de3455d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,6 +99,25 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-mutex" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479db852db25d9dbf6204e6cb6253698f175c15726470f78af0d918e99d6156e" +dependencies = [ + "event-listener", +] + +[[package]] +name = "async-rwlock" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261803dcc39ba9e72760ba6e16d0199b1eef9fc44e81bffabbebb9f5aea3906c" +dependencies = [ + "async-mutex", + "event-listener", +] + [[package]] name = "async-stream" version = "0.3.2" @@ -302,6 +321,40 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" +[[package]] +name = "cached" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4dfac631a8e77b2f327f7852bb6172771f5279c4512efe79fad6067b37be3d" +dependencies = [ + "async-mutex", + "async-rwlock", + "async-trait", + "cached_proc_macro", + "cached_proc_macro_types", + "futures", + "hashbrown", + "once_cell", +] + +[[package]] +name = "cached_proc_macro" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "725f434d6da2814b989bd905c62ca28a9383feff7440210dc279665fbbbc9511" +dependencies = [ + "cached_proc_macro_types", + "darling", + "quote", + "syn", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4f925191b4367301851c6d99b09890311d74b0d43f274c0b34c86d308a3663" + [[package]] name = "cc" version = "1.0.73" @@ -352,7 +405,7 @@ checksum = "58549f1842da3080ce63002102d5bc954c7bc843d4f47818e642abdc36253552" dependencies = [ "chrono", "chrono-tz-build", - "phf 0.10.1", + "phf", ] [[package]] @@ -362,8 +415,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db058d493fb2f65f41861bfed7e3fe6335264a9f0f92710cab5bdf01fef09069" dependencies = [ "parse-zoneinfo", - "phf 0.10.1", - "phf_codegen 0.10.0", + "phf", + "phf_codegen", ] [[package]] @@ -530,6 +583,41 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "darling" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d720b8683f8dd83c65155f0530560cba68cd2bf395f6513a483caee57ff7f4" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a340f241d2ceed1deb47ae36c4144b2707ec7dd0b649f894cb39bb595986324" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c41b3b7352feb3211a0d743dc5700a4e3b60f51bd2b368892d1e0f9a95f44b" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "5.1.0" @@ -685,9 +773,9 @@ dependencies = [ [[package]] name = "enum-as-inner" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c5f0096a91d210159eceb2ff5e1c4da18388a170e1e3ce948aac9c8fdbbf595" +checksum = "570d109b813e904becc80d8d5da38376818a143348413f7149f1340fe04754d4" dependencies = [ "heck", "proc-macro2", @@ -704,6 +792,12 @@ dependencies = [ "backtrace", ] +[[package]] +name = "event-listener" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77f3309417938f28bf8228fcff79a4a37103981e3e186d2ccd19c74b38f4eb71" + [[package]] name = "fake-simd" version = "0.1.2" @@ -808,16 +902,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" version = "0.3.21" @@ -958,9 +1042,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c" +checksum = "d39cd93900197114fa1fcb7ae84ca742095eed9442088988ae74fa744e930e77" dependencies = [ "cfg-if 1.0.0", "libc", @@ -1054,12 +1138,9 @@ checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" [[package]] name = "heck" -version = "0.3.3" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" -dependencies = [ - "unicode-segmentation", -] +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" [[package]] name = "hermit-abi" @@ -1120,17 +1201,12 @@ dependencies = [ ] [[package]] -name = "html5ever" -version = "0.25.1" +name = "html5gum" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aafcf38a1a36118242d29b92e1b08ef84e67e4a5ed06e0a80be20e6a32bfed6b" +checksum = "2dad48b66db55322add2819ae1d7bda0c32f3415269a08330679dbc8b0afeb30" dependencies = [ - "log", - "mac", - "markup5ever", - "proc-macro2", - "quote", - "syn", + "jetscii", ] [[package]] @@ -1204,6 +1280,12 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.1.5" @@ -1285,6 +1367,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" +[[package]] +name = "jetscii" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9447923c57a8a2d5c1b0875cdf96a6324275df728b498f2ede0e5cbde088a15" + [[package]] name = "job_scheduler" version = "1.2.1" @@ -1363,9 +1451,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.118" +version = "0.2.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06e509672465a0504304aa87f9f176f2b2b716ed8fb105ebe5c02dc6dce96a94" +checksum = "1bf2e165bb3457c8e098ea76f3e3bc9db55f87aa90d52d0e6be741470916aaa4" [[package]] name = "libsqlite3-sys" @@ -1426,12 +1514,6 @@ dependencies = [ "linked-hash-map", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "mach" version = "0.3.2" @@ -1447,32 +1529,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" -[[package]] -name = "markup5ever" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a24f40fb03852d1cdd84330cddcaf98e9ec08a7b7768e952fad3b4cf048ec8fd" -dependencies = [ - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "markup5ever_rcdom" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f015da43bcd8d4f144559a3423f4591d69b8ce0652c905374da7205df336ae2b" -dependencies = [ - "html5ever", - "markup5ever", - "tendril", - "xml5ever", -] - [[package]] name = "match_cfg" version = "0.1.0" @@ -1682,12 +1738,6 @@ dependencies = [ "winapi 0.3.9", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" - [[package]] name = "nix" version = "0.23.1" @@ -2073,32 +2123,13 @@ dependencies = [ "sha-1 0.8.2", ] -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - [[package]] name = "phf" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ - "phf_shared 0.10.0", -] - -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "phf_shared", ] [[package]] @@ -2107,18 +2138,8 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", + "phf_generator", + "phf_shared", ] [[package]] @@ -2127,19 +2148,10 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" dependencies = [ - "phf_shared 0.10.0", + "phf_shared", "rand 0.8.5", ] -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher", -] - [[package]] name = "phf_shared" version = "0.10.0" @@ -2201,12 +2213,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - [[package]] name = "proc-macro-hack" version = "0.5.19" @@ -2331,7 +2337,6 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", - "rand_pcg", ] [[package]] @@ -2395,7 +2400,7 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" dependencies = [ - "getrandom 0.2.4", + "getrandom 0.2.5", ] [[package]] @@ -2407,15 +2412,6 @@ dependencies = [ "rand_core 0.5.1", ] -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - [[package]] name = "raw-cpuid" version = "10.2.0" @@ -2589,7 +2585,7 @@ dependencies = [ [[package]] name = "rocket" version = "0.5.0-rc.1" -source = "git+https://github.com/SergioBenitez/Rocket?rev=66d18bf66517e2765494d082629e9b9748ff8ad6#66d18bf66517e2765494d082629e9b9748ff8ad6" +source = "git+https://github.com/SergioBenitez/Rocket?rev=91e3b4397a1637d0f55f23db712cf7bda0c7f891#91e3b4397a1637d0f55f23db712cf7bda0c7f891" dependencies = [ "async-stream", "async-trait", @@ -2627,7 +2623,7 @@ dependencies = [ [[package]] name = "rocket_codegen" version = "0.5.0-rc.1" -source = "git+https://github.com/SergioBenitez/Rocket?rev=66d18bf66517e2765494d082629e9b9748ff8ad6#66d18bf66517e2765494d082629e9b9748ff8ad6" +source = "git+https://github.com/SergioBenitez/Rocket?rev=91e3b4397a1637d0f55f23db712cf7bda0c7f891#91e3b4397a1637d0f55f23db712cf7bda0c7f891" dependencies = [ "devise", "glob", @@ -2642,7 +2638,7 @@ dependencies = [ [[package]] name = "rocket_http" version = "0.5.0-rc.1" -source = "git+https://github.com/SergioBenitez/Rocket?rev=66d18bf66517e2765494d082629e9b9748ff8ad6#66d18bf66517e2765494d082629e9b9748ff8ad6" +source = "git+https://github.com/SergioBenitez/Rocket?rev=91e3b4397a1637d0f55f23db712cf7bda0c7f891#91e3b4397a1637d0f55f23db712cf7bda0c7f891" dependencies = [ "cookie 0.16.0", "either", @@ -2656,6 +2652,7 @@ dependencies = [ "pin-project-lite", "ref-cast", "rustls", + "rustls-pemfile", "serde", "smallvec 1.8.0", "stable-pattern", @@ -2683,17 +2680,25 @@ dependencies = [ [[package]] name = "rustls" -version = "0.19.1" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" +checksum = "4fbfeb8d0ddb84706bc597a5574ab8912817c52a397f819e5b614e2265206921" dependencies = [ - "base64 0.13.0", "log", "ring", "sct", "webpki", ] +[[package]] +name = "rustls-pemfile" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee86d63972a7c661d1536fefe8c3c8407321c3df668891286de28abcd087360" +dependencies = [ + "base64 0.13.0", +] + [[package]] name = "rustversion" version = "1.0.6" @@ -2748,9 +2753,9 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "sct" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" dependencies = [ "ring", "untrusted", @@ -3083,30 +3088,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" [[package]] -name = "string_cache" -version = "0.8.3" +name = "strsim" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33994d0838dc2d152d17a62adf608a869b5e846b65b389af7f3dbc1de45c5b26" -dependencies = [ - "lazy_static", - "new_debug_unreachable", - "parking_lot 0.11.2", - "phf_shared 0.10.0", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f24c8e5e19d22a726626f1a5e16fe15b132dcf21d10177fa5a45ce7962996b97" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", - "proc-macro2", - "quote", -] +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "subtle" @@ -3151,17 +3136,6 @@ dependencies = [ "winapi 0.3.9", ] -[[package]] -name = "tendril" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9ef557cb397a4f0a5a3a628f06515f78563f2209e64d47055d9dc6052bf5e33" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "thiserror" version = "1.0.30" @@ -3324,9 +3298,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.22.0" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6844de72e57df1980054b38be3a9f4702aba4858be64dd700181a8a6d0e1b6" +checksum = "a27d5f2b839802bd8267fa19b0530f5a08b9c08cd417976be2a65d130fe1c11b" dependencies = [ "rustls", "tokio", @@ -3588,12 +3562,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-segmentation" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" - [[package]] name = "unicode-xid" version = "0.2.2" @@ -3640,19 +3608,13 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "uuid" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" dependencies = [ - "getrandom 0.2.4", + "getrandom 0.2.5", ] [[package]] @@ -3667,6 +3629,7 @@ version = "1.0.0" dependencies = [ "backtrace", "bytes 1.1.0", + "cached", "chashmap", "chrono", "chrono-tz", @@ -3682,14 +3645,13 @@ dependencies = [ "futures", "governor", "handlebars", - "html5ever", + "html5gum", "idna 0.2.3", "job_scheduler", "jsonwebtoken", "lettre", "libsqlite3-sys", "log", - "markup5ever_rcdom", "num-derive", "num-traits", "once_cell", @@ -3860,9 +3822,9 @@ dependencies = [ [[package]] name = "webpki" -version = "0.21.4" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" dependencies = [ "ring", "untrusted", @@ -3988,18 +3950,6 @@ dependencies = [ "winapi-build", ] -[[package]] -name = "xml5ever" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9234163818fd8e2418fcde330655e757900d4236acd8cc70fef345ef91f6d865" -dependencies = [ - "log", - "mac", - "markup5ever", - "time 0.1.43", -] - [[package]] name = "yansi" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index d67464f1..1f76c0ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "vaultwarden" version = "1.0.0" authors = ["Daniel García "] edition = "2021" -rust-version = "1.58.1" +rust-version = "1.59" resolver = "2" repository = "https://github.com/dani-garcia/vaultwarden" @@ -116,11 +116,11 @@ handlebars = { version = "4.2.1", features = ["dir_source"] } reqwest = { version = "0.11.9", features = ["stream", "json", "gzip", "brotli", "socks", "cookies", "trust-dns"] } # For favicon extraction from main website -html5ever = "0.25.1" -markup5ever_rcdom = "0.1.0" +html5gum = "0.4.0" regex = { version = "1.5.4", features = ["std", "perf", "unicode-perl"], default-features = false } data-url = "0.1.1" bytes = "1.1.0" +cached = "0.30.0" # Used for custom short lived cookie jar during favicon extraction cookie = "0.15.1" @@ -140,7 +140,7 @@ governor = "0.4.2" ctrlc = { version = "3.2.1", features = ["termination"] } [patch.crates-io] -rocket = { git = 'https://github.com/SergioBenitez/Rocket', rev = '66d18bf66517e2765494d082629e9b9748ff8ad6' } +rocket = { git = 'https://github.com/SergioBenitez/Rocket', rev = '91e3b4397a1637d0f55f23db712cf7bda0c7f891' } # The maintainer of the `job_scheduler` crate doesn't seem to have responded # to any issues or PRs for almost a year (as of April 2021). This hopefully @@ -148,3 +148,9 @@ rocket = { git = 'https://github.com/SergioBenitez/Rocket', rev = '66d18bf66517e # In particular, `cron` has since implemented parsing of some common syntax # that wasn't previously supported (https://github.com/zslayton/cron/pull/64). job_scheduler = { git = 'https://github.com/jjlin/job_scheduler', rev = 'ee023418dbba2bfe1e30a5fd7d937f9e33739806' } + +# Strip debuginfo from the release builds +# Also enable thin LTO for some optimizations +[profile.release] +strip = "debuginfo" +lto = "thin" diff --git a/docker/Dockerfile.j2 b/docker/Dockerfile.j2 index 196af08d..a5194254 100644 --- a/docker/Dockerfile.j2 +++ b/docker/Dockerfile.j2 @@ -182,12 +182,6 @@ RUN touch src/main.rs # your actual source files being built # hadolint ignore=DL3059 RUN {{ mount_rust_cache -}} cargo build --features ${DB} --release{{ package_arch_target_param }} -{% if "alpine" in target_file %} -{% if "armv7" in target_file %} -# hadolint ignore=DL3059 -RUN musl-strip target/{{ package_arch_target }}/release/vaultwarden -{% endif %} -{% endif %} ######################## RUNTIME IMAGE ######################## # Create a new stage with a minimal image diff --git a/docker/armv7/Dockerfile.alpine b/docker/armv7/Dockerfile.alpine index d00017bd..e05965bc 100644 --- a/docker/armv7/Dockerfile.alpine +++ b/docker/armv7/Dockerfile.alpine @@ -78,8 +78,6 @@ RUN touch src/main.rs # your actual source files being built # hadolint ignore=DL3059 RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-musleabihf -# hadolint ignore=DL3059 -RUN musl-strip target/armv7-unknown-linux-musleabihf/release/vaultwarden ######################## RUNTIME IMAGE ######################## # Create a new stage with a minimal image diff --git a/docker/armv7/Dockerfile.buildx.alpine b/docker/armv7/Dockerfile.buildx.alpine index a80405d0..431e0ff9 100644 --- a/docker/armv7/Dockerfile.buildx.alpine +++ b/docker/armv7/Dockerfile.buildx.alpine @@ -78,8 +78,6 @@ RUN touch src/main.rs # your actual source files being built # hadolint ignore=DL3059 RUN --mount=type=cache,target=/root/.cargo/git --mount=type=cache,target=/root/.cargo/registry cargo build --features ${DB} --release --target=armv7-unknown-linux-musleabihf -# hadolint ignore=DL3059 -RUN musl-strip target/armv7-unknown-linux-musleabihf/release/vaultwarden ######################## RUNTIME IMAGE ######################## # Create a new stage with a minimal image diff --git a/src/api/admin.rs b/src/api/admin.rs index 015ec7c7..6fbf30e9 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -301,7 +301,7 @@ fn test_smtp(data: Json, _token: AdminToken) -> EmptyResult { #[get("/logout")] fn logout(cookies: &CookieJar<'_>, referer: Referer) -> Redirect { - cookies.remove(Cookie::named(COOKIE_NAME)); + cookies.remove(Cookie::build(COOKIE_NAME, "").path(admin_path()).finish()); Redirect::to(admin_url(referer)) } @@ -638,7 +638,7 @@ impl<'r> FromRequest<'r> for AdminToken { if decode_admin(access_token).is_err() { // Remove admin cookie - cookies.remove(Cookie::named(COOKIE_NAME)); + cookies.remove(Cookie::build(COOKIE_NAME, "").path(admin_path()).finish()); error!("Invalid or expired admin JWT. IP: {}.", ip); return Outcome::Forward(()); } diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index bb6c6634..13012e96 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1182,9 +1182,7 @@ async fn post_org_import( let ciphers = stream::iter(data.Ciphers) .then(|cipher_data| async { let mut cipher = Cipher::new(cipher_data.Type, cipher_data.Name.clone()); - update_cipher_from_data(&mut cipher, cipher_data, &headers, false, &conn, &nt, UpdateType::CipherCreate) - .await - .ok(); + update_cipher_from_data(&mut cipher, cipher_data, &headers, false, &conn, &nt, UpdateType::None).await.ok(); cipher }) .collect::>() diff --git a/src/api/icons.rs b/src/api/icons.rs index 6af10a35..71c4899d 100644 --- a/src/api/icons.rs +++ b/src/api/icons.rs @@ -1,21 +1,28 @@ use std::{ collections::HashMap, - net::{IpAddr, ToSocketAddrs}, - sync::{Arc, RwLock}, + net::IpAddr, + sync::Arc, time::{Duration, SystemTime}, }; -use bytes::{Buf, Bytes, BytesMut}; +use bytes::{Bytes, BytesMut}; use futures::{stream::StreamExt, TryFutureExt}; use once_cell::sync::Lazy; use regex::Regex; -use reqwest::{header, Client, Response}; +use reqwest::{ + header::{self, HeaderMap, HeaderValue}, + Client, Response, +}; use rocket::{http::ContentType, response::Redirect, Route}; use tokio::{ fs::{create_dir_all, remove_file, symlink_metadata, File}, io::{AsyncReadExt, AsyncWriteExt}, + net::lookup_host, + sync::RwLock, }; +use html5gum::{Emitter, EndTag, InfallibleTokenizer, Readable, StartTag, StringReader, Tokenizer}; + use crate::{ error::Error, util::{get_reqwest_client_builder, Cached}, @@ -34,39 +41,50 @@ pub fn routes() -> Vec { static CLIENT: Lazy = Lazy::new(|| { // Generate the default headers - let mut default_headers = header::HeaderMap::new(); - default_headers - .insert(header::USER_AGENT, header::HeaderValue::from_static("Links (2.22; Linux X86_64; GNU C; text)")); - default_headers - .insert(header::ACCEPT, header::HeaderValue::from_static("text/html, text/*;q=0.5, image/*, */*;q=0.1")); - default_headers.insert(header::ACCEPT_LANGUAGE, header::HeaderValue::from_static("en,*;q=0.1")); - default_headers.insert(header::CACHE_CONTROL, header::HeaderValue::from_static("no-cache")); - default_headers.insert(header::PRAGMA, header::HeaderValue::from_static("no-cache")); + let mut default_headers = HeaderMap::new(); + default_headers.insert(header::USER_AGENT, HeaderValue::from_static("Links (2.22; Linux X86_64; GNU C; text)")); + default_headers.insert(header::ACCEPT, HeaderValue::from_static("text/html, text/*;q=0.5, image/*, */*;q=0.1")); + default_headers.insert(header::ACCEPT_LANGUAGE, HeaderValue::from_static("en,*;q=0.1")); + default_headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); + default_headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache")); + + // Generate the cookie store + let cookie_store = Arc::new(Jar::default()); // Reuse the client between requests - get_reqwest_client_builder() - .cookie_provider(Arc::new(Jar::default())) + let client = get_reqwest_client_builder() + .cookie_provider(cookie_store.clone()) .timeout(Duration::from_secs(CONFIG.icon_download_timeout())) - .default_headers(default_headers) - .build() - .expect("Failed to build icon client") + .default_headers(default_headers.clone()); + + match client.build() { + Ok(client) => client, + Err(e) => { + error!("Possible trust-dns error, trying with trust-dns disabled: '{e}'"); + get_reqwest_client_builder() + .cookie_provider(cookie_store) + .timeout(Duration::from_secs(CONFIG.icon_download_timeout())) + .default_headers(default_headers) + .trust_dns(false) + .build() + .expect("Failed to build client") + } + } }); // Build Regex only once since this takes a lot of time. -static ICON_REL_REGEX: Lazy = Lazy::new(|| Regex::new(r"(?i)icon$|apple.*icon").unwrap()); -static ICON_REL_BLACKLIST: Lazy = Lazy::new(|| Regex::new(r"(?i)mask-icon").unwrap()); static ICON_SIZE_REGEX: Lazy = Lazy::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap()); // Special HashMap which holds the user defined Regex to speedup matching the regex. static ICON_BLACKLIST_REGEX: Lazy>> = Lazy::new(|| RwLock::new(HashMap::new())); -fn icon_redirect(domain: &str, template: &str) -> Option { - if !is_valid_domain(domain) { +async fn icon_redirect(domain: &str, template: &str) -> Option { + if !is_valid_domain(domain).await { warn!("Invalid domain: {}", domain); return None; } - if is_domain_blacklisted(domain) { + if is_domain_blacklisted(domain).await { return None; } @@ -84,30 +102,30 @@ fn icon_redirect(domain: &str, template: &str) -> Option { } #[get("//icon.png")] -fn icon_custom(domain: String) -> Option { - icon_redirect(&domain, &CONFIG.icon_service()) +async fn icon_custom(domain: String) -> Option { + icon_redirect(&domain, &CONFIG.icon_service()).await } #[get("//icon.png")] -fn icon_bitwarden(domain: String) -> Option { - icon_redirect(&domain, "https://icons.bitwarden.net/{}/icon.png") +async fn icon_bitwarden(domain: String) -> Option { + icon_redirect(&domain, "https://icons.bitwarden.net/{}/icon.png").await } #[get("//icon.png")] -fn icon_duckduckgo(domain: String) -> Option { - icon_redirect(&domain, "https://icons.duckduckgo.com/ip3/{}.ico") +async fn icon_duckduckgo(domain: String) -> Option { + icon_redirect(&domain, "https://icons.duckduckgo.com/ip3/{}.ico").await } #[get("//icon.png")] -fn icon_google(domain: String) -> Option { - icon_redirect(&domain, "https://www.google.com/s2/favicons?domain={}&sz=32") +async fn icon_google(domain: String) -> Option { + icon_redirect(&domain, "https://www.google.com/s2/favicons?domain={}&sz=32").await } #[get("//icon.png")] async fn icon_internal(domain: String) -> Cached<(ContentType, Vec)> { const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png"); - if !is_valid_domain(&domain) { + if !is_valid_domain(&domain).await { warn!("Invalid domain: {}", domain); return Cached::ttl( (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), @@ -128,7 +146,7 @@ async fn icon_internal(domain: String) -> Cached<(ContentType, Vec)> { /// /// This does some manual checks and makes use of Url to do some basic checking. /// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255. -fn is_valid_domain(domain: &str) -> bool { +async fn is_valid_domain(domain: &str) -> bool { const ALLOWED_CHARS: &str = "_-."; // If parsing the domain fails using Url, it will not work with reqwest. @@ -260,57 +278,52 @@ mod tests { } } -fn is_domain_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 !is_global(ip_port.ip()) { - warn!("IP {} for domain '{}' is not a global IP!", ip_port.ip(), domain); - return true; - } +use cached::proc_macro::cached; +#[cached(key = "String", convert = r#"{ domain.to_string() }"#, size = 16, time = 60)] +async fn is_domain_blacklisted(domain: &str) -> bool { + if CONFIG.icon_blacklist_non_global_ips() { + if let Ok(s) = lookup_host((domain, 0)).await { + for addr in s { + if !is_global(addr.ip()) { + debug!("IP {} for domain '{}' is not a global IP!", addr.ip(), domain); + return true; } - false - }) - .unwrap_or(false); - - // Skip the regex check if the previous one is true already - if !is_blacklisted { - if let Some(blacklist) = CONFIG.icon_blacklist_regex() { - let mut regex_hashmap = ICON_BLACKLIST_REGEX.read().unwrap(); - - // Use the pre-generate Regex stored in a Lazy HashMap if there's one, else generate it. - let regex = if let Some(regex) = regex_hashmap.get(&blacklist) { - regex - } else { - drop(regex_hashmap); - - let mut regex_hashmap_write = ICON_BLACKLIST_REGEX.write().unwrap(); - // Clear the current list if the previous key doesn't exists. - // To prevent growing of the HashMap after someone has changed it via the admin interface. - if regex_hashmap_write.len() >= 1 { - regex_hashmap_write.clear(); - } - - // Generate the regex to store in too the Lazy Static HashMap. - let blacklist_regex = Regex::new(&blacklist).unwrap(); - regex_hashmap_write.insert(blacklist.to_string(), blacklist_regex); - drop(regex_hashmap_write); - - regex_hashmap = ICON_BLACKLIST_REGEX.read().unwrap(); - regex_hashmap.get(&blacklist).unwrap() - }; - - // Use the pre-generate Regex stored in a Lazy HashMap. - if regex.is_match(domain) { - debug!("Blacklisted domain: {} matched ICON_BLACKLIST_REGEX", domain); - is_blacklisted = true; } } } - is_blacklisted + if let Some(blacklist) = CONFIG.icon_blacklist_regex() { + let mut regex_hashmap = ICON_BLACKLIST_REGEX.read().await; + + // Use the pre-generate Regex stored in a Lazy HashMap if there's one, else generate it. + let regex = if let Some(regex) = regex_hashmap.get(&blacklist) { + regex + } else { + drop(regex_hashmap); + + let mut regex_hashmap_write = ICON_BLACKLIST_REGEX.write().await; + // Clear the current list if the previous key doesn't exists. + // To prevent growing of the HashMap after someone has changed it via the admin interface. + if regex_hashmap_write.len() >= 1 { + regex_hashmap_write.clear(); + } + + // Generate the regex to store in too the Lazy Static HashMap. + let blacklist_regex = Regex::new(&blacklist); + regex_hashmap_write.insert(blacklist.to_string(), blacklist_regex.unwrap()); + drop(regex_hashmap_write); + + regex_hashmap = ICON_BLACKLIST_REGEX.read().await; + regex_hashmap.get(&blacklist).unwrap() + }; + + // Use the pre-generate Regex stored in a Lazy HashMap. + if regex.is_match(domain) { + debug!("Blacklisted domain: {} matched ICON_BLACKLIST_REGEX", domain); + return true; + } + } + false } async fn get_icon(domain: &str) -> Option<(Vec, String)> { @@ -322,7 +335,7 @@ async fn get_icon(domain: &str) -> Option<(Vec, String)> { } if let Some(icon) = get_cached_icon(&path).await { - let icon_type = match get_icon_type(&icon) { + let icon_type = match get_icon_type(&icon).await { Some(x) => x, _ => "x-icon", }; @@ -412,91 +425,62 @@ impl Icon { } } -/// Iterates over the HTML document to find -/// When found it will stop the iteration and the found base href will be shared deref via `base_href`. -/// -/// # Arguments -/// * `node` - A Parsed HTML document via html5ever::parse_document() -/// * `base_href` - a mutable url::Url which will be overwritten when a base href tag has been found. -/// -fn get_base_href(node: &std::rc::Rc, base_href: &mut url::Url) -> bool { - if let markup5ever_rcdom::NodeData::Element { - name, - attrs, - .. - } = &node.data - { - if name.local.as_ref() == "base" { - let attrs = attrs.borrow(); - for attr in attrs.iter() { - let attr_name = attr.name.local.as_ref(); - let attr_value = attr.value.as_ref(); +async fn get_favicons_node( + dom: InfallibleTokenizer, FaviconEmitter>, + icons: &mut Vec, + url: &url::Url, +) { + const TAG_LINK: &[u8] = b"link"; + const TAG_BASE: &[u8] = b"base"; + const TAG_HEAD: &[u8] = b"head"; + const ATTR_REL: &[u8] = b"rel"; + const ATTR_HREF: &[u8] = b"href"; + const ATTR_SIZES: &[u8] = b"sizes"; - if attr_name == "href" { - debug!("Found base href: {}", attr_value); - *base_href = match base_href.join(attr_value) { - Ok(href) => href, - _ => base_href.clone(), - }; - return true; - } - } - return true; - } - } - - // TODO: Might want to limit the recursion depth? - for child in node.children.borrow().iter() { - // Check if we got a true back and stop the iter. - // This means we found a tag and can stop processing the html. - if get_base_href(child, base_href) { - return true; - } - } - false -} - -fn get_favicons_node(node: &std::rc::Rc, icons: &mut Vec, url: &url::Url) { - if let markup5ever_rcdom::NodeData::Element { - name, - attrs, - .. - } = &node.data - { - if name.local.as_ref() == "link" { - let mut has_rel = false; - let mut href = None; - let mut sizes = None; - - let attrs = attrs.borrow(); - for attr in attrs.iter() { - let attr_name = attr.name.local.as_ref(); - let attr_value = attr.value.as_ref(); - - if attr_name == "rel" && ICON_REL_REGEX.is_match(attr_value) && !ICON_REL_BLACKLIST.is_match(attr_value) + let mut base_url = url.clone(); + let mut icon_tags: Vec = Vec::new(); + for token in dom { + match token { + FaviconToken::StartTag(tag) => { + if tag.name == TAG_LINK + && tag.attributes.contains_key(ATTR_REL) + && tag.attributes.contains_key(ATTR_HREF) { - has_rel = true; - } else if attr_name == "href" { - href = Some(attr_value); - } else if attr_name == "sizes" { - sizes = Some(attr_value); + let rel_value = std::str::from_utf8(tag.attributes.get(ATTR_REL).unwrap()) + .unwrap_or_default() + .to_ascii_lowercase(); + if rel_value.contains("icon") && !rel_value.contains("mask-icon") { + icon_tags.push(tag); + } + } else if tag.name == TAG_BASE && tag.attributes.contains_key(ATTR_HREF) { + let href = std::str::from_utf8(tag.attributes.get(ATTR_HREF).unwrap()).unwrap_or_default(); + debug!("Found base href: {href}"); + base_url = match base_url.join(href) { + Ok(inner_url) => inner_url, + _ => url.clone(), + }; } } - - if has_rel { - if let Some(inner_href) = href { - if let Ok(full_href) = url.join(inner_href).map(String::from) { - let priority = get_icon_priority(&full_href, sizes); - icons.push(Icon::new(priority, full_href)); - } + FaviconToken::EndTag(tag) => { + if tag.name == TAG_HEAD { + break; } } } } - // TODO: Might want to limit the recursion depth? - for child in node.children.borrow().iter() { - get_favicons_node(child, icons, url); + for icon_tag in icon_tags { + if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF) { + if let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default()) { + let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) { + std::str::from_utf8(v).unwrap_or_default() + } else { + "" + }; + let priority = get_icon_priority(full_href.as_str(), sizes).await; + icons.push(Icon::new(priority, full_href.to_string())); + } + }; } } @@ -514,13 +498,13 @@ struct IconUrlResult { /// /// # Example /// ``` -/// let icon_result = get_icon_url("github.com")?; -/// let icon_result = get_icon_url("vaultwarden.discourse.group")?; +/// let icon_result = get_icon_url("github.com").await?; +/// let icon_result = get_icon_url("vaultwarden.discourse.group").await?; /// ``` async fn get_icon_url(domain: &str) -> Result { // Default URL with secure and insecure schemes - let ssldomain = format!("https://{}", domain); - let httpdomain = format!("http://{}", domain); + let ssldomain = format!("https://{domain}"); + let httpdomain = format!("http://{domain}"); // First check the domain as given during the request for both HTTPS and HTTP. let resp = match get_page(&ssldomain).or_else(|_| get_page(&httpdomain)).await { @@ -537,26 +521,25 @@ async fn get_icon_url(domain: &str) -> Result { tld = domain_parts.next_back().unwrap(), base = domain_parts.next_back().unwrap() ); - if is_valid_domain(&base_domain) { - let sslbase = format!("https://{}", base_domain); - let httpbase = format!("http://{}", base_domain); - debug!("[get_icon_url]: Trying without subdomains '{}'", base_domain); + if is_valid_domain(&base_domain).await { + let sslbase = format!("https://{base_domain}"); + let httpbase = format!("http://{base_domain}"); + debug!("[get_icon_url]: Trying without subdomains '{base_domain}'"); sub_resp = get_page(&sslbase).or_else(|_| get_page(&httpbase)).await; } // When the domain is not an IP, and has less then 2 dots, try to add www. infront of it. } else if is_ip.is_err() && domain.matches('.').count() < 2 { - let www_domain = format!("www.{}", domain); - if is_valid_domain(&www_domain) { - let sslwww = format!("https://{}", www_domain); - let httpwww = format!("http://{}", www_domain); - debug!("[get_icon_url]: Trying with www. prefix '{}'", www_domain); + let www_domain = format!("www.{domain}"); + if is_valid_domain(&www_domain).await { + let sslwww = format!("https://{www_domain}"); + let httpwww = format!("http://{www_domain}"); + debug!("[get_icon_url]: Trying with www. prefix '{www_domain}'"); sub_resp = get_page(&sslwww).or_else(|_| get_page(&httpwww)).await; } } - sub_resp } }; @@ -571,26 +554,23 @@ async fn get_icon_url(domain: &str) -> Result { // Set the referer to be used on the final request, some sites check this. // Mostly used to prevent direct linking and other security resons. - referer = url.as_str().to_string(); + referer = url.to_string(); - // Add the default favicon.ico to the list with the domain the content responded from. + // Add the fallback favicon.ico and apple-touch-icon.png to the list with the domain the content responded from. iconlist.push(Icon::new(35, String::from(url.join("/favicon.ico").unwrap()))); + iconlist.push(Icon::new(40, String::from(url.join("/apple-touch-icon.png").unwrap()))); // 384KB should be more than enough for the HTML, though as we only really need the HTML header. - let mut limited_reader = stream_to_bytes_limit(content, 384 * 1024).await?.reader(); + let limited_reader = stream_to_bytes_limit(content, 384 * 1024).await?.to_vec(); - use html5ever::tendril::TendrilSink; - let dom = html5ever::parse_document(markup5ever_rcdom::RcDom::default(), Default::default()) - .from_utf8() - .read_from(&mut limited_reader)?; - - let mut base_url: url::Url = url; - get_base_href(&dom.document, &mut base_url); - get_favicons_node(&dom.document, &mut iconlist, &base_url); + let dom = Tokenizer::new_with_emitter(limited_reader.to_reader(), FaviconEmitter::default()).infallible(); + get_favicons_node(dom, &mut iconlist, &url).await; } else { // Add the default favicon.ico to the list with just the given domain - iconlist.push(Icon::new(35, format!("{}/favicon.ico", ssldomain))); - iconlist.push(Icon::new(35, format!("{}/favicon.ico", httpdomain))); + iconlist.push(Icon::new(35, format!("{ssldomain}/favicon.ico"))); + iconlist.push(Icon::new(40, format!("{ssldomain}/apple-touch-icon.png"))); + iconlist.push(Icon::new(35, format!("{httpdomain}/favicon.ico"))); + iconlist.push(Icon::new(40, format!("{httpdomain}/apple-touch-icon.png"))); } // Sort the iconlist by priority @@ -608,7 +588,7 @@ async fn get_page(url: &str) -> Result { } async fn get_page_with_referer(url: &str, referer: &str) -> Result { - if is_domain_blacklisted(url::Url::parse(url).unwrap().host_str().unwrap_or_default()) { + if is_domain_blacklisted(url::Url::parse(url).unwrap().host_str().unwrap_or_default()).await { warn!("Favicon '{}' resolves to a blacklisted domain or IP!", url); } @@ -632,12 +612,12 @@ async fn get_page_with_referer(url: &str, referer: &str) -> Result) -> u8 { +async fn get_icon_priority(href: &str, sizes: &str) -> u8 { // Check if there is a dimension set - let (width, height) = parse_sizes(sizes); + let (width, height) = parse_sizes(sizes).await; // Check if there is a size given if width != 0 && height != 0 { @@ -679,15 +659,15 @@ fn get_icon_priority(href: &str, sizes: Option<&str>) -> u8 { /// /// # Example /// ``` -/// let (width, height) = parse_sizes("64x64"); // (64, 64) -/// let (width, height) = parse_sizes("x128x128"); // (128, 128) -/// let (width, height) = parse_sizes("32"); // (0, 0) +/// let (width, height) = parse_sizes("64x64").await; // (64, 64) +/// let (width, height) = parse_sizes("x128x128").await; // (128, 128) +/// let (width, height) = parse_sizes("32").await; // (0, 0) /// ``` -fn parse_sizes(sizes: Option<&str>) -> (u16, u16) { +async fn parse_sizes(sizes: &str) -> (u16, u16) { let mut width: u16 = 0; let mut height: u16 = 0; - if let Some(sizes) = sizes { + if !sizes.is_empty() { match ICON_SIZE_REGEX.captures(sizes.trim()) { None => {} Some(dimensions) => { @@ -703,7 +683,7 @@ fn parse_sizes(sizes: Option<&str>) -> (u16, u16) { } async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { - if is_domain_blacklisted(domain) { + if is_domain_blacklisted(domain).await { err_silent!("Domain is blacklisted", domain) } @@ -727,7 +707,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { // Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create if body.len() >= 67 { // Check if the icon type is allowed, else try an icon from the list. - icon_type = get_icon_type(&body); + icon_type = get_icon_type(&body).await; if icon_type.is_none() { debug!("Icon from {} data:image uri, is not a valid image type", domain); continue; @@ -742,10 +722,10 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { } else { match get_page_with_referer(&icon.href, &icon_result.referer).await { Ok(res) => { - buffer = stream_to_bytes_limit(res, 512 * 1024).await?; // 512 KB for each icon max - // Check if the icon type is allowed, else try an icon from the list. - icon_type = get_icon_type(&buffer); + buffer = stream_to_bytes_limit(res, 5120 * 1024).await?; // 5120KB/5MB for each icon max (Same as icons.bitwarden.net) + // Check if the icon type is allowed, else try an icon from the list. + icon_type = get_icon_type(&buffer).await; if icon_type.is_none() { buffer.clear(); debug!("Icon from {}, is not a valid image type", icon.href); @@ -780,7 +760,7 @@ async fn save_icon(path: &str, icon: &[u8]) { } } -fn get_icon_type(bytes: &[u8]) -> Option<&'static str> { +async fn get_icon_type(bytes: &[u8]) -> Option<&'static str> { match bytes { [137, 80, 78, 71, ..] => Some("png"), [0, 0, 1, 0, ..] => Some("x-icon"), @@ -792,13 +772,30 @@ fn get_icon_type(bytes: &[u8]) -> Option<&'static str> { } } +/// Minimize the amount of bytes to be parsed from a reqwest result. +/// This prevents very long parsing and memory usage. +async fn stream_to_bytes_limit(res: Response, max_size: usize) -> Result { + let mut stream = res.bytes_stream().take(max_size); + let mut buf = BytesMut::new(); + let mut size = 0; + while let Some(chunk) = stream.next().await { + let chunk = &chunk?; + size += chunk.len(); + buf.extend(chunk); + if size >= max_size { + break; + } + } + Ok(buf.freeze()) +} + /// This is an implementation of the default Cookie Jar from Reqwest and reqwest_cookie_store build by pfernie. /// The default cookie jar used by Reqwest keeps all the cookies based upon the Max-Age or Expires which could be a long time. /// That could be used for tracking, to prevent this we force the lifespan of the cookies to always be max two minutes. /// A Cookie Jar is needed because some sites force a redirect with cookies to verify if a request uses cookies or not. use cookie_store::CookieStore; #[derive(Default)] -pub struct Jar(RwLock); +pub struct Jar(std::sync::RwLock); impl reqwest::cookie::CookieStore for Jar { fn set_cookies(&self, cookie_headers: &mut dyn Iterator, url: &url::Url) { @@ -836,11 +833,136 @@ impl reqwest::cookie::CookieStore for Jar { } } -async fn stream_to_bytes_limit(res: Response, max_size: usize) -> Result { - let mut stream = res.bytes_stream().take(max_size); - let mut buf = BytesMut::new(); - while let Some(chunk) = stream.next().await { - buf.extend(chunk?); - } - Ok(buf.freeze()) +/// Custom FaviconEmitter for the html5gum parser. +/// The FaviconEmitter is using an almost 1:1 copy of the DefaultEmitter with some small changes. +/// This prevents emitting tags like comments, doctype and also strings between the tags. +/// Therefor parsing the HTML content is faster. +use std::collections::{BTreeSet, VecDeque}; + +enum FaviconToken { + StartTag(StartTag), + EndTag(EndTag), +} + +#[derive(Default)] +struct FaviconEmitter { + current_token: Option, + last_start_tag: Vec, + current_attribute: Option<(Vec, Vec)>, + seen_attributes: BTreeSet>, + emitted_tokens: VecDeque, +} + +impl FaviconEmitter { + fn emit_token(&mut self, token: FaviconToken) { + self.emitted_tokens.push_front(token); + } + + fn flush_current_attribute(&mut self) { + if let Some((k, v)) = self.current_attribute.take() { + match self.current_token { + Some(FaviconToken::StartTag(ref mut tag)) => { + tag.attributes.entry(k).and_modify(|_| {}).or_insert(v); + } + Some(FaviconToken::EndTag(_)) => { + self.seen_attributes.insert(k); + } + _ => { + debug_assert!(false); + } + } + } + } +} + +impl Emitter for FaviconEmitter { + type Token = FaviconToken; + + fn set_last_start_tag(&mut self, last_start_tag: Option<&[u8]>) { + self.last_start_tag.clear(); + self.last_start_tag.extend(last_start_tag.unwrap_or_default()); + } + + fn pop_token(&mut self) -> Option { + self.emitted_tokens.pop_back() + } + + fn init_start_tag(&mut self) { + self.current_token = Some(FaviconToken::StartTag(StartTag::default())); + } + + fn init_end_tag(&mut self) { + self.current_token = Some(FaviconToken::EndTag(EndTag::default())); + self.seen_attributes.clear(); + } + + fn emit_current_tag(&mut self) { + self.flush_current_attribute(); + let mut token = self.current_token.take().unwrap(); + match token { + FaviconToken::EndTag(_) => { + self.seen_attributes.clear(); + } + FaviconToken::StartTag(ref mut tag) => { + self.set_last_start_tag(Some(&tag.name)); + } + } + self.emit_token(token); + } + + fn push_tag_name(&mut self, s: &[u8]) { + match self.current_token { + Some( + FaviconToken::StartTag(StartTag { + ref mut name, + .. + }) + | FaviconToken::EndTag(EndTag { + ref mut name, + .. + }), + ) => { + name.extend(s); + } + _ => debug_assert!(false), + } + } + + fn init_attribute(&mut self) { + self.flush_current_attribute(); + self.current_attribute = Some((Vec::new(), Vec::new())); + } + + fn push_attribute_name(&mut self, s: &[u8]) { + self.current_attribute.as_mut().unwrap().0.extend(s); + } + + fn push_attribute_value(&mut self, s: &[u8]) { + self.current_attribute.as_mut().unwrap().1.extend(s); + } + + fn current_is_appropriate_end_tag_token(&mut self) -> bool { + match self.current_token { + Some(FaviconToken::EndTag(ref tag)) => !self.last_start_tag.is_empty() && self.last_start_tag == tag.name, + _ => false, + } + } + + // We do not want and need these parts of the HTML document + // These will be skipped and ignored during the tokenization and iteration. + fn emit_current_comment(&mut self) {} + fn emit_current_doctype(&mut self) {} + fn emit_eof(&mut self) {} + fn emit_error(&mut self, _: html5gum::Error) {} + fn emit_string(&mut self, _: &[u8]) {} + fn init_comment(&mut self) {} + fn init_doctype(&mut self) {} + fn push_comment(&mut self, _: &[u8]) {} + fn push_doctype_name(&mut self, _: &[u8]) {} + fn push_doctype_public_identifier(&mut self, _: &[u8]) {} + fn push_doctype_system_identifier(&mut self, _: &[u8]) {} + fn set_doctype_public_identifier(&mut self, _: &[u8]) {} + fn set_doctype_system_identifier(&mut self, _: &[u8]) {} + fn set_force_quirks(&mut self) {} + fn set_self_closing(&mut self) {} } diff --git a/src/config.rs b/src/config.rs index d2a52ef9..f00ea50d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -569,12 +569,14 @@ make_config! { _enable_smtp: bool, true, def, true; /// Host smtp_host: String, true, option; - /// Enable Secure SMTP |> (Explicit) - Enabling this by default would use STARTTLS (Standard ports 587 or 25) - smtp_ssl: bool, true, def, true; - /// Force TLS |> (Implicit) - Enabling this would force the use of an SSL/TLS connection, instead of upgrading an insecure one with STARTTLS (Standard port 465) - smtp_explicit_tls: bool, true, def, false; + /// DEPRECATED smtp_ssl |> DEPRECATED - Please use SMTP_SECURITY + smtp_ssl: bool, false, option; + /// DEPRECATED smtp_explicit_tls |> DEPRECATED - Please use SMTP_SECURITY + smtp_explicit_tls: bool, false, option; + /// Secure SMTP |> ("starttls", "force_tls", "off") Enable a secure connection. Default is "starttls" (Explicit - ports 587 or 25), "force_tls" (Implicit - port 465) or "off", no encryption + smtp_security: String, true, auto, |c| smtp_convert_deprecated_ssl_options(c.smtp_ssl, c.smtp_explicit_tls); // TODO: After deprecation make it `def, "starttls".to_string()` /// Port - smtp_port: u16, true, auto, |c| if c.smtp_explicit_tls {465} else if c.smtp_ssl {587} else {25}; + smtp_port: u16, true, auto, |c| if c.smtp_security == *"force_tls" {465} else if c.smtp_security == *"starttls" {587} else {25}; /// From Address smtp_from: String, true, def, String::new(); /// From Name @@ -657,6 +659,13 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { } if cfg._enable_smtp { + match cfg.smtp_security.as_str() { + "off" | "starttls" | "force_tls" => (), + _ => err!( + "`SMTP_SECURITY` is invalid. It needs to be one of the following options: starttls, force_tls or off" + ), + } + if cfg.smtp_host.is_some() == cfg.smtp_from.is_empty() { err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support") } @@ -735,6 +744,20 @@ fn extract_url_path(url: &str) -> String { } } +/// Convert the old SMTP_SSL and SMTP_EXPLICIT_TLS options +fn smtp_convert_deprecated_ssl_options(smtp_ssl: Option, smtp_explicit_tls: Option) -> String { + if smtp_explicit_tls.is_some() || smtp_ssl.is_some() { + println!("[DEPRECATED]: `SMTP_SSL` or `SMTP_EXPLICIT_TLS` is set. Please use `SMTP_SECURITY` instead."); + } + if smtp_explicit_tls.is_some() && smtp_explicit_tls.unwrap() { + return "force_tls".to_string(); + } else if smtp_ssl.is_some() && !smtp_ssl.unwrap() { + return "off".to_string(); + } + // Return the default `starttls` in all other cases + "starttls".to_string() +} + impl Config { pub fn load() -> Result { // Loading from env and file diff --git a/src/mail.rs b/src/mail.rs index df9919d2..362d4aa3 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -30,7 +30,7 @@ fn mailer() -> SmtpTransport { .timeout(Some(Duration::from_secs(CONFIG.smtp_timeout()))); // Determine security - let smtp_client = if CONFIG.smtp_ssl() || CONFIG.smtp_explicit_tls() { + let smtp_client = if CONFIG.smtp_security() != *"off" { let mut tls_parameters = TlsParameters::builder(host); if CONFIG.smtp_accept_invalid_hostnames() { tls_parameters = tls_parameters.dangerous_accept_invalid_hostnames(true); @@ -40,7 +40,7 @@ fn mailer() -> SmtpTransport { } let tls_parameters = tls_parameters.build().unwrap(); - if CONFIG.smtp_explicit_tls() { + if CONFIG.smtp_security() == *"force_tls" { smtp_client.tls(Tls::Wrapper(tls_parameters)) } else { smtp_client.tls(Tls::Required(tls_parameters)) diff --git a/src/static/scripts/bootstrap-native.js b/src/static/scripts/bootstrap-native.js index 3827dfa6..c00b4e87 100644 --- a/src/static/scripts/bootstrap-native.js +++ b/src/static/scripts/bootstrap-native.js @@ -1,6 +1,6 @@ /*! - * Native JavaScript for Bootstrap v4.0.8 (https://thednp.github.io/bootstrap.native/) - * Copyright 2015-2021 © dnp_theme + * Native JavaScript for Bootstrap v4.1.0 (https://thednp.github.io/bootstrap.native/) + * Copyright 2015-2022 © dnp_theme * Licensed under MIT (https://github.com/thednp/bootstrap.native/blob/master/LICENSE) */ (function (global, factory) { @@ -9,157 +9,599 @@ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.BSN = factory()); })(this, (function () { 'use strict'; - const transitionEndEvent = 'webkitTransition' in document.head.style ? 'webkitTransitionEnd' : 'transitionend'; + /** @type {Record} */ + const EventRegistry = {}; - const supportTransition = 'webkitTransition' in document.head.style || 'transition' in document.head.style; + /** + * The global event listener. + * + * @this {Element | HTMLElement | Window | Document} + * @param {Event} e + * @returns {void} + */ + function globalListener(e) { + const that = this; + const { type } = e; + const oneEvMap = EventRegistry[type] ? [...EventRegistry[type]] : []; - const transitionDuration = 'webkitTransition' in document.head.style ? 'webkitTransitionDuration' : 'transitionDuration'; + oneEvMap.forEach((elementsMap) => { + const [element, listenersMap] = elementsMap; + [...listenersMap].forEach((listenerMap) => { + if (element === that) { + const [listener, options] = listenerMap; + listener.apply(element, [e]); - const transitionProperty = 'webkitTransition' in document.head.style ? 'webkitTransitionProperty' : 'transitionProperty'; + if (options && options.once) { + removeListener(element, type, listener, options); + } + } + }); + }); + } - function getElementTransitionDuration(element) { + /** + * Register a new listener with its options and attach the `globalListener` + * to the target if this is the first listener. + * + * @param {Element | HTMLElement | Window | Document} element + * @param {string} eventType + * @param {EventListenerObject['handleEvent']} listener + * @param {AddEventListenerOptions=} options + */ + const addListener = (element, eventType, listener, options) => { + // get element listeners first + if (!EventRegistry[eventType]) { + EventRegistry[eventType] = new Map(); + } + const oneEventMap = EventRegistry[eventType]; + + if (!oneEventMap.has(element)) { + oneEventMap.set(element, new Map()); + } + const oneElementMap = oneEventMap.get(element); + + // get listeners size + const { size } = oneElementMap; + + // register listener with its options + if (oneElementMap) { + oneElementMap.set(listener, options); + } + + // add listener last + if (!size) { + element.addEventListener(eventType, globalListener, options); + } + }; + + /** + * Remove a listener from registry and detach the `globalListener` + * if no listeners are found in the registry. + * + * @param {Element | HTMLElement | Window | Document} element + * @param {string} eventType + * @param {EventListenerObject['handleEvent']} listener + * @param {AddEventListenerOptions=} options + */ + const removeListener = (element, eventType, listener, options) => { + // get listener first + const oneEventMap = EventRegistry[eventType]; + const oneElementMap = oneEventMap && oneEventMap.get(element); + const savedOptions = oneElementMap && oneElementMap.get(listener); + + // also recover initial options + const { options: eventOptions } = savedOptions !== undefined + ? savedOptions + : { options }; + + // unsubscribe second, remove from registry + if (oneElementMap && oneElementMap.has(listener)) oneElementMap.delete(listener); + if (oneEventMap && (!oneElementMap || !oneElementMap.size)) oneEventMap.delete(element); + if (!oneEventMap || !oneEventMap.size) delete EventRegistry[eventType]; + + // remove listener last + if (!oneElementMap || !oneElementMap.size) { + element.removeEventListener(eventType, globalListener, eventOptions); + } + }; + + /** + * Advanced event listener based on subscribe / publish pattern. + * @see https://www.patterns.dev/posts/classic-design-patterns/#observerpatternjavascript + * @see https://gist.github.com/shystruk/d16c0ee7ac7d194da9644e5d740c8338#file-subpub-js + * @see https://hackernoon.com/do-you-still-register-window-event-listeners-in-each-component-react-in-example-31a4b1f6f1c8 + */ + const EventListener = { + on: addListener, + off: removeListener, + globalListener, + registry: EventRegistry, + }; + + /** + * A global namespace for `click` event. + * @type {string} + */ + const mouseclickEvent = 'click'; + + /** + * A global namespace for 'transitionend' string. + * @type {string} + */ + const transitionEndEvent = 'transitionend'; + + /** + * A global namespace for 'transitionDelay' string. + * @type {string} + */ + const transitionDelay = 'transitionDelay'; + + /** + * A global namespace for `transitionProperty` string for modern browsers. + * + * @type {string} + */ + const transitionProperty = 'transitionProperty'; + + /** + * Shortcut for `window.getComputedStyle(element).propertyName` + * static method. + * + * * If `element` parameter is not an `HTMLElement`, `getComputedStyle` + * throws a `ReferenceError`. + * + * @param {HTMLElement | Element} element target + * @param {string} property the css property + * @return {string} the css property value + */ + function getElementStyle(element, property) { const computedStyle = getComputedStyle(element); - const propertyValue = computedStyle[transitionProperty]; - const durationValue = computedStyle[transitionDuration]; + + // @ts-ignore -- must use camelcase strings, + // or non-camelcase strings with `getPropertyValue` + return property in computedStyle ? computedStyle[property] : ''; + } + + /** + * Utility to get the computed `transitionDelay` + * from Element in miliseconds. + * + * @param {HTMLElement | Element} element target + * @return {number} the value in miliseconds + */ + function getElementTransitionDelay(element) { + const propertyValue = getElementStyle(element, transitionProperty); + const delayValue = getElementStyle(element, transitionDelay); + + const delayScale = delayValue.includes('ms') ? 1 : 1000; + const duration = propertyValue && propertyValue !== 'none' + ? parseFloat(delayValue) * delayScale : 0; + + return !Number.isNaN(duration) ? duration : 0; + } + + /** + * A global namespace for 'transitionDuration' string. + * @type {string} + */ + const transitionDuration = 'transitionDuration'; + + /** + * Utility to get the computed `transitionDuration` + * from Element in miliseconds. + * + * @param {HTMLElement | Element} element target + * @return {number} the value in miliseconds + */ + function getElementTransitionDuration(element) { + const propertyValue = getElementStyle(element, transitionProperty); + const durationValue = getElementStyle(element, transitionDuration); const durationScale = durationValue.includes('ms') ? 1 : 1000; - const duration = supportTransition && propertyValue && propertyValue !== 'none' + const duration = propertyValue && propertyValue !== 'none' ? parseFloat(durationValue) * durationScale : 0; return !Number.isNaN(duration) ? duration : 0; } + /** + * Utility to make sure callbacks are consistently + * called when transition ends. + * + * @param {HTMLElement | Element} element target + * @param {EventListener} handler `transitionend` callback + */ function emulateTransitionEnd(element, handler) { let called = 0; const endEvent = new Event(transitionEndEvent); const duration = getElementTransitionDuration(element); + const delay = getElementTransitionDelay(element); if (duration) { - element.addEventListener(transitionEndEvent, function transitionEndWrapper(e) { + /** + * Wrap the handler in on -> off callback + * @type {EventListener} e Event object + */ + const transitionEndWrapper = (e) => { if (e.target === element) { handler.apply(element, [e]); element.removeEventListener(transitionEndEvent, transitionEndWrapper); called = 1; } - }); + }; + element.addEventListener(transitionEndEvent, transitionEndWrapper); setTimeout(() => { if (!called) element.dispatchEvent(endEvent); - }, duration + 17); + }, duration + delay + 17); } else { handler.apply(element, [endEvent]); } } - function queryElement(selector, parent) { - const lookUp = parent && parent instanceof Element ? parent : document; - return selector instanceof Element ? selector : lookUp.querySelector(selector); + /** + * Returns the `document` or the `#document` element. + * @see https://github.com/floating-ui/floating-ui + * @param {(Node | HTMLElement | Element | globalThis)=} node + * @returns {Document} + */ + function getDocument(node) { + if (node instanceof HTMLElement) return node.ownerDocument; + if (node instanceof Window) return node.document; + return window.document; } + /** + * A global array of possible `ParentNode`. + */ + const parentNodes = [Document, Element, HTMLElement]; + + /** + * A global array with `Element` | `HTMLElement`. + */ + const elementNodes = [Element, HTMLElement]; + + /** + * Utility to check if target is typeof `HTMLElement`, `Element`, `Node` + * or find one that matches a selector. + * + * @param {HTMLElement | Element | string} selector the input selector or target element + * @param {(HTMLElement | Element | Document)=} parent optional node to look into + * @return {(HTMLElement | Element)?} the `HTMLElement` or `querySelector` result + */ + function querySelector(selector, parent) { + const lookUp = parentNodes.some((x) => parent instanceof x) + ? parent : getDocument(); + + // @ts-ignore + return elementNodes.some((x) => selector instanceof x) + // @ts-ignore + ? selector : lookUp.querySelector(selector); + } + + /** + * Shortcut for `HTMLElement.closest` method which also works + * with children of `ShadowRoot`. The order of the parameters + * is intentional since they're both required. + * + * @see https://stackoverflow.com/q/54520554/803358 + * + * @param {HTMLElement | Element} element Element to look into + * @param {string} selector the selector name + * @return {(HTMLElement | Element)?} the query result + */ + function closest(element, selector) { + return element ? (element.closest(selector) + // @ts-ignore -- break out of `ShadowRoot` + || closest(element.getRootNode().host, selector)) : null; + } + + /** + * Shortcut for `Object.assign()` static method. + * @param {Record} obj a target object + * @param {Record} source a source object + */ + const ObjectAssign = (obj, source) => Object.assign(obj, source); + + /** + * Check class in `HTMLElement.classList`. + * + * @param {HTMLElement | Element} element target + * @param {string} classNAME to check + * @returns {boolean} + */ function hasClass(element, classNAME) { return element.classList.contains(classNAME); } + /** + * Remove class from `HTMLElement.classList`. + * + * @param {HTMLElement | Element} element target + * @param {string} classNAME to remove + * @returns {void} + */ function removeClass(element, classNAME) { element.classList.remove(classNAME); } - const addEventListener = 'addEventListener'; + /** + * Shortcut for the `Element.dispatchEvent(Event)` method. + * + * @param {HTMLElement | Element} element is the target + * @param {Event} event is the `Event` object + */ + const dispatchEvent = (element, event) => element.dispatchEvent(event); - const removeEventListener = 'removeEventListener'; + /** @type {Map>>} */ + const componentData = new Map(); + /** + * An interface for web components background data. + * @see https://github.com/thednp/bootstrap.native/blob/master/src/components/base-component.js + */ + const Data = { + /** + * Sets web components data. + * @param {HTMLElement | Element | string} target target element + * @param {string} component the component's name or a unique key + * @param {Record} instance the component instance + */ + set: (target, component, instance) => { + const element = querySelector(target); + if (!element) return; - const fadeClass = 'fade'; + if (!componentData.has(component)) { + componentData.set(component, new Map()); + } - const showClass = 'show'; + const instanceMap = componentData.get(component); + // @ts-ignore - not undefined, but defined right above + instanceMap.set(element, instance); + }, - const dataBsDismiss = 'data-bs-dismiss'; + /** + * Returns all instances for specified component. + * @param {string} component the component's name or a unique key + * @returns {Map>?} all the component instances + */ + getAllFor: (component) => { + const instanceMap = componentData.get(component); - function bootstrapCustomEvent(namespacedEventType, eventProperties) { - const OriginalCustomEvent = new CustomEvent(namespacedEventType, { cancelable: true }); + return instanceMap || null; + }, - if (eventProperties instanceof Object) { - Object.keys(eventProperties).forEach((key) => { - Object.defineProperty(OriginalCustomEvent, key, { - value: eventProperties[key], - }); - }); + /** + * Returns the instance associated with the target. + * @param {HTMLElement | Element | string} target target element + * @param {string} component the component's name or a unique key + * @returns {Record?} the instance + */ + get: (target, component) => { + const element = querySelector(target); + const allForC = Data.getAllFor(component); + const instance = element && allForC && allForC.get(element); + + return instance || null; + }, + + /** + * Removes web components data. + * @param {HTMLElement | Element | string} target target element + * @param {string} component the component's name or a unique key + */ + remove: (target, component) => { + const element = querySelector(target); + const instanceMap = componentData.get(component); + if (!instanceMap || !element) return; + + instanceMap.delete(element); + + if (instanceMap.size === 0) { + componentData.delete(component); + } + }, + }; + + /** + * An alias for `Data.get()`. + * @type {SHORTER.getInstance} + */ + const getInstance = (target, component) => Data.get(target, component); + + /** + * Returns a namespaced `CustomEvent` specific to each component. + * @param {string} EventType Event.type + * @param {Record=} config Event.options | Event.properties + * @returns {SHORTER.OriginalEvent} a new namespaced event + */ + function OriginalEvent(EventType, config) { + const OriginalCustomEvent = new CustomEvent(EventType, { + cancelable: true, bubbles: true, + }); + + if (config instanceof Object) { + ObjectAssign(OriginalCustomEvent, config); } return OriginalCustomEvent; } + /** + * Global namespace for most components `fade` class. + */ + const fadeClass = 'fade'; + + /** + * Global namespace for most components `show` class. + */ + const showClass = 'show'; + + /** + * Global namespace for most components `dismiss` option. + */ + const dataBsDismiss = 'data-bs-dismiss'; + + /** @type {string} */ + const alertString = 'alert'; + + /** @type {string} */ + const alertComponent = 'Alert'; + + /** + * Shortcut for `HTMLElement.getAttribute()` method. + * @param {HTMLElement | Element} element target element + * @param {string} attribute attribute name + * @returns {string?} attribute value + */ + const getAttribute = (element, attribute) => element.getAttribute(attribute); + + /** + * The raw value or a given component option. + * + * @typedef {string | HTMLElement | Function | number | boolean | null} niceValue + */ + + /** + * Utility to normalize component options + * + * @param {any} value the input value + * @return {niceValue} the normalized value + */ function normalizeValue(value) { - if (value === 'true') { + if (value === 'true') { // boolean return true; } - if (value === 'false') { + if (value === 'false') { // boolean return false; } - if (!Number.isNaN(+value)) { + if (!Number.isNaN(+value)) { // number return +value; } - if (value === '' || value === 'null') { + if (value === '' || value === 'null') { // null return null; } - // string / function / Element / Object + // string / function / HTMLElement / object return value; } + /** + * Shortcut for `Object.keys()` static method. + * @param {Record} obj a target object + * @returns {string[]} + */ + const ObjectKeys = (obj) => Object.keys(obj); + + /** + * Shortcut for `String.toLowerCase()`. + * + * @param {string} source input string + * @returns {string} lowercase output string + */ + const toLowerCase = (source) => source.toLowerCase(); + + /** + * Utility to normalize component options. + * + * @param {HTMLElement | Element} element target + * @param {Record} defaultOps component default options + * @param {Record} inputOps component instance options + * @param {string=} ns component namespace + * @return {Record} normalized component options object + */ function normalizeOptions(element, defaultOps, inputOps, ns) { - const normalOps = {}; - const dataOps = {}; + // @ts-ignore -- our targets are always `HTMLElement` const data = { ...element.dataset }; + /** @type {Record} */ + const normalOps = {}; + /** @type {Record} */ + const dataOps = {}; + const title = 'title'; - Object.keys(data) - .forEach((k) => { - const key = k.includes(ns) - ? k.replace(ns, '').replace(/[A-Z]/, (match) => match.toLowerCase()) - : k; + ObjectKeys(data).forEach((k) => { + const key = ns && k.includes(ns) + ? k.replace(ns, '').replace(/[A-Z]/, (match) => toLowerCase(match)) + : k; - dataOps[key] = normalizeValue(data[k]); - }); + dataOps[key] = normalizeValue(data[k]); + }); - Object.keys(inputOps) - .forEach((k) => { - inputOps[k] = normalizeValue(inputOps[k]); - }); + ObjectKeys(inputOps).forEach((k) => { + inputOps[k] = normalizeValue(inputOps[k]); + }); - Object.keys(defaultOps) - .forEach((k) => { - if (k in inputOps) { - normalOps[k] = inputOps[k]; - } else if (k in dataOps) { - normalOps[k] = dataOps[k]; - } else { - normalOps[k] = defaultOps[k]; - } - }); + ObjectKeys(defaultOps).forEach((k) => { + if (k in inputOps) { + normalOps[k] = inputOps[k]; + } else if (k in dataOps) { + normalOps[k] = dataOps[k]; + } else { + normalOps[k] = k === title + ? getAttribute(element, title) + : defaultOps[k]; + } + }); return normalOps; } + var version = "4.1.0"; + + const Version = version; + /* Native JavaScript for Bootstrap 5 | Base Component ----------------------------------------------------- */ + /** Returns a new `BaseComponent` instance. */ class BaseComponent { - constructor(name, target, defaults, config) { + /** + * @param {HTMLElement | Element | string} target `Element` or selector string + * @param {BSN.ComponentOptions=} config component instance options + */ + constructor(target, config) { const self = this; - const element = queryElement(target); + const element = querySelector(target); - if (element[name]) element[name].dispose(); + if (!element) { + throw Error(`${self.name} Error: "${target}" is not a valid selector.`); + } + + /** @static @type {BSN.ComponentOptions} */ + self.options = {}; + + const prevInstance = Data.get(element, self.name); + if (prevInstance) prevInstance.dispose(); + + /** @type {HTMLElement | Element} */ self.element = element; - if (defaults && Object.keys(defaults).length) { - self.options = normalizeOptions(element, defaults, (config || {}), 'bs'); + if (self.defaults && Object.keys(self.defaults).length) { + self.options = normalizeOptions(element, self.defaults, (config || {}), 'bs'); } - element[name] = self; + + Data.set(element, self.name, self); } - dispose(name) { + /* eslint-disable */ + /** @static */ + get version() { return Version; } + /* eslint-enable */ + + /** @static */ + get name() { return this.constructor.name; } + + /** @static */ + // @ts-ignore + get defaults() { return this.constructor.defaults; } + + /** + * Removes component from target element; + */ + dispose() { const self = this; - self.element[name] = null; - Object.keys(self).forEach((prop) => { self[prop] = null; }); + Data.remove(self.element, self.name); + // @ts-ignore + ObjectKeys(self).forEach((prop) => { self[prop] = null; }); } } @@ -168,24 +610,39 @@ // ALERT PRIVATE GC // ================ - const alertString = 'alert'; - const alertComponent = 'Alert'; const alertSelector = `.${alertString}`; const alertDismissSelector = `[${dataBsDismiss}="${alertString}"]`; + /** + * Static method which returns an existing `Alert` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance} + */ + const getAlertInstance = (element) => getInstance(element, alertComponent); + + /** + * An `Alert` initialization callback. + * @type {BSN.InitCallback} + */ + const alertInitCallback = (element) => new Alert(element); + // ALERT CUSTOM EVENTS // =================== - const closeAlertEvent = bootstrapCustomEvent(`close.bs.${alertString}`); - const closedAlertEvent = bootstrapCustomEvent(`closed.bs.${alertString}`); + const closeAlertEvent = OriginalEvent(`close.bs.${alertString}`); + const closedAlertEvent = OriginalEvent(`closed.bs.${alertString}`); - // ALERT EVENT HANDLERS - // ==================== + // ALERT EVENT HANDLER + // =================== + /** + * Alert `transitionend` callback. + * @param {Alert} self target Alert instance + */ function alertTransitionEnd(self) { - const { element, relatedTarget } = self; + const { element } = self; toggleAlertHandler(self); - if (relatedTarget) closedAlertEvent.relatedTarget = relatedTarget; - element.dispatchEvent(closedAlertEvent); + dispatchEvent(element, closedAlertEvent); self.dispose(); element.remove(); @@ -193,16 +650,24 @@ // ALERT PRIVATE METHOD // ==================== + /** + * Toggle on / off the `click` event listener. + * @param {Alert} self the target alert instance + * @param {boolean=} add when `true`, event listener is added + */ function toggleAlertHandler(self, add) { - const action = add ? addEventListener : removeEventListener; - if (self.dismiss) self.dismiss[action]('click', self.close); + const action = add ? addListener : removeListener; + const { dismiss } = self; + if (dismiss) action(dismiss, mouseclickEvent, self.close); } // ALERT DEFINITION // ================ + /** Creates a new Alert instance. */ class Alert extends BaseComponent { + /** @param {HTMLElement | Element | string} target element or selector */ constructor(target) { - super(alertComponent, target); + super(target); // bind const self = this; @@ -210,28 +675,39 @@ const { element } = self; // the dismiss button - self.dismiss = queryElement(alertDismissSelector, element); - self.relatedTarget = null; + /** @static @type {(HTMLElement | Element)?} */ + self.dismiss = querySelector(alertDismissSelector, element); // add event listener - toggleAlertHandler(self, 1); + toggleAlertHandler(self, true); } + /* eslint-disable */ + /** + * Returns component name string. + * @readonly @static + */ + get name() { return alertComponent; } + /* eslint-enable */ + // ALERT PUBLIC METHODS // ==================== + /** + * Public method that hides the `.alert` element from the user, + * disposes the instance once animation is complete, then + * removes the element from the DOM. + * + * @param {Event=} e most likely the `click` event + * @this {Alert} the `Alert` instance or `EventTarget` + */ close(e) { - const target = e ? e.target : null; - const self = e - ? e.target.closest(alertSelector)[alertComponent] - : this; + // @ts-ignore + const self = e ? getAlertInstance(closest(this, alertSelector)) : this; + if (!self) return; const { element } = self; - if (self && element && hasClass(element, showClass)) { - if (target) { - closeAlertEvent.relatedTarget = target; - self.relatedTarget = target; - } - element.dispatchEvent(closeAlertEvent); + if (hasClass(element, showClass)) { + dispatchEvent(element, closeAlertEvent); if (closeAlertEvent.defaultPrevented) return; removeClass(element, showClass); @@ -242,123 +718,452 @@ } } + /** Remove the component from target element. */ dispose() { toggleAlertHandler(this); - super.dispose(alertComponent); + super.dispose(); } } - Alert.init = { - component: alertComponent, + ObjectAssign(Alert, { selector: alertSelector, - constructor: Alert, - }; + init: alertInitCallback, + getInstance: getAlertInstance, + }); + /** + * A global namespace for aria-pressed. + * @type {string} + */ + const ariaPressed = 'aria-pressed'; + + /** + * Shortcut for `HTMLElement.setAttribute()` method. + * @param {HTMLElement | Element} element target element + * @param {string} attribute attribute name + * @param {string} value attribute value + * @returns {void} + */ + const setAttribute = (element, attribute, value) => element.setAttribute(attribute, value); + + /** + * Add class to `HTMLElement.classList`. + * + * @param {HTMLElement | Element} element target + * @param {string} classNAME to add + * @returns {void} + */ function addClass(element, classNAME) { element.classList.add(classNAME); } + /** + * Global namespace for most components active class. + */ const activeClass = 'active'; + /** + * Global namespace for most components `toggle` option. + */ const dataBsToggle = 'data-bs-toggle'; + /** @type {string} */ + const buttonString = 'button'; + + /** @type {string} */ + const buttonComponent = 'Button'; + /* Native JavaScript for Bootstrap 5 | Button ---------------------------------------------*/ // BUTTON PRIVATE GC // ================= - const buttonString = 'button'; - const buttonComponent = 'Button'; const buttonSelector = `[${dataBsToggle}="${buttonString}"]`; - const ariaPressed = 'aria-pressed'; + + /** + * Static method which returns an existing `Button` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance