diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 50df1b0..95040ed 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -7,7 +7,7 @@ on: workflow_dispatch: jobs: - deploy: + deploy-site: runs-on: ubuntu-latest steps: - name: Checkout the Repository @@ -20,19 +20,14 @@ jobs: with: node-version: 18 - - name: Configure Cache - uses: actions/cache@v2 - with: - path: | - ${{ github.workspace }}/node_modules - **/target - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }} - - name: Install Rust Toolchain uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown + - name: Setup Rust Cache + uses: Swatinem/rust-cache@v2 + - name: Install Trunk uses: jetli/trunk-action@v0.4.0 with: @@ -63,3 +58,49 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} DISTRIBUTION_ID: ${{ secrets.AWS_CLOUDFRONT_DISTRIBUTION_ID }} + + deploy-analytics-lambda: + runs-on: ubuntu-latest + steps: + - name: Checkout the Repository + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Install the Stable Rust Toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@v2 + + - name: Install Zig Toolchain + uses: korandoru/setup-zig@v1 + with: + zig-version: 0.10.0 + + - name: Install Cargo Lambda + uses: jaxxstorm/action-install-gh-release@v1.9.0 + with: + repo: cargo-lambda/cargo-lambda + + - name: Build Lambda Function + run: cargo lambda build --release --arm64 --output-format zip + + - name: Configure AWS CLI + run: | + mkdir ~/.aws + echo "[default]" > ~/.aws/config + echo "credential_source = Environment" >> ~/.aws/config + + - name: Deploy Lambda Function + run: | + aws lambda update-function-code --function-name analytics_lambda \ + --zip-file "fileb://$(pwd)/target/lambda/analytics/bootstrap.zip" --publish + env: + AWS_DEFAULT_REGION: eu-west-1 + AWS_ACCESS_KEY_ID: "${{ secrets.ANALYTICS_DEPLOYER_ACCESS_KEY_ID }}" + AWS_SECRET_ACCESS_KEY: "${{ secrets.ANALYTICS_DEPLOYER_SECRET_ACCESS_KEY }}" diff --git a/Cargo.lock b/Cargo.lock index d2e4f71..9ca042b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,6 +520,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + [[package]] name = "gimli" version = "0.27.3" @@ -1891,7 +1904,9 @@ dependencies = [ "enum-iterator", "env_logger", "gloo 0.10.0", + "gloo-events 0.2.0", "include_dir", + "js-sys", "log", "macros", "model", @@ -1902,6 +1917,7 @@ dependencies = [ "thiserror", "time 0.3.26", "tokio", + "uuid", "wasm-bindgen", "wasm-bindgen-futures", "wasm-logger", @@ -2295,6 +2311,17 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5190c9442dcdaf0ddd50f37420417d219ae5261bbf5db120d0f9bab996c9cba1" +[[package]] +name = "uuid" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" +dependencies = [ + "getrandom", + "serde", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 57a3d74..1cd1bfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,13 +36,17 @@ time = { version = "0.3", features = ["formatting"] } async-trait = { version = "0.1" } enum-iterator = { version = "1.4" } gloo = { version = "0.10" } +gloo-events = { version = "0.2" } include_dir = { version = "0.7" } +js-sys = { version = "0.3" } log = { version = "0.4" } reqwest = { version = "0.11", features = ["json"] } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0" } thiserror = { version = "1.0" } +uuid = { version = "1.4", features = ["js", "serde"] } wasm-bindgen = { version = "0.2" } +wasm-bindgen-futures = { version = "0.4" } yew = { version = "0.20" } yew-hooks = { version = "0.2" } yew-router = { version = "0.17" } @@ -78,6 +82,7 @@ features = [ "LucideList", "LucideMenu", "LucidePencil", + "LucideRefreshCw", "LucideRss", "LucideX" ] @@ -85,11 +90,15 @@ features = [ [dependencies.web-sys] version = "0.3" features = [ + "Blob", "Document", "DomRect", "Element", + "HtmlSelectElement", "IntersectionObserver", "IntersectionObserverEntry", + "Navigator", + "Screen", "ScrollBehavior", "ScrollToOptions", "Window" diff --git a/Trunk.toml b/Trunk.toml index a22164f..8431257 100644 --- a/Trunk.toml +++ b/Trunk.toml @@ -1,3 +1,10 @@ +[watch] +ignore = [ + "analytics", + "cf", + "media" +] + [[hooks]] stage = "pre_build" command = "bash" diff --git a/analytics/lambda/.gitignore b/analytics/lambda/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/analytics/lambda/.gitignore @@ -0,0 +1 @@ +/target diff --git a/analytics/lambda/Cargo.lock b/analytics/lambda/Cargo.lock new file mode 100644 index 0000000..01ff00c --- /dev/null +++ b/analytics/lambda/Cargo.lock @@ -0,0 +1,2622 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c378d78423fdad8089616f827526ee33c19f2fddbd5de1629152c9593ba4783" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + +[[package]] +name = "analytics-lambda" +version = "0.1.0" +dependencies = [ + "analytics-model", + "async-trait", + "env_logger", + "fernet", + "lambda_runtime", + "log", + "openssl", + "poem", + "poem-lambda", + "serde", + "serde_json", + "sqlx", + "time", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "analytics-model" +version = "0.1.0" +dependencies = [ + "log", + "pbkdf2", + "rand_core", + "serde", + "sqlx", + "time", + "uuid", +] + +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "async-trait" +version = "0.1.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "aws_lambda_events" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65991dbc3bfb586939ba1527eefdc99bc21157b6ec891f180fb1e16e2dddc7a9" +dependencies = [ + "base64", + "bytes", + "http", + "http-body", + "http-serde", + "query_map", + "serde", + "serde_json", +] + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "const-oid" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" + +[[package]] +name = "cpufeatures" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484" + +[[package]] +name = "crossbeam-queue" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946" +dependencies = [ + "serde", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_logger" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136526188508e25c6fef639d7927dfb3e0e3084488bf202267829cf7fc23dbdd" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fastrand" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" + +[[package]] +name = "fernet" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3364d69f691f3903b1a71605fa04f40a7c2d259f0f0512347e36d19a63debf1f" +dependencies = [ + "base64", + "byteorder", + "getrandom", + "openssl", + "zeroize", +] + +[[package]] +name = "finl_unicode" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" + +[[package]] +name = "flume" +version = "0.10.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1657b4441c3403d9f7b3409e47575237dac27b1b5726df654a6ecbf92f0f7577" +dependencies = [ + "futures-core", + "futures-sink", + "pin-project", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" + +[[package]] +name = "futures-executor" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" + +[[package]] +name = "futures-macro" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "futures-sink" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" + +[[package]] +name = "futures-task" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" + +[[package]] +name = "futures-util" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" + +[[package]] +name = "h2" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap 1.9.3", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.0", +] + +[[package]] +name = "headers" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "http" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "http-serde" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f560b665ad9f1572cfcaf034f7fb84338a7ce945216d64a90fd81f046a3caee" +dependencies = [ + "http", + "serde", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "0.14.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.9", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +dependencies = [ + "equivalent", + "hashbrown 0.14.0", +] + +[[package]] +name = "is-terminal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +dependencies = [ + "hermit-abi", + "rustix", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" + +[[package]] +name = "js-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lambda_http" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b15e8ab48a5d8eab3110567008caad4d191924d1470b74c5c8b802b904b5a34" +dependencies = [ + "aws_lambda_events", + "base64", + "bytes", + "encoding_rs", + "futures", + "http", + "http-body", + "hyper", + "lambda_runtime", + "mime", + "percent-encoding", + "serde", + "serde_json", + "serde_urlencoded", + "url", +] + +[[package]] +name = "lambda_runtime" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8300e2e10ab2a49016584d9f248736a9da5ea819648df4c9d6c82fa2231fb510" +dependencies = [ + "async-stream", + "bytes", + "futures", + "http", + "http-serde", + "hyper", + "lambda_runtime_api_client", + "serde", + "serde_json", + "serde_path_to_error", + "tokio", + "tokio-stream", + "tower", + "tracing", +] + +[[package]] +name = "lambda_runtime_api_client" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "690c5ae01f3acac8c9c3348b556fc443054e9b7f1deaf53e9ebab716282bf0ed" +dependencies = [ + "http", + "hyper", + "tokio", + "tower-service", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] + +[[package]] +name = "libc" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "libm" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" + +[[package]] +name = "libsqlite3-sys" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128" + +[[package]] +name = "lock_api" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "md-5" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" +dependencies = [ + "digest", +] + +[[package]] +name = "memchr" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "openssl" +version = "0.10.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c" +dependencies = [ + "bitflags 2.4.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "openssl-src" +version = "300.1.3+3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd2c101a165fff9935e34def4669595ab1c7847943c42be86e21503e482be107" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db4d56a4c0478783083cfafcc42493dd4a981d41669da64b4572a2a089b51b1d" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" + +[[package]] +name = "pin-project" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" + +[[package]] +name = "poem" +version = "1.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc7ae19f3e791ae8108b08801abb3708d64d3a16490c720e0b81040cae87b5d" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "mime", + "parking_lot", + "percent-encoding", + "pin-project-lite", + "poem-derive", + "regex", + "rfc7239", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "poem-derive" +version = "1.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550a0bce7273b278894ef3ccc5a6869e7031b6870042f3cc6826ed9faa980a6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "poem-lambda" +version = "1.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38385c8a3d259782ba168cfd8901bfafe646a84de1ad6a9ccf59e14634c51b2" +dependencies = [ + "lambda_http", + "poem", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro2" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "query_map" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4465aacac3bebc9484cf7a56dc8b2d7feacb657da6002a9198b4f7af4247a204" +dependencies = [ + "form_urlencoded", + "serde", + "serde_derive", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "regex" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "697061221ea1b4a94a624f67d0ae2bfe4e22b8a17b6a192afb11046542cc8c47" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.3.8", + "regex-syntax 0.7.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2f401f4955220693b56f8ec66ee9c78abffd8d1c4f23dc41a23839eb88f0795" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.7.5", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" + +[[package]] +name = "rfc7239" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "087317b3cf7eb481f13bd9025d729324b7cd068d6f470e2d76d049e191f5ba47" +dependencies = [ + "uncased", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "rsa" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab43bb47d23c1a631b4b680199a45255dce26fa9ab2fa902581f624ff13e6a8" +dependencies = [ + "byteorder", + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-iter", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustix" +version = "0.38.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7db8590df6dfcd144d22afd1b83b36c21a18d7cbc1dc4bb5295a8712e9eb662" +dependencies = [ + "bitflags 2.4.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustls" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8" +dependencies = [ + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2" +dependencies = [ + "base64", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d93931baf2d282fff8d3a532bbfd7653f734643161b87e3e01e59a04439bf0d" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.188" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.188" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "serde_json" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc66a619ed80bf7a0f6b17dd063a84b88f6dea1813737cf469aef1d081142c2" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4beec8bce849d58d06238cb50db2e1c417cfeafa4c63f692b15c82b7c80f8335" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96426c9936fd7a0124915f9185ea1d20aa9445cc9821142f0a73bc9207a2e186" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" + +[[package]] +name = "socket2" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1e996ef02c474957d681f1b05213dfb0abab947b446a62d37770b23500184a" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7b278788e7be4d0d29c0f39497a0eef3fba6bbc8e70d8bf7fde46edeaa9e85" +dependencies = [ + "itertools", + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e58421b6bc416714d5115a2ca953718f6c621a51b68e4f4922aea5a4391a721" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd4cef4251aabbae751a3710927945901ee1d97ee96d757f6880ebb9a79bfd53" +dependencies = [ + "ahash", + "atoi", + "byteorder", + "bytes", + "crc", + "crossbeam-queue", + "dotenvy", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap 2.0.0", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "208e3165167afd7f3881b16c1ef3f2af69fa75980897aac8874a0696516d12c2" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a4a8336d278c62231d87f24e8a7a74898156e34c1c18942857be2acb29c7dfc" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca69bf415b93b60b80dc8fda3cb4ef52b2336614d8da2de5456cc942a110482" +dependencies = [ + "atoi", + "base64", + "bitflags 2.4.0", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0db2df1b8731c3651e204629dd55e52adbae0462fa1bdcbed56a2302c18181e" +dependencies = [ + "atoi", + "base64", + "bitflags 2.4.0", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4c21bf34c7cae5b283efb3ac1bcc7670df7561124dc2f8bdc0b59be40f79a2" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stringprep" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" +dependencies = [ + "finl_unicode", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239814284fd6f1a4ffe4ca893952cdd93c224b6a1571c9a9eadd670295c0c9e2" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall", + "rustix", + "windows-sys", +] + +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6d7a740b8a666a7e828dd00da9c0dc290dff53154ea77ac109281de90589b7" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49922ecae66cc8a249b77e68d1d0623c1b2c514f0060c27cdc68bd62a1219d35" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "time" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f6bb557fd245c28e6411aa56b6403c689ad95061f50e4be16c274e70a17e48" +dependencies = [ + "deranged", + "itoa", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" + +[[package]] +name = "time-macros" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a942f44339478ef67935ab2bbaec2fb0322496cf3cbe84b261e06ac3814c572" +dependencies = [ + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.5.3", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "toml" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c226a7bba6d859b63c92c4b4fe69c5b6b72d0cb897dbc8e6012298e6154cb56e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.20.0", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.0.0", + "toml_datetime", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ff63e60a958cefbb518ae1fd6566af80d9d4be430a33f3723dfc47d1d411d95" +dependencies = [ + "indexmap 2.0.0", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +dependencies = [ + "cfg-if", + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "tracing-core" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" +dependencies = [ + "lazy_static", + "log", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" + +[[package]] +name = "typenum" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" + +[[package]] +name = "uncased" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b9bc53168a4be7402ab86c3aad243a84dd7381d09be0eddc81280c1da95ca68" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + +[[package]] +name = "unicode-ident" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "uuid" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" +dependencies = [ + "getrandom", + "serde", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.32", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" + +[[package]] +name = "web-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b291546d5d9d1eab74f069c77749f2cb8504a12caa20f0f2de93ddbf6f411888" +dependencies = [ + "rustls-webpki", +] + +[[package]] +name = "whoami" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c2e3184b9c4e92ad5167ca73039d0c42476302ab603e2fec4487511f38ccefc" +dependencies = [ + "memchr", +] + +[[package]] +name = "zeroize" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] diff --git a/analytics/lambda/Cargo.toml b/analytics/lambda/Cargo.toml new file mode 100644 index 0000000..25e353d --- /dev/null +++ b/analytics/lambda/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "analytics-lambda" +version = "0.1.0" +edition = "2021" +publish = false + +[features] +local = [] + +[dependencies] +async-trait = { version = "0.1" } +env_logger = { version = "0.10" } +fernet = { version = "0.2" } +lambda_runtime = "0.8" +log = { version = "0.4" } +openssl = { version = "0.10", features = ["vendored"] } +poem = { version = "1.3" } +poem-lambda = { version = "1.3" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } +time = { version = "0.3", features = ["formatting", "serde"] } +tokio = { version = "1", features = ["full"] } +toml = { version = "0.8" } +tracing = { version = "0.1" } +tracing-subscriber = { version = "0.3", features = ["std", "env-filter", "tracing-log"] } +uuid = { version = "1.2", features = ["v4", "serde"] } + +analytics-model = { path = "../model" } + +[dependencies.sqlx] +version = "0.7" +features = [ + "migrate", + "postgres", + "runtime-tokio-rustls", + "time", + "uuid" +] diff --git a/analytics/lambda/local.sh b/analytics/lambda/local.sh new file mode 100755 index 0000000..b1bc5bd --- /dev/null +++ b/analytics/lambda/local.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +set -eo pipefail + +DB_CONTAINER_NAME="blakerain-analytics-db" +DB_CONTAINER_PORT=5101 + +# Stop the database docker container (if it is already running). +docker stop "$DB_CONTAINER_NAME" || true + +# Start the local database, passing in defaults that correspond to those in 'local.toml' +# configuration file. +docker run --rm --name "$DB_CONTAINER_NAME" -d \ + -e POSTGRES_USER=analytics_local \ + -e POSTGRES_PASSWORD=analytics_local \ + -e POSTGRES_DB=analytics_local \ + -p $DB_CONTAINER_PORT:5432 \ + postgres:alpine \ + -c log_statement=all + +# Make sure that 'cargo watch' is installed +cargo install cargo-watch + +# Runt he language function, reloading any changes. +cargo watch -B 1 -L debug -- cargo run --features local --bin analytics + diff --git a/analytics/lambda/local.toml b/analytics/lambda/local.toml new file mode 100644 index 0000000..fadc2d9 --- /dev/null +++ b/analytics/lambda/local.toml @@ -0,0 +1,10 @@ +[db] +endpoint = "localhost" +port = 5101 +username = "analytics_local" +password = "analytics_local" +dbname = "analytics_local" + +[auth] +# This is a very poor key, but it shouldn't trigger GitHub +token_key = "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=" diff --git a/analytics/lambda/src/bin/analytics.rs b/analytics/lambda/src/bin/analytics.rs new file mode 100644 index 0000000..1da82e8 --- /dev/null +++ b/analytics/lambda/src/bin/analytics.rs @@ -0,0 +1,61 @@ +use analytics_lambda::{ + config::{load_from_env, load_from_file}, + endpoints::auth::AuthContext, + env::Env, + handlers::{ + auth::{new_password, signin, validate_token}, + page_view::{append_page_view, record_page_view}, + query::query_month_view, + }, +}; +use analytics_model::MIGRATOR; +use lambda_runtime::Error; +use poem::{get, middleware, post, Endpoint, EndpointExt, Route}; + +async fn create() -> Result { + let config = if cfg!(feature = "local") { + load_from_file() + } else { + load_from_env().await + }?; + + let env = Env::new(config).await; + MIGRATOR.run(&env.pool).await?; + + Ok(Route::new() + .at("/page_view", post(record_page_view)) + .at("/page_view/:id", post(append_page_view)) + .at("/auth/sign_in", post(signin)) + .at("/auth/new_password", post(new_password)) + .at("/auth/validate", post(validate_token)) + .at("/query/month/:year/:month", get(query_month_view)) + .with(AuthContext::new(&["/auth", "/page_view"], env.clone())) + .with(middleware::Cors::new()) + .with(middleware::Tracing) + .data(env)) +} + +#[tokio::main] +async fn main() -> Result<(), Error> { + let filter_layer = tracing_subscriber::filter::EnvFilter::builder() + .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into()) + .from_env_lossy(); + + tracing_subscriber::fmt() + .with_env_filter(filter_layer) + .without_time() + .with_ansi(cfg!(feature = "local")) + .init(); + + let endpoint = create().await?; + + if cfg!(feature = "local") { + poem::Server::new(poem::listener::TcpListener::bind("127.0.0.1:3000")) + .run(endpoint) + .await?; + } else { + poem_lambda::run(endpoint).await?; + } + + Ok(()) +} diff --git a/analytics/lambda/src/bin/migrate.rs b/analytics/lambda/src/bin/migrate.rs new file mode 100644 index 0000000..6870933 --- /dev/null +++ b/analytics/lambda/src/bin/migrate.rs @@ -0,0 +1,72 @@ +use analytics_lambda::{ + config::{load_from_env, load_from_file}, + env::Env, +}; +use analytics_model::MIGRATOR; +use lambda_runtime::{run, service_fn, Error, LambdaEvent}; +use serde::Deserialize; +use sqlx::PgPool; + +async fn destroy(pool: &PgPool) -> sqlx::Result<()> { + sqlx::query("DROP SCHEMA public CASCADE") + .execute(pool) + .await?; + + sqlx::query("CREATE SCHEMA public").execute(pool).await?; + + sqlx::query("GRANT ALL ON SCHEMA public TO analytics") + .execute(pool) + .await?; + + sqlx::query("GRANT ALL ON SCHEMA public TO public") + .execute(pool) + .await?; + + Ok(()) +} + +#[derive(Deserialize)] +struct Options { + #[serde(default)] + destroy: bool, +} + +#[tokio::main] +async fn main() -> Result<(), Error> { + let filter_layer = tracing_subscriber::filter::EnvFilter::builder() + .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into()) + .from_env_lossy(); + + tracing_subscriber::fmt() + .with_env_filter(filter_layer) + .without_time() + .with_ansi(cfg!(feature = "local")) + .init(); + + run(service_fn( + move |event: LambdaEvent| async move { + let options: Options = serde_json::from_value(event.payload).expect("options"); + + let config = if cfg!(feature = "local") { + load_from_file() + } else { + load_from_env().await + }?; + + let pool = Env::create_pool(&config).await; + + if options.destroy { + log::info!("Destroying database"); + destroy(&pool).await?; + } + + log::info!("Running migrations"); + MIGRATOR.run(&pool).await?; + + Ok::<(), Error>(()) + }, + )) + .await?; + + Ok(()) +} diff --git a/analytics/lambda/src/config.rs b/analytics/lambda/src/config.rs new file mode 100644 index 0000000..3a66ca2 --- /dev/null +++ b/analytics/lambda/src/config.rs @@ -0,0 +1,61 @@ +use std::io::Read; + +use fernet::Fernet; +use lambda_runtime::Error; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub db: DbConfig, + pub auth: AuthConfig, +} + +#[derive(Debug, Deserialize)] +pub struct DbConfig { + pub endpoint: String, + pub port: Option, + pub username: String, + pub password: String, + pub dbname: String, +} + +#[derive(Debug, Deserialize)] +pub struct AuthConfig { + pub token_key: String, +} + +pub fn load_from_file() -> Result { + log::info!("Loading configuration from 'local.toml'"); + let path = std::env::current_dir()?.join("local.toml"); + if !path.is_file() { + log::error!("Local configuration file 'local.toml' not found"); + return Err("Missing configuration file".into()); + } + + let mut file = std::fs::File::open(path)?; + let mut content = String::new(); + file.read_to_string(&mut content)?; + let config = toml::from_str(&content)?; + Ok(config) +} + +pub async fn load_from_env() -> Result { + let endpoint = std::env::var("DATABASE_ENDPOINT")?; + let password = std::env::var("DATABASE_PASSWORD")?; + let token_key = std::env::var("TOKEN_KEY").unwrap_or_else(|_| { + log::info!("Unable to find TOKEN_KEY environment variable; falling back to generated key"); + Fernet::generate_key() + }); + + let db = DbConfig { + endpoint, + port: None, + username: "analytics".to_string(), + password, + dbname: "analytics".to_string(), + }; + + let auth = AuthConfig { token_key }; + + Ok(Config { db, auth }) +} diff --git a/analytics/lambda/src/endpoints/auth.rs b/analytics/lambda/src/endpoints/auth.rs new file mode 100644 index 0000000..f28c6da --- /dev/null +++ b/analytics/lambda/src/endpoints/auth.rs @@ -0,0 +1,118 @@ +use analytics_model::user::User; +use async_trait::async_trait; +use fernet::Fernet; +use poem::{ + error::InternalServerError, + http::StatusCode, + web::headers::{self, authorization::Bearer, HeaderMapExt}, + Endpoint, Middleware, Request, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::env::Env; + +pub struct AuthContext { + skip_prefixes: Vec, + env: Env, +} + +impl AuthContext { + pub fn new(skip_prefixes: &[&str], env: Env) -> Self { + Self { + skip_prefixes: skip_prefixes.iter().map(ToString::to_string).collect(), + env, + } + } +} + +impl Middleware for AuthContext { + type Output = AuthEndpoint; + + fn transform(&self, ep: E) -> Self::Output { + AuthEndpoint::new(self.skip_prefixes.clone(), self.env.clone(), ep) + } +} + +pub struct AuthEndpoint { + skip_prefixes: Vec, + env: Env, + endpoint: E, +} + +impl AuthEndpoint { + fn new(skip_prefixes: Vec, env: Env, endpoint: E) -> Self { + Self { + skip_prefixes, + env, + endpoint, + } + } +} + +#[async_trait] +impl Endpoint for AuthEndpoint { + type Output = E::Output; + + async fn call(&self, mut request: Request) -> poem::Result { + for skip_prefix in &self.skip_prefixes { + if request.uri().path().starts_with(skip_prefix) { + return self.endpoint.call(request).await; + } + } + + // Make sure that we have an 'Authorization' header that has a 'Bearer' token. + let Some(auth) = request.headers().typed_get::>() else { + log::info!("Missing 'Authorization' header with 'Bearer' token"); + return Err(poem::Error::from_status(StatusCode::UNAUTHORIZED)); + }; + + // Ensure that we can decrypt the token using the provided Fernet key. + let Token { user_id } = match Token::decode(&self.env.fernet, auth.token()) { + Some(token) => token, + None => { + log::error!("Failed to decode authentication token"); + return Err(poem::Error::from_status(StatusCode::UNAUTHORIZED)); + } + }; + + // If the user no longer exists, then a simple 401 will suffice. + let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(user_id).fetch_optional(&self.env.pool).await.map_err(InternalServerError)? else { + log::error!("User '{user_id}' no longer exists"); + return Err(poem::Error::from_status(StatusCode::UNAUTHORIZED)); + }; + + // Make sure that the user is still enabled. + if !user.enabled { + log::error!("User '{user_id}' is not enabled"); + return Err(poem::Error::from_status(StatusCode::FORBIDDEN)); + } + + // Store the authenticated user in the request for retrieval by handlers. + request.set_data(user); + + self.endpoint.call(request).await + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Token { + pub user_id: Uuid, +} + +impl Token { + pub fn new(user_id: Uuid) -> Self { + Self { user_id } + } + + pub fn encode(&self, fernet: &Fernet) -> String { + let plain = serde_json::to_string(self).expect("Unable to JSON encode token"); + fernet.encrypt(plain.as_bytes()) + } + + pub fn decode(fernet: &Fernet, encoded: &str) -> Option { + let plain = fernet.decrypt(encoded).ok()?; + serde_json::from_slice(&plain).ok() + } +} diff --git a/analytics/lambda/src/env.rs b/analytics/lambda/src/env.rs new file mode 100644 index 0000000..7364f2a --- /dev/null +++ b/analytics/lambda/src/env.rs @@ -0,0 +1,65 @@ +use std::ops::Deref; +use std::sync::Arc; +use std::time::Duration; + +use fernet::Fernet; +use log::LevelFilter; +use sqlx::postgres::PgConnectOptions; +use sqlx::ConnectOptions; + +use crate::config::Config; + +pub struct Env { + inner: Arc, +} + +impl Clone for Env { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl Deref for Env { + type Target = Inner; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +pub struct Inner { + pub pool: sqlx::PgPool, + pub fernet: Fernet, +} + +impl Env { + pub async fn create_pool(config: &Config) -> sqlx::PgPool { + let mut connection_opts = PgConnectOptions::new() + .host(&config.db.endpoint) + .username(&config.db.username) + .password(&config.db.password) + .database(&config.db.dbname) + .log_statements(LevelFilter::Debug) + .log_slow_statements(LevelFilter::Warn, Duration::from_secs(1)); + + if let Some(port) = config.db.port { + connection_opts = connection_opts.port(port); + } + + sqlx::PgPool::connect_with(connection_opts).await.unwrap() + } + + pub async fn new(config: Config) -> Self { + let pool = Self::create_pool(&config).await; + let inner = Inner { + pool, + fernet: Fernet::new(&config.auth.token_key).expect("valid fernet key"), + }; + + Self { + inner: Arc::new(inner), + } + } +} diff --git a/analytics/lambda/src/handlers.rs b/analytics/lambda/src/handlers.rs new file mode 100644 index 0000000..98c3ba3 --- /dev/null +++ b/analytics/lambda/src/handlers.rs @@ -0,0 +1,3 @@ +pub mod auth; +pub mod page_view; +pub mod query; diff --git a/analytics/lambda/src/handlers/auth.rs b/analytics/lambda/src/handlers/auth.rs new file mode 100644 index 0000000..cde2902 --- /dev/null +++ b/analytics/lambda/src/handlers/auth.rs @@ -0,0 +1,111 @@ +use analytics_model::user::{authenticate, reset_password, User}; +use poem::{ + error::InternalServerError, + handler, + web::{Data, Json}, +}; +use serde::{Deserialize, Serialize}; + +use crate::{endpoints::auth::Token, env::Env}; + +#[derive(Deserialize)] +pub struct SignInBody { + username: String, + password: String, +} + +#[derive(Serialize)] +#[serde(tag = "type")] +pub enum SignInResponse { + InvalidCredentials, + NewPassword, + Successful { token: String }, +} + +#[derive(Deserialize)] +pub struct NewPasswordBody { + username: String, + #[serde(rename = "oldPassword")] + old_password: String, + #[serde(rename = "newPassword")] + new_password: String, +} + +#[derive(Deserialize)] +pub struct ValidateTokenBody { + token: String, +} + +#[derive(Serialize)] +#[serde(tag = "type")] +pub enum ValidateTokenResponse { + Invalid, + Valid { token: String }, +} + +#[handler] +pub async fn signin( + env: Data<&Env>, + Json(SignInBody { username, password }): Json, +) -> poem::Result> { + let Some(user) = authenticate(&env.pool, &username, &password).await.map_err(InternalServerError)? else { + return Ok(Json(SignInResponse::InvalidCredentials)); + }; + + if user.reset_password { + return Ok(Json(SignInResponse::NewPassword)); + } + + let token = Token::new(user.id); + let token = token.encode(&env.fernet); + Ok(Json(SignInResponse::Successful { token })) +} + +#[handler] +pub async fn new_password( + env: Data<&Env>, + Json(NewPasswordBody { + username, + old_password, + new_password, + }): Json, +) -> poem::Result> { + let Some(user) = authenticate(&env.pool, &username, &old_password).await.map_err(InternalServerError)? else { + return Ok(Json(SignInResponse::InvalidCredentials)); + }; + + let Some(user) = reset_password(&env.pool, user.id, new_password).await.map_err(InternalServerError)? else { + return Ok(Json(SignInResponse::InvalidCredentials)); + }; + + let token = Token::new(user.id); + let token = token.encode(&env.fernet); + Ok(Json(SignInResponse::Successful { token })) +} + +#[handler] +pub async fn validate_token( + env: Data<&Env>, + Json(ValidateTokenBody { token }): Json, +) -> poem::Result> { + let Some(Token { user_id }) = Token::decode(&env.fernet, &token) else { + log::error!("Failed to decode authentication token"); + return Ok(Json(ValidateTokenResponse::Invalid)); + }; + + let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(user_id).fetch_optional(&env.pool).await.map_err(InternalServerError)? else { + log::error!("User '{user_id}' no longer exists"); + return Ok(Json(ValidateTokenResponse::Invalid)); + }; + + if !user.enabled { + log::error!("User '{user_id}' is not enabled"); + return Ok(Json(ValidateTokenResponse::Invalid)); + } + + let token = Token::new(user.id); + let token = token.encode(&env.fernet); + + Ok(Json(ValidateTokenResponse::Valid { token })) +} diff --git a/analytics/lambda/src/handlers/page_view.rs b/analytics/lambda/src/handlers/page_view.rs new file mode 100644 index 0000000..cdcb6a4 --- /dev/null +++ b/analytics/lambda/src/handlers/page_view.rs @@ -0,0 +1,92 @@ +use analytics_model::view::{self, create_page_view, PageView}; +use poem::{ + handler, + web::{Data, Json, Path}, +}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::env::Env; + +#[derive(Deserialize)] +pub struct PageViewBody { + path: Option, + ua: Option, + vw: Option, + vh: Option, + sw: Option, + sh: Option, + tz: Option, + rf: Option, +} + +#[derive(Serialize)] +pub struct PageViewResponse { + id: Option, +} + +#[handler] +pub async fn record_page_view( + env: Data<&Env>, + Json(PageViewBody { + path, + ua, + vw, + vh, + sw, + sh, + tz, + rf, + }): Json, +) -> poem::Result> { + let id = if let Some(path) = path { + let id = Uuid::new_v4(); + let view = PageView { + id, + path, + time: OffsetDateTime::now_utc(), + user_agent: ua, + viewport_width: vw, + viewport_height: vh, + screen_width: sw, + screen_height: sh, + timezone: tz, + referrer: rf, + beacon: false, + duration: None, + scroll: None, + }; + + if let Err(err) = create_page_view(&env.pool, view).await { + log::error!("Failed to record page view: {err:?}"); + None + } else { + Some(id) + } + } else { + log::info!("Ignoring request for pageview image with no path"); + None + }; + + Ok(Json(PageViewResponse { id })) +} + +#[derive(Deserialize)] +pub struct AppendPageViewBody { + duration: f64, + scroll: f64, +} + +#[handler] +pub async fn append_page_view( + env: Data<&Env>, + Path(id): Path, + Json(AppendPageViewBody { duration, scroll }): Json, +) -> poem::Result> { + if let Err(err) = view::append_page_view(&env.pool, id, duration, scroll).await { + log::error!("Failed to append page view: {err:?}"); + } + + Ok(Json(())) +} diff --git a/analytics/lambda/src/handlers/query.rs b/analytics/lambda/src/handlers/query.rs new file mode 100644 index 0000000..3c9b54f --- /dev/null +++ b/analytics/lambda/src/handlers/query.rs @@ -0,0 +1,70 @@ +use analytics_model::{user::User, view::PageViewsMonth}; +use poem::{ + error::InternalServerError, + handler, + web::{Data, Json, Path}, +}; +use serde::Serialize; + +use crate::env::Env; + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct PageViewsPathCount { + pub path: String, + pub count: i64, + pub beacons: i64, + pub avg_duration: f64, + pub avg_scroll: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PageViewsMonthResult { + pub site: PageViewsPathCount, + pub views: Vec, + pub paths: Vec, +} + +#[handler] +pub async fn query_month_view( + env: Data<&Env>, + _: Data<&User>, + Path((year, month)): Path<(i32, i32)>, +) -> poem::Result> { + let views = sqlx::query_as::<_, PageViewsMonth>( + "SELECT * FROM page_views_month WHERE path = $1 AND year = $2 AND month = $3 ORDER BY day", + ) + .bind("") + .bind(year) + .bind(month) + .fetch_all(&env.pool) + .await + .map_err(InternalServerError)?; + + let mut paths = sqlx::query_as::<_, PageViewsPathCount>( + "SELECT path, + SUM(count) AS count, + SUM(total_beacon) AS beacons, + SUM(total_duration) / SUM(total_beacon) AS avg_duration, + SUM(total_scroll) / SUM(total_beacon) AS avg_scroll + FROM page_views_month WHERE year = $1 AND month = $2 GROUP BY path", + ) + .bind(year) + .bind(month) + .fetch_all(&env.pool) + .await + .map_err(InternalServerError)?; + + let site = if let Some(index) = paths.iter().position(|count| count.path.is_empty()) { + paths.swap_remove(index) + } else { + PageViewsPathCount { + path: String::new(), + count: 0, + beacons: 0, + avg_duration: 0.0, + avg_scroll: 0.0, + } + }; + + Ok(Json(PageViewsMonthResult { site, views, paths })) +} diff --git a/analytics/lambda/src/lib.rs b/analytics/lambda/src/lib.rs new file mode 100644 index 0000000..838b5d3 --- /dev/null +++ b/analytics/lambda/src/lib.rs @@ -0,0 +1,7 @@ +pub mod config; +pub mod env; +pub mod handlers; + +pub mod endpoints { + pub mod auth; +} diff --git a/analytics/model/.gitignore b/analytics/model/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/analytics/model/.gitignore @@ -0,0 +1 @@ +/target diff --git a/analytics/model/Cargo.lock b/analytics/model/Cargo.lock new file mode 100644 index 0000000..ece6a41 --- /dev/null +++ b/analytics/model/Cargo.lock @@ -0,0 +1,1761 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + +[[package]] +name = "analytics-model" +version = "0.1.0" +dependencies = [ + "log", + "pbkdf2", + "rand_core", + "serde", + "sqlx", + "time", + "uuid", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "const-oid" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" + +[[package]] +name = "cpufeatures" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484" + +[[package]] +name = "crossbeam-queue" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946" +dependencies = [ + "serde", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136526188508e25c6fef639d7927dfb3e0e3084488bf202267829cf7fc23dbdd" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fastrand" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" + +[[package]] +name = "finl_unicode" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" + +[[package]] +name = "flume" +version = "0.10.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1657b4441c3403d9f7b3409e47575237dac27b1b5726df654a6ecbf92f0f7577" +dependencies = [ + "futures-core", + "futures-sink", + "pin-project", + "spin 0.9.8", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" + +[[package]] +name = "futures-executor" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" + +[[package]] +name = "futures-sink" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" + +[[package]] +name = "futures-task" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" + +[[package]] +name = "futures-util" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" + +[[package]] +name = "hashbrown" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" + +[[package]] +name = "js-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] + +[[package]] +name = "libc" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "libm" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" + +[[package]] +name = "libsqlite3-sys" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128" + +[[package]] +name = "lock_api" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "md-5" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" +dependencies = [ + "digest", +] + +[[package]] +name = "memchr" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" + +[[package]] +name = "pin-project" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro2" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "rsa" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab43bb47d23c1a631b4b680199a45255dce26fa9ab2fa902581f624ff13e6a8" +dependencies = [ + "byteorder", + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-iter", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustix" +version = "0.38.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7db8590df6dfcd144d22afd1b83b36c21a18d7cbc1dc4bb5295a8712e9eb662" +dependencies = [ + "bitflags 2.4.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustls" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8" +dependencies = [ + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2" +dependencies = [ + "base64", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d93931baf2d282fff8d3a532bbfd7653f734643161b87e3e01e59a04439bf0d" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.188" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.188" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "serde_json" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc66a619ed80bf7a0f6b17dd063a84b88f6dea1813737cf469aef1d081142c2" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" + +[[package]] +name = "socket2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1e996ef02c474957d681f1b05213dfb0abab947b446a62d37770b23500184a" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7b278788e7be4d0d29c0f39497a0eef3fba6bbc8e70d8bf7fde46edeaa9e85" +dependencies = [ + "itertools", + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e58421b6bc416714d5115a2ca953718f6c621a51b68e4f4922aea5a4391a721" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd4cef4251aabbae751a3710927945901ee1d97ee96d757f6880ebb9a79bfd53" +dependencies = [ + "ahash", + "atoi", + "byteorder", + "bytes", + "crc", + "crossbeam-queue", + "dotenvy", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "208e3165167afd7f3881b16c1ef3f2af69fa75980897aac8874a0696516d12c2" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a4a8336d278c62231d87f24e8a7a74898156e34c1c18942857be2acb29c7dfc" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca69bf415b93b60b80dc8fda3cb4ef52b2336614d8da2de5456cc942a110482" +dependencies = [ + "atoi", + "base64", + "bitflags 2.4.0", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0db2df1b8731c3651e204629dd55e52adbae0462fa1bdcbed56a2302c18181e" +dependencies = [ + "atoi", + "base64", + "bitflags 2.4.0", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4c21bf34c7cae5b283efb3ac1bcc7670df7561124dc2f8bdc0b59be40f79a2" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stringprep" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" +dependencies = [ + "finl_unicode", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239814284fd6f1a4ffe4ca893952cdd93c224b6a1571c9a9eadd670295c0c9e2" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6d7a740b8a666a7e828dd00da9c0dc290dff53154ea77ac109281de90589b7" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49922ecae66cc8a249b77e68d1d0623c1b2c514f0060c27cdc68bd62a1219d35" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "time" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f6bb557fd245c28e6411aa56b6403c689ad95061f50e4be16c274e70a17e48" +dependencies = [ + "deranged", + "itoa", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" + +[[package]] +name = "time-macros" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a942f44339478ef67935ab2bbaec2fb0322496cf3cbe84b261e06ac3814c572" +dependencies = [ + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys", +] + +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +dependencies = [ + "cfg-if", + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + +[[package]] +name = "tracing-core" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" + +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + +[[package]] +name = "unicode-ident" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "uuid" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" +dependencies = [ + "getrandom", + "serde", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.32", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" + +[[package]] +name = "web-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b291546d5d9d1eab74f069c77749f2cb8504a12caa20f0f2de93ddbf6f411888" +dependencies = [ + "rustls-webpki", +] + +[[package]] +name = "whoami" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "zeroize" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" diff --git a/analytics/model/Cargo.toml b/analytics/model/Cargo.toml new file mode 100644 index 0000000..c6c3e21 --- /dev/null +++ b/analytics/model/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "analytics-model" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +log = { version = "0.4" } +pbkdf2 = { version = "0.12", features = ["simple"] } +rand_core = { version = "0.6", features = ["std"] } +serde = { version = "1.0", features = ["derive"] } +time = { version = "0.3", features = ["formatting", "serde"] } +uuid = { version = "1.2", features = ["v4", "serde"] } + +[dependencies.sqlx] +version = "0.7" +features = [ + "migrate", + "postgres", + "runtime-tokio-rustls", + "time", + "uuid" +] diff --git a/analytics/model/migrations/001_views.sql b/analytics/model/migrations/001_views.sql new file mode 100644 index 0000000..ddc9f47 --- /dev/null +++ b/analytics/model/migrations/001_views.sql @@ -0,0 +1,61 @@ +CREATE TABLE IF NOT EXISTS page_views ( + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + path TEXT NOT NULL, + time TIMESTAMP WITH TIME ZONE NOT NULL, + user_agent TEXT, + viewport_width INTEGER, + viewport_height INTEGER, + screen_width INTEGER, + screen_height INTEGER, + timezone TEXT, + referrer TEXT, + beacon BOOLEAN NOT NULL, + duration FLOAT8, + scroll FLOAT8 +); + +CREATE TABLE IF NOT EXISTS page_views_day ( + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + path TEXT NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + day INTEGER NOT NULL, + hour INTEGER NOT NULL, + count INTEGER NOT NULL, + total_beacon INTEGER NOT NULL, + total_scroll FLOAT8 NOT NULL, + total_duration FLOAT8 NOT NULL, + + CONSTRAINT unique_page_views_day + UNIQUE (path, year, month, day, hour) +); + +CREATE TABLE IF NOT EXISTS page_views_week ( + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + path TEXT NOT NULL, + year INTEGER NOT NULL, + week INTEGER NOT NULL, + dow INTEGER NOT NULL, + count INTEGER NOT NULL, + total_beacon INTEGER NOT NULL, + total_scroll FLOAT8 NOT NULL, + total_duration FLOAT8 NOT NULL, + + CONSTRAINT unique_page_views_week + UNIQUE (path, year, week, dow) +); + +CREATE TABLE IF NOT EXISTS page_views_month ( + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + path TEXT NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + day INTEGER NOT NULL, + count INTEGER NOT NULL, + total_beacon INTEGER NOT NULL, + total_scroll FLOAT8 NOT NULL, + total_duration FLOAT8 NOT NULL, + + CONSTRAINT unique_page_views_month + UNIQUE (path, year, month, day) +); diff --git a/analytics/model/migrations/002_users.sql b/analytics/model/migrations/002_users.sql new file mode 100644 index 0000000..c1cee3d --- /dev/null +++ b/analytics/model/migrations/002_users.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS users ( + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL, + password TEXT NOT NULL, + enabled BOOLEAN NOT NULL, + reset_password BOOLEAN NOT NULL, + + CONSTRAINT unique_username + UNIQUE (username) +); + +-- Create an intial user that has a temporary password. The password is: admin +INSERT INTO users (username, password, enabled, reset_password) + VALUES('admin', '$pbkdf2-sha256$i=600000,l=32$V62SYtsc1HWC2hV3jbevjg$OrOHoTwo1YPmNrPUnAUy3Vfg4Lrw90mxOTTISVHmjnk', TRUE, TRUE); diff --git a/analytics/model/src/lib.rs b/analytics/model/src/lib.rs new file mode 100644 index 0000000..74f0aa8 --- /dev/null +++ b/analytics/model/src/lib.rs @@ -0,0 +1,4 @@ +pub mod user; +pub mod view; + +pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); diff --git a/analytics/model/src/user.rs b/analytics/model/src/user.rs new file mode 100644 index 0000000..566275b --- /dev/null +++ b/analytics/model/src/user.rs @@ -0,0 +1,72 @@ +use pbkdf2::{ + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Pbkdf2, +}; +use rand_core::OsRng; +use serde::Serialize; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct User { + pub id: Uuid, + pub username: String, + #[serde(skip)] + pub password: String, + pub enabled: bool, + pub reset_password: bool, +} + +pub async fn authenticate( + pool: &PgPool, + username: &str, + password: &str, +) -> sqlx::Result> { + let user: User = if let Some(user) = sqlx::query_as("SELECT * FROM users WHERE username = $1") + .bind(username) + .fetch_optional(pool) + .await? + { + user + } else { + log::warn!("User not found with username '{username}'"); + return Ok(None); + }; + + let parsed_hash = PasswordHash::new(&user.password).expect("valid password hash"); + if let Err(err) = Pbkdf2.verify_password(password.as_bytes(), &parsed_hash) { + log::error!( + "Incorrect password for user '{username}' ('{}'): {err:?}", + user.id + ); + + return Ok(None); + } + + if !user.enabled { + log::error!("User '{username}' ('{}') is disabled", user.id); + return Ok(None); + } + + Ok(Some(user)) +} + +pub async fn reset_password( + pool: &PgPool, + id: Uuid, + new_password: String, +) -> sqlx::Result> { + let salt = SaltString::generate(&mut OsRng); + let password = Pbkdf2 + .hash_password(new_password.as_bytes(), &salt) + .expect("valid password hash") + .to_string(); + + sqlx::query_as( + "UPDATE users SET password = $1, reset_password = FALSE WHERE id = $2 RETURNING *", + ) + .bind(password) + .bind(id) + .fetch_optional(pool) + .await +} diff --git a/analytics/model/src/view.rs b/analytics/model/src/view.rs new file mode 100644 index 0000000..1d73586 --- /dev/null +++ b/analytics/model/src/view.rs @@ -0,0 +1,331 @@ +use serde::Serialize; +use sqlx::PgPool; +use time::OffsetDateTime; +use uuid::Uuid; + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct PageView { + pub id: Uuid, + pub path: String, + pub time: OffsetDateTime, + pub user_agent: Option, + pub viewport_width: Option, + pub viewport_height: Option, + pub screen_width: Option, + pub screen_height: Option, + pub timezone: Option, + pub referrer: Option, + pub beacon: bool, + pub duration: Option, + pub scroll: Option, +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct PageViewsDay { + pub id: Uuid, + pub path: String, + pub year: i32, + pub month: i32, + pub day: i32, + pub hour: i32, + pub count: i32, + pub total_beacon: i32, + pub total_scroll: f64, + pub total_duration: f64, +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct PageViewsWeek { + pub id: Uuid, + pub path: String, + pub year: i32, + pub week: i32, + pub dow: i32, + pub count: i32, + pub total_beacon: i32, + pub total_scroll: f64, + pub total_duration: f64, +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct PageViewsMonth { + pub id: Uuid, + pub path: String, + pub year: i32, + pub month: i32, + pub day: i32, + pub count: i32, + pub total_beacon: i32, + pub total_scroll: f64, + pub total_duration: f64, +} + +pub async fn create_page_view(pool: &PgPool, view: PageView) -> sqlx::Result<()> { + sqlx::query( + "INSERT INTO page_views + (id, path, time, user_agent, + viewport_width, viewport_height, + screen_width, screen_height, + timezone, referrer, + beacon, duration, scroll) + VALUES ($1, $2, $3, $4, + $5, $6, + $7, $8, + $9, $10, + $11, $12, $13)", + ) + .bind(view.id) + .bind(&view.path) + .bind(view.time) + .bind(view.user_agent) + .bind(view.viewport_width) + .bind(view.viewport_height) + .bind(view.screen_width) + .bind(view.screen_height) + .bind(view.timezone) + .bind(view.referrer) + .bind(view.beacon) + .bind(view.duration) + .bind(view.scroll) + .execute(pool) + .await?; + + update_count_accumulators(pool, &view.path, view.time).await?; + update_count_accumulators(pool, "", view.time).await?; + + Ok(()) +} + +async fn update_count_accumulators( + pool: &PgPool, + path: &str, + time: OffsetDateTime, +) -> sqlx::Result<()> { + sqlx::query( + " + INSERT INTO page_views_day + (path, year, month, day, hour, count, total_beacon, total_scroll, total_duration) + VALUES + ($1, $2, $3, $4, $5, 1, 0, 0, 0) + ON CONFLICT (path, year, month, day, hour) + DO UPDATE SET + count = page_views_day.count + 1 + ", + ) + .bind(path) + .bind(time.year()) + .bind(time.month() as i32) + .bind(time.day() as i32) + .bind(time.hour() as i32) + .execute(pool) + .await?; + + sqlx::query( + " + INSERT INTO page_views_week + (path, year, week, dow, count, total_beacon, total_scroll, total_duration) + VALUES + ($1, $2, $3, $4, 1, 0, 0, 0) + ON CONFLICT (path, year, week, dow) + DO UPDATE SET + count = page_views_week.count + 1 + ", + ) + .bind(path) + .bind(time.year()) + .bind(time.iso_week() as i32) + .bind(time.weekday().number_days_from_sunday() as i32) + .execute(pool) + .await?; + + sqlx::query( + " + INSERT INTO page_views_month + (path, year, month, day, count, total_beacon, total_scroll, total_duration) + VALUES + ($1, $2, $3, $4, 1, 0, 0, 0) + ON CONFLICT (path, year, month, day) + DO UPDATE SET + count = page_views_month.count + 1 + ", + ) + .bind(path) + .bind(time.year()) + .bind(time.month() as i32) + .bind(time.day() as i32) + .execute(pool) + .await?; + + Ok(()) +} + +struct Accumulators { + duration: f64, + scroll: f64, + count_delta: i32, + duration_delta: f64, + scroll_delta: f64, +} + +async fn update_beacon_accumulators( + pool: &PgPool, + path: &str, + time: OffsetDateTime, + Accumulators { + duration, + scroll, + count_delta, + duration_delta, + scroll_delta, + }: Accumulators, +) -> sqlx::Result<()> { + sqlx::query( + " + INSERT INTO page_views_day + (path, year, month, day, hour, count, total_beacon, total_scroll, total_duration) + VALUES + ($1, $2, $3, $4, $5, 1, 1, $6, $7) + ON CONFLICT (path, year, month, day, hour) + DO UPDATE SET + total_beacon = page_views_day.total_beacon + $8, + total_scroll = page_views_day.total_scroll + $9, + total_duration = page_views_day.total_duration + $10 + ", + ) + .bind(path) + .bind(time.year()) + .bind(time.month() as i32) + .bind(time.day() as i32) + .bind(time.hour() as i32) + .bind(scroll) + .bind(duration) + .bind(count_delta) + .bind(scroll_delta) + .bind(duration_delta) + .execute(pool) + .await?; + + sqlx::query( + " + INSERT INTO page_views_week + (path, year, week, dow, count, total_beacon, total_scroll, total_duration) + VALUES + ($1, $2, $3, $4, 1, 1, $5, $6) + ON CONFLICT (path, year, week, dow) + DO UPDATE SET + total_beacon = page_views_week.total_beacon + $7, + total_scroll = page_views_week.total_scroll + $8, + total_duration = page_views_week.total_duration + $9 + ", + ) + .bind(path) + .bind(time.year()) + .bind(time.iso_week() as i32) + .bind(time.weekday().number_days_from_sunday() as i32) + .bind(scroll) + .bind(duration) + .bind(count_delta) + .bind(scroll_delta) + .bind(duration_delta) + .execute(pool) + .await?; + + sqlx::query( + " + INSERT INTO page_views_month + (path, year, month, day, count, total_beacon, total_scroll, total_duration) + VALUES + ($1, $2, $3, $4, 1, 1, $5, $6) + ON CONFLICT (path, year, month, day) + DO UPDATE SET + total_beacon = page_views_month.total_beacon + $7, + total_scroll = page_views_month.total_scroll + $8, + total_duration = page_views_month.total_duration + $9 + ", + ) + .bind(path) + .bind(time.year()) + .bind(time.month() as i32) + .bind(time.day() as i32) + .bind(scroll) + .bind(duration) + .bind(count_delta) + .bind(scroll_delta) + .bind(duration_delta) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn append_page_view( + pool: &PgPool, + uuid: Uuid, + duration: f64, + scroll: f64, +) -> sqlx::Result<()> { + let view = match sqlx::query_as::<_, PageView>("SELECT * FROM page_views WHERE id = $1") + .bind(uuid) + .fetch_optional(pool) + .await? + { + Some(view) => view, + None => { + log::warn!("Ignoring append for page view '{uuid}' which does not exist"); + return Ok(()); + } + }; + + // If the beacon has already been received, we want to subtract the last recorded duration and + // scroll distance from our totals before we then add the new duration and scroll distance. + let (count_delta, duration_delta, scroll_delta) = if view.beacon { + ( + 0, + duration - view.duration.unwrap_or(0.0), + scroll - view.scroll.unwrap_or(0.0), + ) + } else { + (1, duration, scroll) + }; + + // Update the page view record with the received duration and scroll distance, and set the + // beacon flag so we know we've recorded this beacon data into our accumulators. + sqlx::query("UPDATE page_views SET duration = $1, scroll = $2, beacon = $3 WHERE id = $4") + .bind(duration) + .bind(scroll) + .bind(true) + .bind(uuid) + .execute(pool) + .await?; + + // Update the accumulated statistics for the page view path, and the site overall. + + update_beacon_accumulators( + pool, + &view.path, + view.time, + Accumulators { + duration, + scroll, + count_delta, + duration_delta, + scroll_delta, + }, + ) + .await?; + update_beacon_accumulators( + pool, + "", + view.time, + Accumulators { + duration, + scroll, + count_delta, + duration_delta, + scroll_delta, + }, + ) + .await?; + + Ok(()) +} diff --git a/cf/analytics.yaml b/cf/analytics.yaml new file mode 100644 index 0000000..207a25b --- /dev/null +++ b/cf/analytics.yaml @@ -0,0 +1,312 @@ +# +# analytics.yaml +# +# CloudFormation template for site analytics resources. +# + +Description: Site analytics + +Parameters: + DomainName: + Type: String + Description: The domain name to use + Default: blakerain.com + + HostedZoneId: + Type: String + Description: The hosted zone for the domain + Default: Z2C0W1IB1QO9DO + +Outputs: + AnalyticsLambdaDeployerAccessKeyId: + Value: !Ref AnalyticsLambdaDeployerAccessKey + + AnalyticsLambdaDeployerSecretAccessKey: + Value: !GetAtt AnalyticsLambdaDeployerAccessKey.SecretAccessKey + +Resources: + AnalyticsVpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/16 + EnableDnsHostnames: true + EnableDnsSupport: true + + AnalyticsSubnet1: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref AnalyticsVpc + AvailabilityZone: eu-west-1a + CidrBlock: 10.0.4.0/24 + + AnalyticsSubnet2: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref AnalyticsVpc + AvailabilityZone: eu-west-1b + CidrBlock: 10.0.5.0/24 + + AnalyticsLambdaSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + VpcId: !Ref AnalyticsVpc + GroupDescription: Lambda security group + + AnalyticsDatabaseSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + VpcId: !Ref AnalyticsVpc + GroupDescription: Database security group + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: "5432" + ToPort: "5432" + SourceSecurityGroupId: !Ref AnalyticsLambdaSecurityGroup + Description: Allow inbound PostgreSQL traffic from Lambda functions + + AnalyticsDatabaseSubnetGroup: + Type: AWS::RDS::DBSubnetGroup + Properties: + DBSubnetGroupName: analytics_dbsubnet_group + DBSubnetGroupDescription: Analytics database subnet group + SubnetIds: + - !Ref AnalyticsSubnet1 + - !Ref AnalyticsSubnet2 + + AnalyticsDatabase: + Type: AWS::RDS::DBInstance + Properties: + AllocatedStorage: "20" + AutoMinorVersionUpgrade: true + AvailabilityZone: eu-west-1a + BackupRetentionPeriod: 7 + DBInstanceClass: db.t4g.micro + DBName: analytics + DBSubnetGroupName: !Ref AnalyticsDatabaseSubnetGroup + Engine: postgres + MasterUsername: analytics + MasterUserPassword: "{{resolve:ssm:analytics_database_password}}" + MaxAllocatedStorage: 250 + MultiAZ: false + Port: "5432" + PreferredBackupWindow: "03:00-04:00" + PreferredMaintenanceWindow: "Sun:00:00-Sun:02:00" + PubliclyAccessible: false + VPCSecurityGroups: + - !Ref AnalyticsDatabaseSecurityGroup + + AnalyticsLambdaLogGroup: + Type: AWS::Logs::LogGroup + Properties: + RetentionInDays: 365 + LogGroupName: + Fn::Join: + - "/" + - - "" + - aws + - lambda + - !Ref AnalyticsLambda + + AnalyticsLambdaLoggingPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: analytics_lambda_logging_policy + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - "logs:CreateLogStream" + - "logs:PutLogEvents" + Resource: !GetAtt AnalyticsLambdaLogGroup.Arn + Roles: + - !Ref AnalyticsLambdaRole + + AnalyticsLambdaPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: analytics_lambda_policy + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - "ec2:CreateNetworkInterface" + - "ec2:DeleteNetworkInterface" + - "ec2:DescribeNetworkInterfaces" + - "ec2:AssignPrivateIpAddresses" + - "ec2:UnassignPrivateIpAddresses" + Resource: "*" + - Effect: Allow + Action: + - "logs:CreateLogGroup" + Resource: + Fn::Sub: "arn:aws:logs:${AWS::Region}:${AWS::AccountId}::*" + Roles: + - !Ref AnalyticsLambdaRole + + AnalyticsLambdaRole: + Type: AWS::IAM::Role + Properties: + RoleName: analytics_lambda_role + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: "sts:AssumeRole" + + AnalyticsLambda: + Type: AWS::Lambda::Function + Properties: + FunctionName: analytics_lambda + Description: "Site analytics" + Handler: unused + Architectures: + - arm64 + MemorySize: 512 + Runtime: provided.al2 + Timeout: 360 + Role: !GetAtt AnalyticsLambdaRole.Arn + Code: + S3Bucket: private.s3.blakerain.com + S3Key: default-function.zip + Environment: + Variables: + RUST_LOG: info + DATABASE_ENDPOINT: !GetAtt AnalyticsDatabase.Endpoint.Address + DATABASE_PASSWORD: "{{resolve:ssm:analytics_database_password}}" + VpcConfig: + SubnetIds: + - !Ref AnalyticsSubnet1 + - !Ref AnalyticsSubnet2 + SecurityGroupIds: + - !Ref AnalyticsLambdaSecurityGroup + DependsOn: + - AnalyticsLambdaPolicy + + AnalyticsLambdaDeployer: + Type: AWS::IAM::User + Properties: + UserName: analytics_lambda_deployer + + AnalyticsLambdaDeployerAccessKey: + Type: AWS::IAM::AccessKey + Properties: + UserName: !Ref AnalyticsLambdaDeployer + + AnalyticsApi: + Type: AWS::ApiGatewayV2::Api + Properties: + Name: blakerain_analytics_api + Description: Analytics API + ProtocolType: HTTP + + AnalyticsApiIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref AnalyticsApi + ConnectionType: INTERNET + IntegrationMethod: POST + IntegrationType: AWS_PROXY + TimeoutInMillis: 30000 + PayloadFormatVersion: "2.0" + IntegrationUri: !GetAtt AnalyticsLambda.Arn + + AnalyticsApiRouteDefault: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref AnalyticsApi + ApiKeyRequired: false + RouteKey: "$default" + Target: + Fn::Join: + - "/" + - - integrations + - !Ref AnalyticsApiIntegration + + AnalyticsApiLogGroup: + Type: AWS::Logs::LogGroup + Properties: + RetentionInDays: 365 + LogGroupName: "/aws/apigateway/blakerain_analytics_api" + + AnalyticsApiStage: + Type: AWS::ApiGatewayV2::Stage + Properties: + ApiId: !Ref AnalyticsApi + StageName: "$default" + AutoDeploy: true + AccessLogSettings: + DestinationArn: !GetAtt AnalyticsApiLogGroup.Arn + Format: '$context.identity.sourceIp - - [$context.requestTime] "$context.httpMethod $context.routeKey $context.protocol" $context.status $context.responseLength $context.requestId' + + AnalyticsApiPermission: + Type: AWS::Lambda::Permission + Properties: + Action: "lambda:InvokeFunction" + FunctionName: !GetAtt AnalyticsLambda.Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - ":" + - - "arn:aws:execute-api" + - !Sub "${AWS::Region}" + - !Sub "${AWS::AccountId}" + - Fn::Join: + - "/" + - - !Ref AnalyticsApi + - "*" + - "$default" + + AnalyticsApiDomain: + Type: AWS::ApiGatewayV2::DomainName + Properties: + DomainName: + Fn::Join: + - "." + - - analytics + - !Ref DomainName + DomainNameConfigurations: + - CertificateArn: !Ref AnalyticsApiCertificate + EndpointType: REGIONAL + SecurityPolicy: TLS_1_2 + + AnalyticsApiDomainMapping: + Type: AWS::ApiGatewayV2::ApiMapping + Properties: + ApiId: !Ref AnalyticsApi + DomainName: !Ref AnalyticsApiDomain + Stage: !Ref AnalyticsApiStage + + AnalyticsApiCertificate: + Type: AWS::CertificateManager::Certificate + Properties: + DomainName: + Fn::Join: + - "." + - - analytics + - !Ref DomainName + ValidationMethod: DNS + DomainValidationOptions: + - DomainName: + Fn::Join: + - "." + - - analytics + - !Ref DomainName + HostedZoneId: !Ref HostedZoneId + + AnalyticsApiRecordSet: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !Ref HostedZoneId + Name: + Fn::Join: + - "." + - - analytics + - !Ref DomainName + Type: A + AliasTarget: + HostedZoneId: !GetAtt AnalyticsApiDomain.RegionalHostedZoneId + DNSName: !GetAtt AnalyticsApiDomain.RegionalDomainName diff --git a/public/analytics.js b/public/analytics.js new file mode 100644 index 0000000..532253a --- /dev/null +++ b/public/analytics.js @@ -0,0 +1,41 @@ +export function getTimezone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return null; + } +} + +export function getReferrer() { + return document.referrer + .replace(/^https?:\/\/((m|l|w{2,3})([0-9]+)?\.)?([^?#]+)(.*)$/, "$4") + .replace(/^([^/]+)$/, "$1"); +} + +export function getPosition() { + try { + const doc = window.document.documentElement; + const body = window.document.body; + + return Math.min( + 100, + 5 * + Math.round( + (100 * (doc.scrollTop + doc.clientHeight)) / body.scrollHeight / 5 + ) + ); + } catch { + return 0; + } +} + +export function sendBeacon(url, body) { + return fetch(url, { + keepalive: true, + method: "POST", + headers: { + "content-type": "application/json", + }, + body, + }); +} diff --git a/src/bin/site-build.rs b/src/bin/site-build.rs index 4929630..861006e 100644 --- a/src/bin/site-build.rs +++ b/src/bin/site-build.rs @@ -90,6 +90,11 @@ impl Env { } async fn render_route(&self, route: Route) -> String { + assert!( + route.shoud_render(), + "Route {route:?} should not be rendered" + ); + let head = HeadContext::default(); let render = { @@ -135,7 +140,11 @@ struct RenderRoute { fn collect_routes() -> Vec { enum_iterator::all::() - .map(|route| { + .filter_map(|route| { + if !route.should_render() { + return None; + } + let path = route.to_path(); let path = if path == "/" { PathBuf::from("index.html") @@ -143,7 +152,7 @@ fn collect_routes() -> Vec { PathBuf::from(&path[1..]).with_extension("html") }; - RenderRoute { route, path } + Some(RenderRoute { route, path }) }) .collect() } diff --git a/src/bin/site.rs b/src/bin/site.rs index 2390a94..0f5279e 100644 --- a/src/bin/site.rs +++ b/src/bin/site.rs @@ -5,7 +5,7 @@ fn main() { wasm_logger::init(wasm_logger::Config::default()); log::info!( - "blakerain.com {}, {} {} build", + "blakerain.com {}, {} {} build, compiled {}", env!("CARGO_PKG_VERSION"), if cfg!(debug_assertions) { "debug" @@ -16,11 +16,10 @@ fn main() { "hydration" } else { "standard" - } + }, + env!("BUILD_TIME") ); - log::info!("Compiled {}", env!("BUILD_TIME")); - let app = yew::Renderer::::new(); #[cfg(feature = "hydration")] @@ -31,7 +30,7 @@ fn main() { #[cfg(not(feature = "hydration"))] { - log::info!("Rendering application"); + log::info!("Mounting application"); app.render(); } } diff --git a/src/components.rs b/src/components.rs index 3039e46..b74cce3 100644 --- a/src/components.rs +++ b/src/components.rs @@ -1,5 +1,7 @@ +pub mod analytics; pub mod blog; pub mod content; +pub mod display; pub mod head; pub mod layout; pub mod render; diff --git a/src/components/analytics.rs b/src/components/analytics.rs new file mode 100644 index 0000000..c1ee8e2 --- /dev/null +++ b/src/components/analytics.rs @@ -0,0 +1,404 @@ +use std::rc::Rc; + +use js_sys::Promise; +use serde::{Deserialize, Serialize}; +use time::{Duration, OffsetDateTime}; +use uuid::Uuid; +use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; +use wasm_bindgen_futures::JsFuture; +use yew::{function_component, html, use_effect_with_deps, use_reducer, Event, Html, Reducible}; +use yew_hooks::{ + use_async, use_async_with_options, use_event_with_window, UseAsyncHandle, UseAsyncOptions, +}; +use yew_router::prelude::use_location; + +#[wasm_bindgen(module = "/public/analytics.js")] +extern "C" { + #[wasm_bindgen(js_name = "getTimezone")] + fn get_timezone() -> Option; + + #[wasm_bindgen(js_name = "getReferrer")] + fn get_referrer() -> String; + + #[wasm_bindgen(js_name = "getPosition")] + fn get_position() -> f64; + + #[wasm_bindgen(catch, js_name = "sendBeacon")] + fn send_beacon(url: &str, body: &str) -> Result; +} + +#[derive(Serialize)] +struct AnalyticsData { + path: Option, + ua: Option, + vw: Option, + vh: Option, + sw: Option, + sh: Option, + tz: Option, + rf: Option, +} + +#[derive(Deserialize)] +struct AnalyticsResponse { + id: Option, +} + +#[inline] +fn quick_f64_to_i32(value: f64) -> i32 { + value as i32 +} + +fn should_not_track() -> bool { + let dnt = gloo::utils::window().navigator().do_not_track(); + dnt == "1" || dnt == "yes" +} + +impl AnalyticsData { + pub fn capture() -> Self { + let window = gloo::utils::window(); + + let path = if let Ok(mut path) = window.location().pathname() { + if !path.starts_with('/') { + path.insert(0, '/') + } + + if path.len() > 1 && path.ends_with('/') { + path.pop().expect("pop"); + } + + Some(path) + } else { + None + }; + + Self { + path, + ua: window.navigator().user_agent().ok(), + vw: window + .inner_width() + .expect("inner_width") + .as_f64() + .map(quick_f64_to_i32), + vh: window + .inner_height() + .expect("inner_height") + .as_f64() + .map(quick_f64_to_i32), + sw: window.screen().expect("screen").width().ok(), + sh: window.screen().expect("screen").height().ok(), + tz: get_timezone(), + rf: Some(get_referrer()), + } + } +} + +#[derive(Clone)] +struct AnalyticsState { + view_id: Option, + start: OffsetDateTime, + scroll: f64, + visibility: VisibilityState, +} + +#[derive(Clone)] +enum VisibilityState { + Unknown, + Visible { + total_hidden: Duration, + }, + Hidden { + total: Duration, + start: OffsetDateTime, + }, +} + +impl Default for VisibilityState { + fn default() -> Self { + Self::Unknown + } +} + +impl VisibilityState { + fn from_document() -> Self { + let hidden = gloo::utils::window().document().expect("document").hidden(); + + if hidden { + VisibilityState::Hidden { + total: Duration::new(0, 0), + start: OffsetDateTime::now_utc(), + } + } else { + VisibilityState::Visible { + total_hidden: Duration::new(0, 0), + } + } + } + + fn to_visible(&self) -> Self { + match self { + Self::Unknown => Self::Visible { + total_hidden: Duration::new(0, 0), + }, + + Self::Hidden { total, start } => { + let hidden = OffsetDateTime::now_utc() - *start; + let total_hidden = *total + hidden; + + log::info!( + "Page is now visible; was hidden for {} second(s) ({} total)", + hidden.whole_seconds(), + total_hidden.whole_seconds(), + ); + + Self::Visible { total_hidden } + } + + Self::Visible { .. } => self.clone(), + } + } + + fn to_hidden(&self) -> Self { + match self { + Self::Unknown => Self::Hidden { + total: Duration::new(0, 0), + start: OffsetDateTime::now_utc(), + }, + + Self::Hidden { .. } => self.clone(), + + Self::Visible { + total_hidden: hidden, + } => Self::Hidden { + total: *hidden, + start: OffsetDateTime::now_utc(), + }, + } + } +} + +impl AnalyticsState { + fn new() -> Self { + Self { + view_id: None, + start: OffsetDateTime::now_utc(), + scroll: 0.0, + visibility: VisibilityState::default(), + } + } + + fn new_with_id(id: Uuid) -> Self { + Self { + view_id: Some(id), + start: OffsetDateTime::now_utc(), + scroll: get_position().clamp(0.0, 100.0), + visibility: VisibilityState::from_document(), + } + } + + fn get_total_hidden(&self) -> Duration { + match self.visibility { + VisibilityState::Unknown => Duration::seconds(0), + VisibilityState::Visible { + total_hidden: hidden, + } => hidden, + VisibilityState::Hidden { total, start } => total + (OffsetDateTime::now_utc() - start), + } + } + + fn get_duration(&self) -> f64 { + ((OffsetDateTime::now_utc() - self.start) - self.get_total_hidden()) + .abs() + .clamp(Duration::new(0, 0), Duration::hours(2)) + .as_seconds_f64() + .round() + } +} + +enum AnalyticsAction { + NewPageView(Uuid), + SetScroll(f64), + VisibilityChanged(bool), +} + +impl Reducible for AnalyticsState { + type Action = AnalyticsAction; + + fn reduce(self: Rc, action: Self::Action) -> Rc { + match action { + AnalyticsAction::NewPageView(id) => Self::new_with_id(id), + + AnalyticsAction::SetScroll(distance) => Self { + scroll: self.scroll.max(distance), + ..(*self).clone() + }, + + AnalyticsAction::VisibilityChanged(visible) => { + let visibility = if visible { + self.visibility.to_visible() + } else { + self.visibility.to_hidden() + }; + + Self { + visibility, + ..(*self).clone() + } + } + } + .into() + } +} + +#[derive(Serialize)] +struct AnalyticsBeaconData { + duration: f64, + scroll: f64, +} + +impl From<&AnalyticsState> for AnalyticsBeaconData { + fn from(state: &AnalyticsState) -> Self { + Self { + duration: state.get_duration(), + scroll: 0.0_f64.max(state.scroll), + } + } +} + +impl AnalyticsBeaconData { + pub async fn send(&self, url: &str) -> Result<(), JsValue> { + let body = serde_json::to_string(self).expect("JSON"); + let res = send_beacon(url, &body)?; + JsFuture::from(res).await?; + Ok(()) + } +} + +pub fn get_analytics_host() -> String { + let mut host = std::option_env!("ANALYTICS_HOST") + .unwrap_or("https://analytics.blakerain.com") + .to_string(); + + if !host.ends_with('/') { + host.push('/'); + } + + host +} + +#[function_component(Analytics)] +pub fn analytics() -> Html { + let host = get_analytics_host(); + let state = use_reducer(AnalyticsState::new); + let location = use_location(); + + let send_analytics: UseAsyncHandle<(), &'static str> = { + let host = host.clone(); + let state = state.clone(); + use_async_with_options( + async move { + if should_not_track() { + log::info!("Do Not Track is enabled; analytics will not be sent"); + return Ok(()); + } + + let data = AnalyticsData::capture(); + + let res = reqwest::Client::new() + .post(format!("{host}page_view")) + .json(&data) + .send() + .await + .map_err(|err| { + log::error!("Unable to send analytics data: {err:?}"); + "Unable to send analytics data" + })?; + + let AnalyticsResponse { id } = + res.json::().await.map_err(|err| { + log::error!("Unable to parse analytics response: {err:?}"); + "Unable to parse analytics response" + })?; + + if let Some(id) = id { + log::info!( + "New page view '{id}' (for '{}')", + data.path.unwrap_or_default() + ); + + state.dispatch(AnalyticsAction::NewPageView(id)); + } else { + log::warn!("Analytics record was not created; received no UUID"); + } + + Ok(()) + }, + UseAsyncOptions::enable_auto(), + ) + }; + + let send_beacon: UseAsyncHandle<(), &'static str> = { + let host = host.clone(); + let state = state.clone(); + use_async(async move { + if should_not_track() { + log::info!("Do Not Track is enabled; analytics beacon will not be sent"); + return Ok(()); + } + + if let Some(id) = state.view_id { + log::info!("Sending beacon for page view '{id}'"); + AnalyticsBeaconData::from(&*state) + .send(&format!("{host}page_view/{id}")) + .await + .map_err(|err| { + log::error!("Failed to send analytics beacon: {err:?}"); + "Unable to send analytics beacon" + })?; + } + + Ok(()) + }) + }; + + { + let send_beacon = send_beacon.clone(); + use_effect_with_deps( + move |_| { + send_beacon.run(); + send_analytics.run(); + }, + location.map(|loc| loc.path().to_string()), + ) + } + + { + let state = state.clone(); + use_event_with_window("scroll", move |_: Event| { + let distance = get_position(); + state.dispatch(AnalyticsAction::SetScroll(distance)); + }) + } + + { + let state = state.clone(); + let send_beacon = send_beacon.clone(); + use_event_with_window("visibilitychange", move |_: Event| { + let hidden = gloo::utils::window().document().expect("document").hidden(); + state.dispatch(AnalyticsAction::VisibilityChanged(!hidden)); + + if hidden { + send_beacon.run(); + } + }) + } + + { + let send_beacon = send_beacon.clone(); + use_event_with_window("pagehide", move |_: Event| { + send_beacon.run(); + }) + } + + html! {} +} diff --git a/src/components/display.rs b/src/components/display.rs new file mode 100644 index 0000000..1954e0b --- /dev/null +++ b/src/components/display.rs @@ -0,0 +1 @@ +pub mod bar_chart; diff --git a/src/components/display/bar_chart.rs b/src/components/display/bar_chart.rs new file mode 100644 index 0000000..e20cc10 --- /dev/null +++ b/src/components/display/bar_chart.rs @@ -0,0 +1,146 @@ +use yew::{classes, function_component, html, use_state, Callback, Html, Properties}; + +pub struct AxisScale { + pub height: f32, + pub min_value: f32, + pub max_value: f32, +} + +impl AxisScale { + pub fn scale(&self, value: f32) -> f32 { + self.height * (value - self.min_value) / (self.max_value - self.min_value) + } +} + +#[derive(Properties, PartialEq)] +pub struct BarChartProps { + pub labels: Vec, + pub data: Vec, + pub onhover: Option>, + pub onleave: Option>, +} + +const CHART_WIDTH: f32 = 1000.0; +const CHART_HEIGHT: f32 = 562.0; +const TOP_OFFSET: f32 = 40.0; +const AXIS_OFFSET_X: f32 = 60.0; +const AXIS_OFFSET_Y: f32 = 40.0; +const CHART_AREA_WIDTH: f32 = CHART_WIDTH - AXIS_OFFSET_X; +const CHART_AREA_HEIGHT: f32 = CHART_HEIGHT - (TOP_OFFSET + AXIS_OFFSET_Y); +const AXIS_GRADUATION_COUNT: usize = 15; + +#[function_component(BarChart)] +pub fn bar_chart(props: &BarChartProps) -> Html { + debug_assert_eq!(props.labels.len(), props.data.len()); + let highlight = use_state(|| None::); + + let mut min_value = f32::MAX; + let mut max_value = f32::MIN; + + for value in &props.data { + min_value = min_value.min(*value); + max_value = max_value.max(*value); + } + + let scale = AxisScale { + height: CHART_AREA_HEIGHT, + min_value, + max_value, + }; + + let graduations = + ((15f32.min(max_value - min_value)).round() as usize).min(AXIS_GRADUATION_COUNT); + let graduation_step = CHART_AREA_HEIGHT / graduations as f32; + let bar_width = CHART_AREA_WIDTH / props.data.len() as f32; + + html! { + + + + + { for props.labels.iter().enumerate().map(|(index, label)| html! { + + + + {label.clone()} + + + })} + + + + + + { for (0..graduations).map(|index| { + let value = scale.max_value - + (index as f32 * (scale.max_value - scale.min_value) / graduations as f32); + html! { + + + + {format!("{:.0}", value)} + + + } + })} + + + + { for props.data.iter().enumerate().map(|(index, value)| { + let onhover = props.onhover.clone(); + let onleave = props.onleave.clone(); + + if (scale.min_value - value).abs() < 0.01 { + return html! {} + } + + html! { + + } + })} + + + } +} diff --git a/src/components/layout.rs b/src/components/layout.rs index b46c24a..c37be93 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -2,6 +2,8 @@ use web_sys::{window, ScrollBehavior, ScrollToOptions}; use yew::{function_component, html, use_effect_with_deps, Children, Html, Properties}; use yew_router::prelude::use_location; +use crate::components::analytics::Analytics; + mod footer; pub mod goto_top; pub mod intersperse; @@ -34,6 +36,7 @@ pub fn layout(props: &LayoutProps) -> Html { {props.children.clone()} + } } diff --git a/src/components/layout/footer.rs b/src/components/layout/footer.rs index c4b39e6..0a50596 100644 --- a/src/components/layout/footer.rs +++ b/src/components/layout/footer.rs @@ -40,6 +40,9 @@ pub fn footer(_: &FooterProps) -> Html { rel="noreferrer"> {"Mastodon"} + classes="hover:text-neutral-50" to={Route::AnalyticsRoot}> + {"Analytics"} + >
{"Powered by "} diff --git a/src/model.rs b/src/model.rs index 7557cd2..7eac203 100644 --- a/src/model.rs +++ b/src/model.rs @@ -5,6 +5,7 @@ use yew::{function_component, html, use_memo, Children, ContextProvider, Html, P macros::tags!("content/tags.yaml"); +pub mod analytics; pub mod blog; pub mod pages; diff --git a/src/model/analytics.rs b/src/model/analytics.rs new file mode 100644 index 0000000..d36208e --- /dev/null +++ b/src/model/analytics.rs @@ -0,0 +1,31 @@ +use serde::Deserialize; +use uuid::Uuid; + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PageViewsMonth { + pub id: Uuid, + pub path: String, + pub year: i32, + pub month: i32, + pub day: i32, + pub count: i32, + pub total_beacon: i32, + pub total_scroll: f64, + pub total_duration: f64, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PageViewsPathCount { + pub path: String, + pub count: i64, + pub beacons: i64, + pub avg_duration: f64, + pub avg_scroll: f64, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PageViewsMonthResult { + pub site: PageViewsPathCount, + pub views: Vec, + pub paths: Vec, +} diff --git a/src/pages.rs b/src/pages.rs index 6b06843..bfe4272 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -1,8 +1,11 @@ use enum_iterator::Sequence; use yew::{html, Html}; -use yew_router::Routable; +use yew_router::{Routable, Switch}; + +use self::analytics::AnalyticsRoute; mod about; +mod analytics; mod blog; mod blog_post; mod disclaimer; @@ -21,6 +24,10 @@ pub enum Route { BlogPost { doc_id: crate::model::blog::DocId }, #[at("/disclaimer")] Disclaimer, + #[at("/analytics")] + AnalyticsRoot, + #[at("/analytics/*")] + Analytics, #[not_found] #[at("/404")] NotFound, @@ -31,6 +38,10 @@ impl Route { !matches!(self, Self::Disclaimer) } + pub fn should_render(&self) -> bool { + !matches!(self, Self::AnalyticsRoot | Self::Analytics) + } + pub fn switch(self) -> Html { match self { Self::Home => html! { }, @@ -39,6 +50,9 @@ impl Route { Self::BlogPost { doc_id } => html! { }, Self::Disclaimer => html! { }, Self::NotFound => html! { }, + Self::AnalyticsRoot | Self::Analytics => { + html! { render={AnalyticsRoute::switch} /> } + } } } } diff --git a/src/pages/analytics.rs b/src/pages/analytics.rs new file mode 100644 index 0000000..9e40a1a --- /dev/null +++ b/src/pages/analytics.rs @@ -0,0 +1,26 @@ +use enum_iterator::Sequence; +use yew::{html, Html}; +use yew_router::{prelude::Redirect, Routable}; + +use super::Route; + +mod auth; +mod dashboard; + +#[derive(Debug, Clone, Routable, Sequence, PartialEq)] +pub enum AnalyticsRoute { + #[at("/analytics")] + Dashboard, + #[not_found] + #[at("/analytics/404")] + NotFound, +} + +impl AnalyticsRoute { + pub fn switch(self) -> Html { + match self { + Self::Dashboard => html! { }, + Self::NotFound => html! { to={Route::NotFound} /> }, + } + } +} diff --git a/src/pages/analytics/auth.rs b/src/pages/analytics/auth.rs new file mode 100644 index 0000000..62a3ded --- /dev/null +++ b/src/pages/analytics/auth.rs @@ -0,0 +1,509 @@ +use std::rc::Rc; + +use gloo::storage::{errors::StorageError, Storage}; +use serde::Deserialize; +use wasm_bindgen::JsCast; +use web_sys::{HtmlInputElement, InputEvent, SubmitEvent}; +use yew::{ + function_component, html, use_reducer, Callback, Children, ContextProvider, Html, Properties, + Reducible, UseReducerHandle, +}; +use yew_hooks::{use_async, use_async_with_options, use_interval, UseAsyncHandle, UseAsyncOptions}; +use yew_icons::{Icon, IconId}; + +use crate::components::analytics::get_analytics_host; + +#[derive(Debug, PartialEq)] +enum AuthState { + // There is no authentication information + Empty, + // We have a stored authentication token that we want to validate. + Validating { token: String }, + // We have a valid authentication token. + Valid { token: String }, +} + +enum AuthStateAction { + UseToken(String), + Clear, +} + +const STORED_TOKEN_ID: &str = "blakerain-analytics-token"; + +impl AuthState { + pub fn new() -> Self { + if let Some(token) = Self::get_stored_token() { + Self::Validating { token } + } else { + Self::Empty + } + } + + pub fn get_stored_token() -> Option { + match gloo::storage::LocalStorage::get(STORED_TOKEN_ID) { + Ok(token) => Some(token), + Err(err) => match err { + StorageError::KeyNotFound(_) => None, + StorageError::SerdeError(err) => { + log::error!("Failed to deserialize stored authentication token: {err:?}"); + Self::remove_stored_token(); + None + } + + StorageError::JsError(err) => { + log::info!("Failed to load stored authentication token: {err:?}"); + None + } + }, + } + } + + pub fn set_stored_token(token: &str) { + gloo::storage::LocalStorage::set(STORED_TOKEN_ID, token) + .expect("SessionStorage to be writable") + } + + pub fn remove_stored_token() { + gloo::storage::LocalStorage::delete(STORED_TOKEN_ID) + } +} + +impl Reducible for AuthState { + type Action = AuthStateAction; + + fn reduce(self: Rc, action: Self::Action) -> Rc { + match action { + AuthStateAction::UseToken(token) => { + Self::set_stored_token(&token); + Self::Valid { token } + } + + AuthStateAction::Clear => { + Self::remove_stored_token(); + Self::Empty + } + } + .into() + } +} + +#[derive(Deserialize)] +#[serde(tag = "type")] +pub enum SignInResponse { + InvalidCredentials, + NewPassword, + Successful { token: String }, +} + +#[derive(Deserialize)] +#[serde(tag = "type")] +pub enum ValidateTokenResponse { + Invalid, + Valid { token: String }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AuthTokenContext(pub String); + +#[derive(Properties, PartialEq)] +pub struct WithAuthProps { + #[prop_or_default] + pub children: Children, +} + +#[function_component(WithAuth)] +pub fn with_auth(props: &WithAuthProps) -> Html { + let host = get_analytics_host(); + let state = use_reducer(AuthState::new); + + let submission: UseAsyncHandle<(), &'static str> = { + // If we have a token already in our state, then we want to validate it automatically. + let options = if let AuthState::Validating { .. } = *state { + UseAsyncOptions::enable_auto() + } else { + UseAsyncOptions::default() + }; + + let host = host.clone(); + let state = state.clone(); + + use_async_with_options( + async move { + if let AuthState::Validating { token } = &*state { + log::info!("Validating and regenerating authentication token"); + + let res = reqwest::Client::new() + .post(format!("{host}auth/validate")) + .json(&serde_json::json!({ + "token": token + })) + .send() + .await + .map_err(|err| { + log::error!( + "Unable to validate analytics authentication token: {err:?}" + ); + "Unable to validate analytics authentication token" + })?; + + let res = res.json::().await.map_err(|err| { + log::error!("Unable to parse analytics token validation response: {err:?}"); + "Unable to parse analytics token validation response" + })?; + + match res { + ValidateTokenResponse::Invalid => { + log::error!("Stored token was invalid; clearing state"); + state.dispatch(AuthStateAction::Clear); + } + + ValidateTokenResponse::Valid { token } => { + log::info!("Stored token was valid and regenerated"); + state.dispatch(AuthStateAction::UseToken(token)) + } + } + } else { + log::warn!("No analytics token was present to validate"); + } + + Ok(()) + }, + options, + ) + }; + + // Every five minutes: refresh and revalidate the token. + use_interval(move || submission.run(), 5 * 60 * 1000); + + match &*state { + AuthState::Empty => { + html! { + + } + } + + AuthState::Validating { .. } => { + html! { +
+ {"Validating Authentication ..."} +
+ } + } + + AuthState::Valid { token } => { + html! { + context={AuthTokenContext(token.clone())}> + {props.children.clone()} + > + } + } + } +} + +#[derive(Properties, PartialEq)] +struct AuthContainerProps { + title: String, + message: Option, + error: Option, + #[prop_or_default] + children: Children, +} + +#[function_component(AuthContainer)] +fn auth_container( + AuthContainerProps { + title, + message, + error, + children, + }: &AuthContainerProps, +) -> Html { + html! { +
+
+
+

{title}

+ if let Some(message) = message { +

{message}

+ } +
+ if let Some(error) = error { +

{error}

+ } + {children.clone()} +
+
+ } +} + +#[derive(Properties, PartialEq)] +struct SignInProps { + pub host: String, + pub state: UseReducerHandle, +} + +#[derive(Clone)] +struct SignInState { + processing: bool, + message: &'static str, + error: Option, + username: String, + password: String, + new_password: Option, + complete: bool, +} + +enum SignInStateAction { + SetProcessing, + SetError(String), + SetUsername(String), + SetPassword(String), + SetNewPassword(String), + InvalidCredentials, + RequireNewPassword, +} + +impl SignInState { + fn new() -> Self { + Self { + processing: false, + message: "Sign in using your username and password", + error: None, + username: String::new(), + password: String::new(), + new_password: None, + complete: false, + } + } + + fn is_complete(username: &String, password: &String, new_password: Option<&String>) -> bool { + !username.is_empty() + && !password.is_empty() + && !new_password.map(String::is_empty).unwrap_or(false) + } +} + +impl Reducible for SignInState { + type Action = SignInStateAction; + + fn reduce(self: Rc, action: Self::Action) -> Rc { + match action { + SignInStateAction::SetProcessing => Self { + processing: true, + ..(*self).clone() + }, + + SignInStateAction::SetError(error) => Self { + error: Some(error), + ..(*self).clone() + }, + + SignInStateAction::SetUsername(username) => Self { + complete: Self::is_complete(&username, &self.password, self.new_password.as_ref()), + username, + ..(*self).clone() + }, + + SignInStateAction::SetPassword(password) => Self { + complete: Self::is_complete(&self.username, &password, self.new_password.as_ref()), + password, + ..(*self).clone() + }, + + SignInStateAction::SetNewPassword(new_password) => Self { + complete: Self::is_complete(&self.username, &self.password, Some(&new_password)), + new_password: Some(new_password), + ..(*self).clone() + }, + + SignInStateAction::InvalidCredentials => Self { + processing: false, + error: Some("Invalid username or password".to_string()), + username: String::new(), + password: String::new(), + new_password: self.new_password.as_ref().map(|_| String::new()), + complete: false, + ..(*self).clone() + }, + + SignInStateAction::RequireNewPassword => Self { + processing: false, + message: "Please enter a new password", + error: None, + password: String::new(), + new_password: Some(String::new()), + complete: false, + ..(*self).clone() + }, + } + .into() + } +} + +#[function_component(SignIn)] +fn sign_in(SignInProps { host, state }: &SignInProps) -> Html { + let sign_in_state = use_reducer(SignInState::new); + + let username_change = { + let sign_in_state = sign_in_state.clone(); + Callback::from(move |event: InputEvent| { + let target = event + .target() + .expect("event target") + .dyn_into::() + .expect("input element"); + + sign_in_state.dispatch(SignInStateAction::SetUsername(target.value())); + }) + }; + + let password_change = { + let sign_in_state = sign_in_state.clone(); + Callback::from(move |event: InputEvent| { + let target = event + .target() + .expect("event target") + .dyn_into::() + .expect("input element"); + + sign_in_state.dispatch(SignInStateAction::SetPassword(target.value())); + }) + }; + + let new_password_change = { + let sign_in_state = sign_in_state.clone(); + Callback::from(move |event: InputEvent| { + let target = event + .target() + .expect("event target") + .dyn_into::() + .expect("input element"); + + sign_in_state.dispatch(SignInStateAction::SetNewPassword(target.value())); + }) + }; + + let submit: UseAsyncHandle<(), &'static str> = { + let host = host.clone(); + let state = state.clone(); + let sign_in_state = sign_in_state.clone(); + + let payload = if let Some(new_password) = &sign_in_state.new_password { + serde_json::json!({ + "username": sign_in_state.username, + "oldPassword": sign_in_state.password, + "newPassword": new_password + }) + } else { + serde_json::json!({ + "username": sign_in_state.username, + "password": sign_in_state.password + }) + }; + + use_async(async move { + { + let res = reqwest::Client::new() + .post(if sign_in_state.new_password.is_some() { + format!("{host}auth/new_password") + } else { + format!("{host}auth/sign_in") + }) + .json(&payload) + .send() + .await + .map_err(|err| { + log::error!("Failed to send authentication request: {err:?}"); + "Error communicating with authentication server" + })?; + + let res = res.json::().await.map_err(|err| { + log::error!("Failed to decode sign in response: {err:?}"); + "Error communicating with authentication server" + })?; + + match res { + SignInResponse::InvalidCredentials => { + sign_in_state.dispatch(SignInStateAction::InvalidCredentials); + } + + SignInResponse::NewPassword => { + sign_in_state.dispatch(SignInStateAction::RequireNewPassword); + } + + SignInResponse::Successful { token } => { + state.dispatch(AuthStateAction::UseToken(token)); + } + } + + Ok(()) + } + .map_err(|err: &'static str| { + sign_in_state.dispatch(SignInStateAction::SetError(err.to_string())); + err + }) + }) + }; + + let onsubmit = { + let sign_in_state = sign_in_state.clone(); + + Callback::from(move |event: SubmitEvent| { + event.prevent_default(); + + if !sign_in_state.complete { + log::error!("Attempt to submit form without completing"); + return; + } + + sign_in_state.dispatch(SignInStateAction::SetProcessing); + submit.run() + }) + }; + + html! { +
+ +
+ + +
+
+ + +
+ if let Some(new_password) = &sign_in_state.new_password { +
+ + +
+ } + +
+
+ } +} diff --git a/src/pages/analytics/dashboard.rs b/src/pages/analytics/dashboard.rs new file mode 100644 index 0000000..504bbbb --- /dev/null +++ b/src/pages/analytics/dashboard.rs @@ -0,0 +1,271 @@ +use time::{Month, OffsetDateTime}; +use wasm_bindgen::JsCast; +use yew::{function_component, html, use_context, use_state, Callback, Html, UseStateHandle}; +use yew_hooks::{use_async_with_options, UseAsyncHandle, UseAsyncOptions}; +use yew_icons::{Icon, IconId}; + +use crate::{ + components::{analytics::get_analytics_host, display::bar_chart::BarChart}, + model::analytics::{PageViewsMonth, PageViewsMonthResult}, + pages::analytics::auth::{AuthTokenContext, WithAuth}, +}; + +async fn get_month_views( + host: &str, + token: &str, + year: i32, + month: i32, +) -> Result { + reqwest::Client::new() + .get(format!("{host}query/month/{year}/{month}")) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .map_err(|err| { + log::error!("Unable to query analytics API: {err:?}"); + "Unable to query analytics API" + })? + .json() + .await + .map_err(|err| { + log::error!("Unable to deserialize response from analytics API: {err:?}"); + "Unable to deserialize response from analytics API" + }) +} + +fn month_view_chart( + year: i32, + month: Month, + bar_hover: UseStateHandle>, + mut views: &[PageViewsMonth], +) -> Html { + let ndays = time::util::days_in_year_month(year, month) as i32; + + let mut labels = Vec::new(); + let mut padded = Vec::new(); + for i in 0..ndays { + labels.push((i + 1).to_string()); + + if let Some(view) = views.first() { + if view.day == i + 1 { + padded.push(view.count as f32); + views = &views[1..]; + continue; + } + } + + padded.push(0.0); + } + + debug_assert_eq!(padded.len(), ndays as usize); + + let onhover = { + let bar_hover = bar_hover.clone(); + Callback::from(move |index| bar_hover.set(Some(index))) + }; + + let onleave = { + let bar_hover = bar_hover.clone(); + Callback::from(move |_| bar_hover.set(None)) + }; + + html! { + + } +} + +fn month_select_options(active: Month) -> Html { + let mut month = Month::January; + let mut nodes = Vec::new(); + + for _ in 0..12 { + nodes.push(html! { + + }); + + month = month.next(); + } + + nodes.into_iter().collect::() +} + +#[function_component(DashboardContent)] +fn dashboard_content() -> Html { + let now = OffsetDateTime::now_local().expect("local time"); + let host = get_analytics_host(); + let token = use_context::().expect("AuthTokenContext to be provided"); + + let year = use_state(|| now.year()); + let month = use_state(|| now.month()); + let month_result = use_state(PageViewsMonthResult::default); + let bar_hover = use_state(|| None::); + + let load_dashboard: UseAsyncHandle<(), &'static str> = { + let year = year.clone(); + let month = month.clone(); + let month_result = month_result.clone(); + + use_async_with_options( + async move { + let mut result = get_month_views(&host, &token.0, *year, (*month) as i32).await?; + + result.paths.sort_by(|a, b| { + let a = a.count + a.beacons; + (b.count + b.beacons).cmp(&a) + }); + + month_result.set(result); + Ok(()) + }, + UseAsyncOptions::enable_auto(), + ) + }; + + let onrefresh = { + let load_dashboard = load_dashboard.clone(); + Callback::from(move |_| load_dashboard.run()) + }; + + let year_change = { + let year = year.clone(); + let load_dashboard = load_dashboard.clone(); + + Callback::from(move |event: yew::Event| { + let input = event + .target() + .unwrap() + .dyn_into::() + .unwrap(); + + year.set(input.value().parse().unwrap_or(now.year())); + load_dashboard.run(); + }) + }; + + let month_change = { + let month = month.clone(); + let load_dashboard = load_dashboard.clone(); + + Callback::from(move |event: yew::Event| { + let input = event + .target() + .unwrap() + .dyn_into::() + .unwrap(); + + month.set(input.value().parse().unwrap_or(now.month())); + load_dashboard.run(); + }) + }; + + html! { +
+
+
+

{"Analytics"}

+ + +
+ +
+
+
+
+ {month_view_chart(*year, *month, bar_hover.clone(), &month_result.views)} +
+
+ if let Some(index) = *bar_hover { + if let Some(day) = month_result.views.iter().find(|view| view.day == (index as i32) + 1) { + { format!( + "{:04}-{:02}-{:02}: {} views, {} beacons, {:.2}s avg. duration, {:.2}% avg. scroll", + *year, + *month as u8, + day.day, + day.count, + day.total_beacon, + if day.total_beacon != 0 { + day.total_scroll / day.total_beacon as f64 + } else { + 0.0 + }, + if day.total_beacon != 0 { + day.total_scroll / day.total_beacon as f64 + } else { + 0.0 + }, + ) } + } + } +
+
+
+
+ + + + + + + + + + + + {for month_result.paths.iter().map(|path| html! { + + + + + + + + })} + + + + + + + + +
{"Path"}{"View Count"}{"Total Beacons"}{"Avg. Duration"}{"Avg. Scroll"}
{ path.path.clone() }{ path.count.to_string() }{ path.beacons.to_string() } + { format!("{:.0} s", path.avg_duration) } + + { format!("{:.0}%", path.avg_scroll) } +
{"Total"} + { month_result.site.count.to_string() } + + { month_result.site.beacons.to_string() } + + { format!("{:.0} s", month_result.site.avg_duration) } + + { format!("{:.0}%", month_result.site.avg_scroll) } +
+
+
+
+
+ } +} + +#[function_component(Page)] +pub fn page() -> Html { + html! { + + + + } +} diff --git a/style/main.css b/style/main.css index 980ac3d..774732f 100644 --- a/style/main.css +++ b/style/main.css @@ -6,6 +6,35 @@ @apply dark:bg-zinc-900 dark:text-neutral-200; } + .button { + @apply inline-flex items-center justify-center border border-transparent; + @apply px-4 py-2; + @apply rounded-md shadow-sm text-sm text-gray-300 bg-primary; + @apply disabled:bg-slate-300 dark:disabled:bg-gray-600 dark:disabled:text-gray-400; + @apply hover:text-white dark:disabled:hover:text-gray-400; + @apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400; + @apply transition-colors; + + > svg { + @apply mr-1; + } + } + + select, + input[type="text"], + input[type="number"], + input[type="password"] { + @apply border-primary rounded-md; + @apply text-neutral-800 placeholder:text-neutral-300; + @apply dark:bg-zinc-800 dark:text-neutral-200 dark:placeholder:text-neutral-700; + @apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400; + + &:disabled { + @apply text-neutral-500 dark:text-neutral-500; + @apply dark:bg-zinc-900; + } + } + .markdown { @apply flex flex-col; @apply font-text text-xl print:text-base; @@ -239,60 +268,6 @@ } } - div.table { - @apply flex overflow-x-scroll; - } - - table { - @apply min-w-full mb-8 border-collapse table-auto; - - thead { - @apply bg-transparent dark:bg-neutral-800; - @apply dark:text-white; - @apply border-b border-neutral-500; - - tr { - th { - @apply px-6 py-4; - - &.left { - @apply text-left; - } - - &.right { - @apply text-right; - } - - &.center { - @apply text-center; - } - } - } - } - - tbody { - tr { - @apply border-b border-neutral-400 dark:border-neutral-600; - - td { - @apply whitespace-nowrap px-6 py-4; - - &.left { - @apply text-left; - } - - &.right { - @apply text-right; - } - - &.center { - @apply text-center; - } - } - } - } - } - .callout { @apply flex flex-col gap-2 rounded-md p-4 text-base mb-8; @apply print:border-2 print:p-2; @@ -374,6 +349,67 @@ @apply text-violet-600 dark:text-violet-400; } } - } + } /* .callout */ + } /* .markdown */ + + div.table { + @apply flex overflow-x-scroll; } + + table { + @apply min-w-full mb-8 border-collapse table-auto; + + thead { + @apply bg-transparent dark:bg-neutral-800; + @apply dark:text-white; + @apply border-b border-neutral-500; + + tr { + th { + @apply px-6 py-4; + + &.left { + @apply text-left; + } + + &.right { + @apply text-right; + } + + &.center { + @apply text-center; + } + } + } + } /* thead */ + + tbody { + tr { + @apply border-b border-neutral-400 dark:border-neutral-600; + + td { + @apply whitespace-nowrap px-6 py-4; + + &.left { + @apply text-left; + } + + &.right { + @apply text-right; + } + + &.center { + @apply text-center; + } + } + } + } /* tbody */ + + &.tight { + thead tr th, + tbody tr td { + @apply px-3 py-2; + } + } + } /* table */ }