diff --git a/.dockerignore b/.dockerignore index b67eebd8..a084bf6b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,13 +10,13 @@ /config-tracker.local.toml /config.local.toml /config.toml +/contrib/dev-tools/container/ /cspell.json /data_v2.db* /data.db /data.db* -/docker/ /project-words.txt /README.md /rustfmt.toml /storage/ -/target/ +/target/ \ No newline at end of file diff --git a/.env.local b/.env.local index 8d5f8e89..1260d0f0 100644 --- a/.env.local +++ b/.env.local @@ -1,6 +1,7 @@ DATABASE_URL=sqlite://storage/database/data.db?mode=rwc -TORRUST_IDX_BACK_CONFIG= -TORRUST_IDX_BACK_USER_UID=1000 -TORRUST_TRACKER_CONFIG= -TORRUST_TRACKER_DATABASE_DRIVER=sqlite3 -TORRUST_TRACKER_API_ADMIN_TOKEN=MyAccessToken +TORRUST_INDEX_CONFIG_TOML= +TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SECRET_KEY=MaxVerstappenWC2021 +USER_ID=1000 +TORRUST_TRACKER_CONFIG_TOML= +TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER=Sqlite3 +TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN=MyAccessToken diff --git a/.github/workflows/container.yaml b/.github/workflows/container.yaml index 84ac4200..f9dc1b5d 100644 --- a/.github/workflows/container.yaml +++ b/.github/workflows/container.yaml @@ -30,7 +30,7 @@ jobs: - id: build name: Build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: file: ./Containerfile push: false @@ -111,7 +111,7 @@ jobs: uses: docker/metadata-action@v5 with: images: | - "${{ secrets.DOCKER_HUB_USERNAME }}/${{secrets.DOCKER_HUB_REPOSITORY_NAME }}" + "${{ vars.DOCKER_HUB_USERNAME }}/${{vars.DOCKER_HUB_REPOSITORY_NAME }}" tags: | type=ref,event=branch @@ -119,7 +119,7 @@ jobs: name: Login to Docker Hub uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} + username: ${{ vars.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - id: setup @@ -127,7 +127,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: file: ./Containerfile push: true @@ -149,7 +149,7 @@ jobs: uses: docker/metadata-action@v5 with: images: | - "${{ secrets.DOCKER_HUB_USERNAME }}/${{secrets.DOCKER_HUB_REPOSITORY_NAME }}" + "${{ vars.DOCKER_HUB_USERNAME }}/${{vars.DOCKER_HUB_REPOSITORY_NAME }}" tags: | type=semver,value=${{ needs.context.outputs.version }},pattern={{raw}} type=semver,value=${{ needs.context.outputs.version }},pattern={{version}} @@ -160,7 +160,7 @@ jobs: name: Login to Docker Hub uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} + username: ${{ vars.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - id: setup @@ -168,7 +168,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: file: ./Containerfile push: true diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index e84abf1a..40d0e301 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -54,7 +54,7 @@ jobs: name: Install Tools uses: taiki-e/install-action@v2 with: - tool: grcov + tool: cargo-llvm-cov, cargo-nextest, grcov - id: imdl name: Install Intermodal @@ -68,13 +68,17 @@ jobs: name: Run Unit Tests run: cargo test --tests --benches --examples --workspace --all-targets --all-features - - id: coverage - name: Generate Coverage Report + - id: coverage-llvm + name: Generate Coverage Report with LLVM + run: cargo llvm-cov nextest --tests --benches --examples --workspace --all-targets --all-features + + - id: coverage-grcov + name: Generate Coverage Report with grcov uses: alekitto/grcov@v0.2 - id: upload name: Upload Coverage Report - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} files: ${{ steps.coverage.outputs.report }} diff --git a/.github/workflows/labels.yaml b/.github/workflows/labels.yaml index 97aaa030..bb8283f3 100644 --- a/.github/workflows/labels.yaml +++ b/.github/workflows/labels.yaml @@ -29,7 +29,7 @@ jobs: - id: sync name: Apply Labels from File - uses: EndBug/label-sync@da00f2c11fdb78e4fae44adac2fdd713778ea3e8 + uses: EndBug/label-sync@v2 with: config-file: .github/labels.json delete-other-labels: true diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 5ae97af4..075ae057 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -111,12 +111,6 @@ jobs: name: Make Build Clean run: cargo clean - - id: tools - name: Install Tools - uses: taiki-e/install-action@v2 - with: - tool: cargo-llvm-cov, cargo-nextest - - id: imdl name: Install Intermodal run: cargo install imdl @@ -129,6 +123,39 @@ jobs: name: Run Unit Tests run: cargo test --tests --benches --examples --workspace --all-targets --all-features - - id: coverage - name: Generate Coverage Report - run: cargo llvm-cov nextest --tests --benches --examples --workspace --all-targets --all-features + integration: + name: Integrations + runs-on: ubuntu-latest + needs: check + + strategy: + matrix: + toolchain: [stable, nightly] + + steps: + - id: checkout + name: Checkout Repository + uses: actions/checkout@v4 + + - id: setup + name: Setup Toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ matrix.toolchain }} + + - id: cache + name: Enable Job Cache + uses: Swatinem/rust-cache@v2 + + # Temporary Cleaning to avoid Rust Compiler Bug + - id: clean + name: Make Build Clean + run: cargo clean + + - id: test-sqlite + name: Run Integration Tests (SQLite) + run: ./contrib/dev-tools/container/e2e/sqlite/run-e2e-tests.sh + + - id: test-mysql + name: Run Integration Tests (MySQL) + run: ./contrib/dev-tools/container/e2e/mysql/run-e2e-tests.sh diff --git a/.gitignore b/.gitignore index c7d2c3f0..281cc1ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ /.coverage/ /.env +/.idea/ /config.toml /data_v2.db* /data.db* +/output/ /storage/ /target -/uploads/ -/.idea/ +/uploads/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 1046838d..bd7874e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "addr2line" -version = "0.21.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" dependencies = [ "gimli", ] @@ -19,9 +19,9 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "ahash" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ "getrandom", "once_cell", @@ -30,21 +30,23 @@ dependencies = [ [[package]] name = "ahash" -version = "0.8.3" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", + "const-random", "getrandom", "once_cell", "version_check", + "zerocopy", ] [[package]] name = "aho-corasick" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] @@ -66,9 +68,9 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" [[package]] name = "android-tzdata" @@ -85,11 +87,72 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "argon2" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17ba4cac0a46bc1d2912652a751c47f2a9f3a7fe89bcae2275d418f5270402f9" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", @@ -99,9 +162,9 @@ dependencies = [ [[package]] name = "arrayref" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545" +checksum = "9d151e35f61089500b617991b791fc8bfd237ae50cd5950803758a179b41e67a" [[package]] name = "arrayvec" @@ -117,9 +180,9 @@ checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" [[package]] name = "async-compression" -version = "0.4.4" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f658e2baef915ba0f26f1f7c42bfb8e12f532a01f449a090ded75ae7a07e9ba2" +checksum = "fec134f64e2bc57411226dfc4e52dec859ddfc7e711fc5e07b612584f000e4aa" dependencies = [ "brotli", "flate2", @@ -133,13 +196,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.74" +version = "0.1.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" +checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.74", ] [[package]] @@ -151,26 +214,69 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" -version = "1.1.0" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "aws-lc-rs" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae74d9bd0a7530e8afd1770739ad34b36838829d6ad61818f9230f683f5ad77" +dependencies = [ + "aws-lc-sys", + "mirai-annotations", + "paste", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "0f0e249228c6ad2d240c2dc94b714d711629d52bad946075d8e9b2f5391f0703" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", + "libc", + "paste", +] [[package]] name = "axum" -version = "0.6.20" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" dependencies = [ "async-trait", "axum-core", - "bitflags 1.3.2", "bytes", "futures-util", "http", "http-body", + "http-body-util", "hyper", + "hyper-util", "itoa", "matchit", "memchr", @@ -183,35 +289,64 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.1", "tokio", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] name = "axum-core" -version = "0.3.4" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3" dependencies = [ "async-trait", "bytes", "futures-util", "http", "http-body", + "http-body-util", "mime", + "pin-project-lite", "rustversion", + "sync_wrapper 0.1.2", "tower-layer", "tower-service", + "tracing", +] + +[[package]] +name = "axum-server" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56bac90848f6a9393ac03c63c640925c4b7c8ca21654de40d53f55964667c7d8" +dependencies = [ + "arc-swap", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tower-service", ] [[package]] name = "backtrace" -version = "0.3.69" +version = "0.3.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" dependencies = [ "addr2line", "cc", @@ -230,9 +365,15 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" -version = "0.21.4" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" @@ -246,6 +387,29 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" +[[package]] +name = "bindgen" +version = "0.69.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" +dependencies = [ + "bitflags 2.6.0", + "cexpr", + "clang-sys", + "itertools", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.74", + "which 4.4.2", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -254,9 +418,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" dependencies = [ "serde", ] @@ -281,9 +445,9 @@ dependencies = [ [[package]] name = "brotli" -version = "3.4.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516074a47ef4bce09577a3b379392300159ce5b1ba2e501ff1c819950066100f" +checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -292,9 +456,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "2.5.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da74e2b81409b1b743f8f0c62cc6254afefb8b8e50bbfe3735550f7aeefa3448" +checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -302,9 +466,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.7.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c79ad7fb2dd38f3dabd76b09c6a5a20c038fc0213ef1e9afd30eb777f120f019" +checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" dependencies = [ "memchr", "serde", @@ -312,15 +476,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.14.0" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytemuck" -version = "1.14.0" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6" +checksum = "102087e286b4677862ea56cf8fc58bb2cdfa8725c40ffb80fe3a008eb7f2fc83" [[package]] name = "byteorder" @@ -330,20 +494,58 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.5.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" + +[[package]] +name = "camino" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0ec6b951b160caa93cc0c7b209e5a3bff7aae9062213451ac99493cd844c239" +dependencies = [ + "serde", +] + +[[package]] +name = "casbin" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71063d3ee2f5ecc89229ccade0f3f8fb413b5e3978124a38b611216f91dd7c9" +dependencies = [ + "async-trait", + "fixedbitset", + "getrandom", + "once_cell", + "parking_lot", + "petgraph", + "regex", + "rhai", + "ritelinked", + "serde", + "thiserror", + "tokio", +] [[package]] name = "cc" -version = "1.0.83" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "e9e8aabfac534be767c909e0690571677d49f41bd8465ae876fe043d52ba5292" dependencies = [ "jobserver", "libc", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -352,61 +554,137 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.31" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "windows-targets", + "serde", + "windows-targets 0.52.6", ] [[package]] name = "chumsky" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23170228b96236b5a7299057ac284a321457700bc8c41a4476052f0f4ba5349d" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" dependencies = [ - "hashbrown 0.12.3", + "hashbrown 0.14.5", "stacker", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d8838454fda655dafd3accb2b6e2bea645b9e4078abe84a22ceb947235c5cc" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.74", +] + +[[package]] +name = "clap_lex" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" + +[[package]] +name = "cmake" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31c789563b815f77f4250caee12365734369f942439b7defd71e18a48197130" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + [[package]] name = "colored" -version = "2.0.4" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2674ec482fbc38012cf31e6c42ba0177b431a0cb6f15fe40efa5aab1bda516f6" +checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" dependencies = [ - "is-terminal", "lazy_static", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] -name = "config" -version = "0.13.3" +name = "concurrent-queue" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d379af7f68bfc21714c6c7dea883544201741d2ce8274bb12fa54f89507f52a7" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "async-trait", - "json5", - "lazy_static", - "nom", - "pathdiff", - "ron", - "rust-ini", - "serde", - "serde_json", - "toml 0.5.11", - "yaml-rust", + "crossbeam-utils", ] [[package]] name = "const-oid" -version = "0.9.5" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", +] [[package]] name = "convert_case" @@ -416,9 +694,9 @@ checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" [[package]] name = "core-foundation" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ "core-foundation-sys", "libc", @@ -426,62 +704,83 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.4" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.9" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" dependencies = [ "libc", ] [[package]] name = "crc" -version = "3.0.1" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc32fast" -version = "1.3.2" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ "cfg-if", ] [[package]] -name = "crossbeam-queue" -version = "0.3.8" +name = "crossbeam-deque" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" dependencies = [ - "cfg-if", + "crossbeam-epoch", "crossbeam-utils", ] [[package]] -name = "crossbeam-utils" -version = "0.8.16" +name = "crossbeam-epoch" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ - "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" +dependencies = [ + "crossbeam-utils", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + [[package]] name = "crypto-common" version = "0.1.6" @@ -492,6 +791,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.74", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.74", +] + [[package]] name = "data-url" version = "0.1.1" @@ -503,9 +837,9 @@ dependencies = [ [[package]] name = "der" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" dependencies = [ "const-oid", "pem-rfc7468", @@ -514,11 +848,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.9" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -534,15 +869,15 @@ dependencies = [ [[package]] name = "derive_more" -version = "0.99.17" +version = "0.99.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" dependencies = [ "convert_case", "proc-macro2", "quote", "rustc_version", - "syn 1.0.109", + "syn 2.0.74", ] [[package]] @@ -557,51 +892,57 @@ dependencies = [ "subtle", ] -[[package]] -name = "dlv-list" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" - [[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" -version = "1.9.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" dependencies = [ "serde", ] [[package]] name = "email-encoding" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbfb21b9878cf7a348dcb8559109aabc0ec40d69924bd706fa5149846c4fef75" +checksum = "60d1d33cdaede7e24091f039632eb5d3c7469fe5b066a985281a34fc70fa317f" dependencies = [ - "base64 0.21.4", + "base64 0.22.1", "memchr", ] [[package]] name = "email_address" -version = "0.2.4" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" dependencies = [ "serde", ] [[package]] name = "encoding_rs" -version = "0.8.33" +version = "0.8.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" dependencies = [ "cfg-if", ] @@ -614,12 +955,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.5" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3e13f66a2f95e32a39eaa81f6b95d42878ca0e1db0c7543723dfe12557e860" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -630,26 +971,31 @@ checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" dependencies = [ "cfg-if", "home", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] name = "event-listener" -version = "2.5.3" +version = "5.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] [[package]] name = "fastrand" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" [[package]] name = "fdeflate" -version = "0.3.0" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d329bdeac514ee06249dabc27877490f17f5d371ec693360768b838e19f3ae10" +checksum = "4f9bfee30e4dedf0ab8b422f03af778d9612b63f502710fc500a334ebe2de645" dependencies = [ "simd-adler32", ] @@ -664,16 +1010,32 @@ dependencies = [ ] [[package]] -name = "finl_unicode" -version = "1.2.0" +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "parking_lot", + "pear", + "serde", + "tempfile", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "fixedbitset" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.0.28" +version = "1.0.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +checksum = "7f211bbe8e69bbd0cfdea405084f128ae8b4aaa6b0b522fc8f2b009084797920" dependencies = [ "crc32fast", "miniz_oxide", @@ -693,7 +1055,7 @@ checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" dependencies = [ "futures-core", "futures-sink", - "spin 0.9.8", + "spin", ] [[package]] @@ -730,18 +1092,30 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" dependencies = [ "futures-channel", "futures-core", @@ -754,9 +1128,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", "futures-sink", @@ -764,15 +1138,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" [[package]] name = "futures-executor" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" dependencies = [ "futures-core", "futures-task", @@ -792,38 +1166,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" [[package]] name = "futures-macro" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.74", ] [[package]] name = "futures-sink" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" [[package]] name = "futures-task" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" [[package]] name = "futures-util" -version = "0.3.28" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-channel", "futures-core", @@ -849,58 +1223,66 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.10" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] name = "gimli" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "globset" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759c97c1e17c55525b57192c06a267cda0ac5210b222d6b82189a2338fa1c13d" +checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" dependencies = [ "aho-corasick", "bstr", - "fnv", "log", - "regex", + "regex-automata", + "regex-syntax", ] [[package]] name = "globwalk" -version = "0.8.1" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.6.0", "ignore", "walkdir", ] [[package]] name = "h2" -version = "0.3.21" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833" +checksum = "fa82e28a107a8cc405f0839610bdc9b15f1e25ec7d696aa5cf173edbcb1486ab" dependencies = [ + "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "futures-util", "http", - "indexmap 1.9.3", + "indexmap 2.3.0", "slab", "tokio", "tokio-util", @@ -909,46 +1291,49 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.12.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" dependencies = [ - "ahash 0.7.6", + "ahash 0.7.8", ] [[package]] name = "hashbrown" -version = "0.14.1" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dfda62a12f55daeae5015f81b0baea145391cb4520f86c248fc615d72640d12" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash 0.8.3", + "ahash 0.8.11", "allocator-api2", ] [[package]] name = "hashlink" -version = "0.8.4" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown 0.14.1", + "hashbrown 0.14.5", ] [[package]] name = "heck" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -dependencies = [ - "unicode-segmentation", -] +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.3.3" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "hex" @@ -958,9 +1343,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ "hmac", ] @@ -976,29 +1361,29 @@ dependencies = [ [[package]] name = "home" -version = "0.5.5" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" dependencies = [ - "windows-sys", + "windows-sys 0.52.0", ] [[package]] name = "hostname" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" dependencies = [ + "cfg-if", "libc", - "match_cfg", - "winapi", + "windows", ] [[package]] name = "http" -version = "0.2.9" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" dependencies = [ "bytes", "fnv", @@ -1007,26 +1392,32 @@ dependencies = [ [[package]] name = "http-body" -version = "0.4.5" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", - "pin-project-lite", ] [[package]] -name = "http-range-header" -version = "0.3.1" +name = "http-body-util" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", +] [[package]] name = "httparse" -version = "1.8.0" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" [[package]] name = "httpdate" @@ -1036,13 +1427,12 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "0.14.27" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" +checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" dependencies = [ "bytes", "futures-channel", - "futures-core", "futures-util", "h2", "http", @@ -1051,38 +1441,76 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.4.9", + "smallvec", "tokio", - "tower-service", - "tracing", "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-tls" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", + "http-body-util", "hyper", + "hyper-util", "native-tls", "tokio", "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", + "tower", + "tower-service", + "tracing", ] [[package]] name = "iana-time-zone" -version = "0.1.57" +version = "0.1.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows", + "windows-core", ] [[package]] @@ -1094,11 +1522,17 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ "unicode-bidi", "unicode-normalization", @@ -1106,17 +1540,16 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.20" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbe7873dab538a9a44ad79ede1faf5f30d49f9a5c883ddbab48bce81b64b7492" +checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" dependencies = [ + "crossbeam-deque", "globset", - "lazy_static", "log", "memchr", - "regex", + "regex-automata", "same-file", - "thread_local", "walkdir", "winapi-util", ] @@ -1129,55 +1562,67 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] name = "indexmap" -version = "2.0.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897" +checksum = "de3fc2e30ba82dd1b3911c8de1ffc143c74a914a14e99514d7637e3099df5ea0" dependencies = [ "equivalent", - "hashbrown 0.14.1", + "hashbrown 0.14.5", + "serde", ] [[package]] -name = "ipnet" -version = "2.8.0" +name = "inlinable_string" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" [[package]] -name = "is-terminal" -version = "0.4.9" +name = "instant" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ - "hermit-abi", - "rustix", - "windows-sys", + "cfg-if", ] +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itertools" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "jobserver" -version = "0.1.27" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" dependencies = [ "libc", ] @@ -1190,31 +1635,21 @@ checksum = "229d53d58899083193af11e15917b5640cd40b29ff475a1fe4ef725deb02d0f2" [[package]] name = "js-sys" -version = "0.3.64" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" dependencies = [ "wasm-bindgen", ] -[[package]] -name = "json5" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" -dependencies = [ - "pest", - "pest_derive", - "serde", -] - [[package]] name = "jsonwebtoken" -version = "8.3.0" +version = "9.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" +checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" dependencies = [ - "base64 0.21.4", + "base64 0.21.7", + "js-sys", "pem", "ring", "serde", @@ -1233,21 +1668,27 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.5.2", + "spin", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "lettre" -version = "0.11.0" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d47084ad58f99c26816d174702f60e873f861fcef3f9bd6075b4ad2dd72d07d5" +checksum = "1a62049a808f1c4e2356a2a380bd5f2aca3b011b0b482cf3b914ba1731426969" dependencies = [ "async-trait", - "base64 0.21.4", + "base64 0.22.1", "chumsky", "email-encoding", "email_address", @@ -1260,23 +1701,36 @@ dependencies = [ "mime", "native-tls", "nom", - "once_cell", + "percent-encoding", "quoted_printable", "rustls", "rustls-pemfile", - "socket2 0.5.4", + "serde", + "serde_json", + "socket2", "tokio", "tokio-native-tls", "tokio-rustls", "url", + "uuid", "webpki-roots", ] [[package]] name = "libc" -version = "0.2.149" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "libloading" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" +checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +dependencies = [ + "cfg-if", + "windows-targets 0.52.6", +] [[package]] name = "libm" @@ -1286,32 +1740,26 @@ checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" [[package]] name = "libsqlite3-sys" -version = "0.26.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" dependencies = [ "cc", "pkg-config", "vcpkg", ] -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" -version = "0.4.10" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da2479e8c062e40bf0066ffa0bc823de0a9368974af99c9f6df941d2c231e03f" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "lock_api" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", @@ -1319,15 +1767,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" - -[[package]] -name = "match_cfg" -version = "0.1.0" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "matches" @@ -1353,9 +1795,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.6.4" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "memmap2" @@ -1374,9 +1816,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mime_guess" -version = "2.0.4" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" dependencies = [ "mime", "unicase", @@ -1390,9 +1832,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.7.1" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" dependencies = [ "adler", "simd-adler32", @@ -1400,40 +1842,72 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.8" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ + "hermit-abi", "libc", "wasi", - "windows-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "mirai-annotations" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9be0862c1b3f26a88803c4a49de6889c10e608b3ee9344e6ef5b45fb37ad3d1" + +[[package]] +name = "mockall" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.74", ] [[package]] name = "multer" -version = "2.1.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01acbdc23469fd8fe07ab135923371d5f5a422fbf9c522158677c8eb15bc51c2" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" dependencies = [ "bytes", "encoding_rs", "futures-util", "http", "httparse", - "log", "memchr", "mime", - "spin 0.9.8", + "spin", "version_check", ] [[package]] name = "native-tls" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" dependencies = [ - "lazy_static", "libc", "log", "openssl", @@ -1455,13 +1929,22 @@ dependencies = [ "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" -version = "0.4.4" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -1483,21 +1966,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "autocfg", "num-traits", ] [[package]] name = "num-iter" -version = "0.1.43" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ "autocfg", "num-integer", @@ -1506,46 +1994,36 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.17" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 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" +version = "0.36.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +checksum = "27b64972346851a39438c60b341ebc01bba47464ae329e55cf343eb93964efd9" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "openssl" -version = "0.10.57" +version = "0.10.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c" +checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" dependencies = [ - "bitflags 2.4.0", + "bitflags 2.6.0", "cfg-if", "foreign-types", "libc", @@ -1562,7 +2040,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.74", ] [[package]] @@ -1573,9 +2051,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.93" +version = "0.9.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db4d56a4c0478783083cfafcc42493dd4a981d41669da64b4572a2a089b51b1d" +checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" dependencies = [ "cc", "libc", @@ -1584,20 +2062,22 @@ dependencies = [ ] [[package]] -name = "ordered-multimap" -version = "0.4.3" +name = "overload" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" -dependencies = [ - "dlv-list", - "hashbrown 0.12.3", -] +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" [[package]] name = "parking_lot" -version = "0.12.1" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", @@ -1605,15 +2085,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.8" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.3", "smallvec", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1629,15 +2109,9 @@ dependencies = [ [[package]] name = "paste" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" - -[[package]] -name = "pathdiff" -version = "0.2.1" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pbkdf2" @@ -1651,13 +2125,37 @@ dependencies = [ "sha2", ] +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.74", +] + [[package]] name = "pem" -version = "1.1.1" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" +checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" dependencies = [ - "base64 0.13.1", + "base64 0.22.1", + "serde", ] [[package]] @@ -1671,15 +2169,15 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pest" -version = "2.7.4" +version = "2.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c022f1e7b65d6a24c0dbbd5fb344c66881bc01f3e5ae74a1c8100f2f985d98a4" +checksum = "cd53dff83f26735fdc1ca837098ccf133605d794cdae66acfc2bfac3ec809d95" dependencies = [ "memchr", "thiserror", @@ -1688,9 +2186,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.7.4" +version = "2.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35513f630d46400a977c4cb58f78e1bfbe01434316e60c37d27b9ad6139c66d8" +checksum = "2a548d2beca6773b1c244554d36fcf8548a8a58e74156968211567250e48e49a" dependencies = [ "pest", "pest_generator", @@ -1698,28 +2196,38 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.4" +version = "2.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9fc1b9e7057baba189b5c626e2d6f40681ae5b6eb064dc7c7834101ec8123a" +checksum = "3c93a82e8d145725dcbaf44e5ea887c8a869efdcc28706df2d08c69e17077183" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.74", ] [[package]] name = "pest_meta" -version = "2.7.4" +version = "2.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df74e9e7ec4053ceb980e7c0c8bd3594e977fde1af91daba9c928e8e8c6708d" +checksum = "a941429fea7e08bedec25e4f6785b6ffaacc6b755da98df5ef3e7dcf4a124c4f" dependencies = [ "once_cell", "pest", "sha2", ] +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.3.0", +] + [[package]] name = "pico-args" version = "0.4.2" @@ -1728,29 +2236,29 @@ checksum = "db8bcd96cb740d03149cbad5518db9fd87126a10ab519c011893b1754134c468" [[package]] name = "pin-project" -version = "1.1.3" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.3" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.74", ] [[package]] name = "pin-project-lite" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "pin-utils" @@ -1781,15 +2289,15 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.27" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" [[package]] name = "png" -version = "0.17.10" +version = "0.17.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd75bf2d8dd3702b9707cdbc56a5b9ef42cec752eb8b3bafc01234558442aa64" +checksum = "06e4b0d3d1312775e782c86c91a111aa1f910cbb65e1337f9975b5f9a554b5e1" dependencies = [ "bitflags 1.3.2", "crc32fast", @@ -1806,19 +2314,71 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.17" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e9086cc7640c29a356d1a29fd134380bee9d8f79a17410aa76e7ad295f42c97" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8177bee8e75d6846599c6b9ff679ed51e882816914eec639944d7c9aa11931" + +[[package]] +name = "predicates-tree" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b740d195ed3166cd147c8047ec98db0e22ec019eb8eeb76d343b795304fb13" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" +dependencies = [ + "proc-macro2", + "syn 2.0.74", +] [[package]] name = "proc-macro2" -version = "1.0.69" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.74", + "version_check", + "yansi", +] + [[package]] name = "psm" version = "0.1.21" @@ -1830,18 +2390,18 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.33" +version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] [[package]] name = "quoted_printable" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79ec282e887b434b68c18fe5c121d38e72a5cf35119b59e54ec5b992ea9c8eb0" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" [[package]] name = "rand" @@ -1881,18 +2441,27 @@ checksum = "9ae028b272a6e99d9f8260ceefa3caa09300a8d6c8d2b2001316474bc52122e9" [[package]] name = "redox_syscall" -version = "0.3.5" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +dependencies = [ + "bitflags 2.6.0", +] + [[package]] name = "regex" -version = "1.10.1" +version = "1.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaac441002f822bc9705a681810a4dd2963094b9ca0ddc41cb963a4c189189ea" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" dependencies = [ "aho-corasick", "memchr", @@ -1902,9 +2471,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.2" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5011c7e263a695dc8ca064cddb722af1be54e517a280b12a5356f98366899e5d" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" dependencies = [ "aho-corasick", "memchr", @@ -1913,17 +2482,17 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" [[package]] name = "reqwest" -version = "0.11.22" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b" +checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" dependencies = [ - "base64 0.21.4", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -1931,8 +2500,11 @@ dependencies = [ "h2", "http", "http-body", + "http-body-util", "hyper", + "hyper-rustls", "hyper-tls", + "hyper-util", "ipnet", "js-sys", "log", @@ -1942,9 +2514,11 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", + "sync_wrapper 1.0.1", "system-configuration", "tokio", "tokio-native-tls", @@ -1973,37 +2547,65 @@ dependencies = [ [[package]] name = "rgb" -version = "0.8.36" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20ec2d3e3fc7a92ced357df9cebd5a10b6fb2aa1ee797bf7e9ce2f17dffc8f59" +checksum = "0f86ae463694029097b846d8f99fd5536740602ae00022c0c50c5600720b2f71" dependencies = [ "bytemuck", ] +[[package]] +name = "rhai" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61797318be89b1a268a018a92a7657096d83f3ecb31418b9e9c16dcbb043b702" +dependencies = [ + "ahash 0.8.11", + "bitflags 2.6.0", + "instant", + "num-traits", + "once_cell", + "rhai_codegen", + "serde", + "smallvec", + "smartstring", + "thin-vec", +] + +[[package]] +name = "rhai_codegen" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a11a05ee1ce44058fa3d5961d05194fdbe3ad6b40f904af764d81b86450e6b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.74", +] + [[package]] name = "ring" -version = "0.16.20" +version = "0.17.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", + "cfg-if", + "getrandom", "libc", - "once_cell", - "spin 0.5.2", + "spin", "untrusted", - "web-sys", - "winapi", + "windows-sys 0.52.0", ] [[package]] -name = "ron" -version = "0.7.1" +name = "ritelinked" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" +checksum = "98f2771d255fd99f0294f13249fecd0cae6e074f86b4197ec1f1689d537b44d3" dependencies = [ - "base64 0.13.1", - "bitflags 1.3.2", - "serde", + "ahash 0.7.8", + "hashbrown 0.11.2", ] [[package]] @@ -2017,16 +2619,14 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.2" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ab43bb47d23c1a631b4b680199a45255dce26fa9ab2fa902581f624ff13e6a8" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" dependencies = [ - "byteorder", "const-oid", "digest", "num-bigint-dig", "num-integer", - "num-iter", "num-traits", "pkcs1", "pkcs8", @@ -2038,20 +2638,16 @@ dependencies = [ ] [[package]] -name = "rust-ini" -version = "0.18.0" +name = "rustc-demangle" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" -dependencies = [ - "cfg-if", - "ordered-multimap", -] +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] -name = "rustc-demangle" -version = "0.1.23" +name = "rustc-hash" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc_version" @@ -2064,53 +2660,66 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.19" +version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "745ecfa778e66b2b63c88a61cb36e0eea109e803b0b86bf9879fbc77c70e86ed" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.4.0", + "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.21.7" +version = "0.23.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8" +checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" dependencies = [ + "aws-lc-rs", "log", + "once_cell", "ring", + "rustls-pki-types", "rustls-webpki", - "sct", + "subtle", + "zeroize", ] [[package]] name = "rustls-pemfile" -version = "1.0.3" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2" +checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425" dependencies = [ - "base64 0.21.4", + "base64 0.22.1", + "rustls-pki-types", ] +[[package]] +name = "rustls-pki-types" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" + [[package]] name = "rustls-webpki" -version = "0.101.6" +version = "0.102.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c7d5dece342910d9ba34d259310cae3e0154b873b35408b787b59bce53d34fe" +checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e" dependencies = [ + "aws-lc-rs", "ring", + "rustls-pki-types", "untrusted", ] [[package]] name = "rustversion" -version = "1.0.14" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" [[package]] name = "rustybuzz" @@ -2130,9 +2739,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "safe_arch" @@ -2154,11 +2763,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" dependencies = [ - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -2167,23 +2776,13 @@ 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 = "security-framework" -version = "2.9.2" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.6.0", "core-foundation", "core-foundation-sys", "libc", @@ -2192,9 +2791,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.9.1" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf" dependencies = [ "core-foundation-sys", "libc", @@ -2202,15 +2801,15 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.189" +version = "1.0.206" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e422a44e74ad4001bdc8eede9a4570ab52f71190e9c076d14369f38b9200537" +checksum = "5b3e4cd94123dd520a128bcd11e34d9e9e423e7e3e50425cb1b4b1e3549d0284" dependencies = [ "serde_derive", ] @@ -2227,40 +2826,41 @@ dependencies = [ [[package]] name = "serde_bytes" -version = "0.11.12" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab33ec92f677585af6d88c65593ae2375adde54efdbf16d597f2cbc7a6d368ff" +checksum = "387cc504cb06bb40a96c8e04e951fe01854cf6bc921053c954e4a606d9675c6a" dependencies = [ "serde", ] [[package]] name = "serde_derive" -version = "1.0.189" +version = "1.0.206" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e48d1f918009ce3145511378cf68d613e3b3d9137d67272562080d68a2b32d5" +checksum = "fabfb6138d2383ea8208cf98ccf69cdfb1aff4088460681d84189aa259762f97" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.74", ] [[package]] name = "serde_json" -version = "1.0.107" +version = "1.0.124" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65" +checksum = "66ad62847a56b3dba58cc891acd13884b9c61138d330c0d7b6181713d4fce38d" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] [[package]] name = "serde_path_to_error" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4beec8bce849d58d06238cb50db2e1c417cfeafa4c63f692b15c82b7c80f8335" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" dependencies = [ "itoa", "serde", @@ -2268,9 +2868,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.3" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96426c9936fd7a0124915f9185ea1d20aa9445cc9821142f0a73bc9207a2e186" +checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d" dependencies = [ "serde", ] @@ -2287,6 +2887,36 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cecfa94848272156ea67b2b1a53f20fc7bc638c4a46d2f8abde08f05f4b857" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.3.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8fee4991ef4f274617a51ad4af30519438dacb2f56ac773b08a1922ff743350" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.74", +] + [[package]] name = "sha-1" version = "0.10.1" @@ -2320,20 +2950,35 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ "libc", ] [[package]] name = "signature" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", "rand_core", @@ -2383,36 +3028,35 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.11.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +dependencies = [ + "serde", +] [[package]] -name = "socket2" -version = "0.4.9" +name = "smartstring" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" dependencies = [ - "libc", - "winapi", + "autocfg", + "serde", + "static_assertions", + "version_check", ] [[package]] name = "socket2" -version = "0.5.4" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.52.0", ] -[[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" @@ -2424,9 +3068,9 @@ dependencies = [ [[package]] name = "spki" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1e996ef02c474957d681f1b05213dfb0abab947b446a62d37770b23500184a" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", "der", @@ -2434,20 +3078,19 @@ dependencies = [ [[package]] name = "sqlformat" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7b278788e7be4d0d29c0f39497a0eef3fba6bbc8e70d8bf7fde46edeaa9e85" +checksum = "f895e3734318cc55f1fe66258926c9b910c124d47520339efecbb6c59cec7c1f" dependencies = [ - "itertools", "nom", "unicode_categories", ] [[package]] name = "sqlx" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e50c216e3624ec8e7ecd14c6a6a6370aad6ee5d8cfc3ab30b5162eeeef2ed33" +checksum = "27144619c6e5802f1380337a209d2ac1c431002dd74c6e60aebff3c506dc4f0c" dependencies = [ "sqlx-core", "sqlx-macros", @@ -2458,17 +3101,15 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d6753e460c998bbd4cd8c6f0ed9a64346fcca0723d6e75e52fdc351c5d2169d" +checksum = "a999083c1af5b5d6c071d34a708a19ba3e02106ad82ef7bbd69f5e48266b613b" dependencies = [ - "ahash 0.8.3", "atoi", "byteorder", "bytes", "crc", "crossbeam-queue", - "dotenvy", "either", "event-listener", "futures-channel", @@ -2476,9 +3117,10 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", + "hashbrown 0.14.5", "hashlink", "hex", - "indexmap 2.0.2", + "indexmap 2.3.0", "log", "memchr", "native-tls", @@ -2500,22 +3142,22 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a793bb3ba331ec8359c1853bd39eed32cdd7baaf22c35ccf5c92a7e8d1189ec" +checksum = "a23217eb7d86c584b8cbe0337b9eacf12ab76fe7673c513141ec42565698bb88" dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", - "syn 1.0.109", + "syn 2.0.74", ] [[package]] name = "sqlx-macros-core" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a4ee1e104e00dedb6aa5ffdd1343107b0a4702e862a84320ee7cc74782d96fc" +checksum = "1a099220ae541c5db479c6424bdf1b200987934033c2584f79a0e1693601e776" dependencies = [ "dotenvy", "either", @@ -2531,7 +3173,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 1.0.109", + "syn 2.0.74", "tempfile", "tokio", "url", @@ -2539,13 +3181,13 @@ dependencies = [ [[package]] name = "sqlx-mysql" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864b869fdf56263f4c95c45483191ea0af340f9f3e3e7b4d57a61c7c87a970db" +checksum = "5afe4c38a9b417b6a9a5eeffe7235d0a106716495536e7727d1c7f4b1ff3eba6" dependencies = [ "atoi", - "base64 0.21.4", - "bitflags 2.4.0", + "base64 0.22.1", + "bitflags 2.6.0", "byteorder", "bytes", "crc", @@ -2582,13 +3224,13 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7ae0e6a97fb3ba33b23ac2671a5ce6e3cabe003f451abd5a56e7951d975624" +checksum = "b1dbb157e65f10dbe01f729339c06d239120221c9ad9fa0ba8408c4cc18ecf21" dependencies = [ "atoi", - "base64 0.21.4", - "bitflags 2.4.0", + "base64 0.22.1", + "bitflags 2.6.0", "byteorder", "crc", "dotenvy", @@ -2609,7 +3251,6 @@ dependencies = [ "rand", "serde", "serde_json", - "sha1", "sha2", "smallvec", "sqlx-core", @@ -2622,9 +3263,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d59dc83cf45d89c555a577694534fcd1b55c545a816c816ce51f20bbe56a4f3f" +checksum = "9b2cdd83c008a622d94499c0006d8ee5f821f36c89b7d625c900e5dc30b5c5ee" dependencies = [ "atoi", "flume", @@ -2637,6 +3278,7 @@ dependencies = [ "log", "percent-encoding", "serde", + "serde_urlencoded", "sqlx-core", "time", "tracing", @@ -2656,22 +3298,34 @@ dependencies = [ "winapi", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stringprep" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" dependencies = [ - "finl_unicode", "unicode-bidi", "unicode-normalization", + "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "svgtypes" @@ -2695,9 +3349,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.38" +version = "2.0.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e96b79aaa137db8f61e26363a0c9b47d8b4ec75da28b7d1d614c2303e232408b" +checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" dependencies = [ "proc-macro2", "quote", @@ -2710,6 +3364,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" + [[package]] name = "system-configuration" version = "0.5.1" @@ -2733,22 +3393,22 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.8.0" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" +checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" dependencies = [ "cfg-if", "fastrand", - "redox_syscall", + "once_cell", "rustix", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] name = "tera" -version = "1.19.1" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8" +checksum = "ab9d851b45e865f178319da0abdbfe6acbc4328759ff18dafc3a41c16b4cd2ee" dependencies = [ "globwalk", "lazy_static", @@ -2760,6 +3420,12 @@ dependencies = [ "unic-segment", ] +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + [[package]] name = "text-colorizer" version = "1.0.0" @@ -2787,31 +3453,40 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "thin-vec" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38c90d48152c236a3ab59271da4f4ae63d678c5d7ad6b7714d7cb9760be5e4b" +dependencies = [ + "serde", +] + [[package]] name = "thiserror" -version = "1.0.49" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.49" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.74", ] [[package]] name = "thread_local" -version = "1.1.7" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" dependencies = [ "cfg-if", "once_cell", @@ -2819,12 +3494,13 @@ dependencies = [ [[package]] name = "time" -version = "0.3.30" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", + "num-conv", "powerfmt", "serde", "time-core", @@ -2839,13 +3515,23 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.15" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ + "num-conv", "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny-skia" version = "0.6.6" @@ -2862,9 +3548,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.6.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" dependencies = [ "tinyvec_macros", ] @@ -2877,31 +3563,30 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.33.0" +version = "1.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f38200e3ef7995e5ef13baec2f432a6da0aa9ac495b2c0e8f3b7eec2c92d653" +checksum = "daa4fb1bc778bd6f04cbfc4bb2d06a7396a8f299dc33ea1900cedaa316f467b1" dependencies = [ "backtrace", "bytes", "libc", "mio", - "num_cpus", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.4", + "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] name = "tokio-macros" -version = "2.1.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.74", ] [[package]] @@ -2916,19 +3601,20 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.24.1" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ "rustls", + "rustls-pki-types", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" dependencies = [ "futures-core", "pin-project-lite", @@ -2937,32 +3623,22 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.9" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d68074620f57a0b21594d9735eb2e98ab38b17f80d3fcb189fca266771ca60d" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", - "tracing", -] - -[[package]] -name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", ] [[package]] name = "toml" -version = "0.8.2" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" dependencies = [ "serde", "serde_spanned", @@ -2972,20 +3648,20 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.3" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.20.2" +version = "0.22.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d" dependencies = [ - "indexmap 2.0.2", + "indexmap 2.3.0", "serde", "serde_spanned", "toml_datetime", @@ -2994,36 +3670,49 @@ dependencies = [ [[package]] name = "torrust-index" -version = "3.0.0-alpha.2" +version = "3.0.0-alpha.12" dependencies = [ + "anyhow", "argon2", "async-trait", "axum", + "axum-server", "binascii", "bytes", + "camino", + "casbin", "chrono", - "config", + "clap", "derive_more", "email_address", "fern", + "figment", "futures", + "futures-util", "hex", + "http", + "http-body", "hyper", - "indexmap 2.0.2", + "hyper-util", + "indexmap 2.3.0", "jsonwebtoken", "lazy_static", "lettre", "log", + "mockall", "pbkdf2", + "pin-project-lite", "rand", "rand_core", "regex", "reqwest", + "rustversion", "serde", "serde_bencode", "serde_bytes", "serde_derive", "serde_json", + "serde_with", "sha-1", "sqlx", "tempfile", @@ -3032,20 +3721,26 @@ dependencies = [ "text-to-png", "thiserror", "tokio", - "toml 0.8.2", + "toml", "torrust-index-located-error", + "tower", "tower-http", + "trace", + "tracing", + "tracing-subscriber", + "url", "urlencoding", "uuid", - "which", + "which 6.0.2", ] [[package]] name = "torrust-index-located-error" -version = "3.0.0-alpha.2" +version = "3.0.0-alpha.12" dependencies = [ - "log", "thiserror", + "tracing", + "tracing-subscriber", ] [[package]] @@ -3066,23 +3761,24 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ "async-compression", - "bitflags 2.4.0", + "bitflags 2.6.0", "bytes", "futures-core", - "futures-util", "http", "http-body", - "http-range-header", + "http-body-util", "pin-project-lite", "tokio", "tokio-util", "tower-layer", "tower-service", + "tracing", + "uuid", ] [[package]] @@ -3097,11 +3793,22 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +[[package]] +name = "trace" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ad0c048e114d19d1140662762bfdb10682f3bc806d8be18af846600214dd9af" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "tracing" -version = "0.1.39" +version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee2ef2af84856a50c1d430afce2fdded0a4ec7eda868db86409b4543df0797f9" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ "log", "pin-project-lite", @@ -3117,7 +3824,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.74", ] [[package]] @@ -3127,13 +3834,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "nu-ansi-term", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", + "tracing-serde", ] [[package]] name = "try-lock" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttf-parser" @@ -3153,6 +3899,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -3214,9 +3969,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.13" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" [[package]] name = "unicode-bidi-mirroring" @@ -3244,24 +3999,24 @@ checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unicode-normalization" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" dependencies = [ "tinyvec", ] [[package]] -name = "unicode-script" -version = "0.5.5" +name = "unicode-properties" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d817255e1bed6dfd4ca47258685d14d2bdcfbc64fdc9e3819bd5848057b8ecc" +checksum = "e4259d9d4425d9f0661581b804cb85fe66a4c631cadd8f490d1c13a35d5d9291" [[package]] -name = "unicode-segmentation" -version = "1.10.1" +name = "unicode-script" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +checksum = "ad8d71f5726e5f285a935e9fe8edfd53f0491eb6e9a5774097fdabee7cd8c9cd" [[package]] name = "unicode-vo" @@ -3277,19 +4032,20 @@ checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" [[package]] name = "untrusted" -version = "0.7.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.4.1" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -3325,15 +4081,27 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" -version = "1.4.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" +checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" dependencies = [ "getrandom", ] +[[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" @@ -3342,15 +4110,15 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "walkdir" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", @@ -3371,11 +4139,17 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" -version = "0.2.87" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -3383,24 +4157,24 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.87" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.74", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.37" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03" +checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" dependencies = [ "cfg-if", "js-sys", @@ -3410,9 +4184,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.87" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3420,28 +4194,28 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.87" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.74", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.87" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" [[package]] name = "web-sys" -version = "0.3.64" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" +checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" dependencies = [ "js-sys", "wasm-bindgen", @@ -3449,9 +4223,12 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.25.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc" +checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" +dependencies = [ + "rustls-pki-types", +] [[package]] name = "which" @@ -3465,11 +4242,27 @@ dependencies = [ "rustix", ] +[[package]] +name = "which" +version = "6.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d9c5ed668ee1f17edb3b627225343d210006a90bb1e3745ce1f30b1fb115075" +dependencies = [ + "either", + "home", + "rustix", + "winsafe", +] + [[package]] name = "whoami" -version = "1.4.1" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" +checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9" +dependencies = [ + "redox_syscall 0.4.1", + "wasite", +] [[package]] name = "winapi" @@ -3489,11 +4282,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "winapi", + "windows-sys 0.59.0", ] [[package]] @@ -3504,11 +4297,21 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.48.0" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3517,7 +4320,25 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -3526,13 +4347,29 @@ 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", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -3541,66 +4378,120 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[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_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[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_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" -version = "0.5.17" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3b801d0e0a6726477cc207f60162da452f3a95adb368399bef20a946e06f65c" +checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" dependencies = [ "memchr", ] [[package]] name = "winreg" -version = "0.50.0" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" dependencies = [ "cfg-if", - "windows-sys", + "windows-sys 0.48.0", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "xml-rs" -version = "0.8.19" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcb9cbac069e033553e8bb871be2fbdffcab578eb25bd0f7c508cedc6dcd75a" +checksum = "539a77ee7c0de333dcc6da69b177380a0b81e0dacfa4f7344c465a36871ee601" [[package]] name = "xmlparser" @@ -3615,43 +4506,75 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] -name = "yaml-rust" -version = "0.4.5" +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ - "linked-hash-map", + "proc-macro2", + "quote", + "syn 2.0.74", ] [[package]] name = "zeroize" -version = "1.6.0" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.74", +] [[package]] name = "zstd" -version = "0.13.0" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bffb3309596d527cfcba7dfc6ed6052f1d39dfbd7c867aa2e865e4a449c10110" +checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.0.0" +version = "7.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43747c7422e2924c11144d5229878b98180ef8b06cca4ab5af37afc8a8d8ea3e" +checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.9+zstd.1.5.5" +version = "2.0.13+zstd.1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e16efa8a874a0481a574084d34cc26fdb3b99627480f785888deb6386506656" +checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 89c35e18..9b732288 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,40 +27,60 @@ license = "AGPL-3.0-only" publish = true repository = "https://github.com/torrust/torrust-tracker" rust-version = "1.72" -version = "3.0.0-alpha.2" - +version = "3.0.0-alpha.12" [profile.dev.package.sqlx-macros] opt-level = 3 [dependencies] +anyhow = "1.0.81" argon2 = "0" async-trait = "0" axum = { version = "0", features = ["multipart"] } +axum-server = { version = "0", features = ["tls-rustls"] } binascii = "0" bytes = "1" +camino = { version = "1.1.6", features = ["serde"] } +casbin = "2.2.0" chrono = { version = "0", default-features = false, features = ["clock"] } -config = "0" +clap = { version = "4.5.4", features = ["derive", "env"] } derive_more = "0" email_address = "0" fern = "0" +figment = { version = "0.10", features = ["env", "test", "toml"] } futures = "0" +futures-util = "0.3.30" hex = "0" -hyper = "0" +http = "1.1.0" +http-body = "1.0.0" +hyper = "1" +hyper-util = { version = "0.1.3", features = ["http1", "http2", "tokio"] } indexmap = "2" -jsonwebtoken = "8" +jsonwebtoken = "9" lazy_static = "1.4.0" -lettre = { version = "0", features = ["builder", "smtp-transport", "tokio1", "tokio1-native-tls", "tokio1-rustls-tls"] } +lettre = { version = "0", features = [ + "builder", + "file-transport-envelope", + "smtp-transport", + "tokio1", + "tokio1-native-tls", + "tokio1-rustls-tls", +] } log = "0" +mockall = "0.12.1" pbkdf2 = { version = "0", features = ["simple"] } +pin-project-lite = "0.2" +rand = "0" rand_core = { version = "0", features = ["std"] } regex = "1" reqwest = { version = "0", features = ["json", "multipart"] } -serde = { version = "1", features = ["rc"] } +rustversion = "1.0.14" +serde = { version = "1", features = ["derive", "rc"] } serde_bencode = "0" serde_bytes = "0" serde_derive = "1" serde_json = "1" +serde_with = "3.8.1" sha-1 = "0" sqlx = { version = "0", features = ["migrate", "mysql", "runtime-tokio-native-tls", "sqlite", "time"] } tera = { version = "1", default-features = false } @@ -69,13 +89,17 @@ text-to-png = "0" thiserror = "1" tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } toml = "0" -torrust-index-located-error = { version = "3.0.0-alpha.2", path = "packages/located-error" } -tower-http = { version = "0", features = ["compression-full", "cors"] } +torrust-index-located-error = { version = "3.0.0-alpha.12", path = "packages/located-error" } +tower = { version = "0.4", features = ["timeout"] } +tower-http = { version = "0", features = ["compression-full", "cors", "propagate-header", "request-id", "trace"] } +trace = "0.1.7" +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.18", features = ["json"] } +url = { version = "2.5.0", features = ["serde"] } urlencoding = "2" uuid = { version = "1", features = ["v4"] } [dev-dependencies] -rand = "0" tempfile = "3" uuid = { version = "1", features = ["v4"] } -which = "4" +which = "6" diff --git a/Containerfile b/Containerfile index 378a9b10..b17dd7ea 100644 --- a/Containerfile +++ b/Containerfile @@ -3,13 +3,13 @@ # Torrust Index ## Builder Image -FROM rust:bookworm as chef +FROM rust:bookworm AS chef WORKDIR /tmp RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash RUN cargo binstall --no-confirm cargo-chef cargo-nextest ## Tester Image -FROM rust:slim-bookworm as tester +FROM rust:slim-bookworm AS tester WORKDIR /tmp RUN apt-get update; apt-get install -y curl sqlite3; apt-get autoclean @@ -21,7 +21,7 @@ RUN mkdir -p /app/share/torrust/default/database/; \ sqlite3 /app/share/torrust/default/database/index.sqlite3.db "VACUUM;" ## Su Exe Compile -FROM docker.io/library/gcc:bookworm as gcc +FROM docker.io/library/gcc:bookworm AS gcc COPY ./contrib/dev-tools/su-exec/ /usr/local/src/su-exec/ RUN cc -Wall -Werror -g /usr/local/src/su-exec/su-exec.c -o /usr/local/bin/su-exec; chmod +x /usr/local/bin/su-exec @@ -62,7 +62,7 @@ RUN cargo nextest archive --tests --benches --examples --workspace --all-targets # Extract and Test (debug) -FROM tester as test_debug +FROM tester AS test_debug WORKDIR /test COPY . /test/src/ COPY --from=build_debug \ @@ -76,7 +76,7 @@ RUN mkdir -p /app/bin/; cp -l /test/src/target/debug/torrust-index /app/bin/torr RUN chown -R root:root /app; chmod -R u=rw,go=r,a+X /app; chmod -R a+x /app/bin # Extract and Test (release) -FROM tester as test +FROM tester AS test WORKDIR /test COPY . /test/src COPY --from=build \ @@ -85,33 +85,31 @@ COPY --from=build \ RUN cargo nextest run --workspace-remap /test/src/ --extract-to /test/src/ --no-run --archive-file /test/torrust-index.tar.zst RUN cargo nextest run --workspace-remap /test/src/ --target-dir-remap /test/src/target/ --cargo-metadata /test/src/target/nextest/cargo-metadata.json --binaries-metadata /test/src/target/nextest/binaries-metadata.json -RUN mkdir -p /app/bin/; cp -l /test/src/target/release/torrust-index /app/bin/torrust-index +RUN mkdir -p /app/bin/; \ + cp -l /test/src/target/release/torrust-index /app/bin/torrust-index; \ + cp -l /test/src/target/release/health_check /app/bin/health_check; # RUN mkdir -p /app/lib/; cp -l $(realpath $(ldd /app/bin/torrust-index | grep "libz\.so\.1" | awk '{print $3}')) /app/lib/libz.so.1 RUN chown -R root:root /app; chmod -R u=rw,go=r,a+X /app; chmod -R a+x /app/bin ## Runtime -FROM gcr.io/distroless/cc-debian12:debug as runtime +FROM gcr.io/distroless/cc-debian12:debug AS runtime RUN ["/busybox/cp", "-sp", "/busybox/sh","/busybox/cat","/busybox/ls","/busybox/env", "/bin/"] COPY --from=gcc --chmod=0555 /usr/local/bin/su-exec /bin/su-exec -ARG TORRUST_INDEX_PATH_CONFIG="/etc/torrust/index/index.toml" +ARG TORRUST_INDEX_CONFIG_TOML_PATH="/etc/torrust/index/index.toml" ARG TORRUST_INDEX_DATABASE_DRIVER="sqlite3" ARG USER_ID=1000 -ARG UDP_PORT=6969 -ARG HTTP_PORT=7070 -ARG API_PORT=1212 +ARG API_PORT=3001 +ARG IMPORTER_API_PORT=3002 -ENV TORRUST_INDEX_PATH_CONFIG=${TORRUST_INDEX_PATH_CONFIG} +ENV TORRUST_INDEX_CONFIG_TOML_PATH=${TORRUST_INDEX_CONFIG_TOML_PATH} ENV TORRUST_INDEX_DATABASE_DRIVER=${TORRUST_INDEX_DATABASE_DRIVER} ENV USER_ID=${USER_ID} -ENV UDP_PORT=${UDP_PORT} -ENV HTTP_PORT=${HTTP_PORT} ENV API_PORT=${API_PORT} +ENV IMPORTER_API_PORT=${IMPORTER_API_PORT} ENV TZ=Etc/UTC -EXPOSE ${UDP_PORT}/udp -EXPOSE ${HTTP_PORT}/tcp EXPOSE ${API_PORT}/tcp RUN mkdir -p /var/lib/torrust/index /var/log/torrust/index /etc/torrust/index @@ -126,15 +124,16 @@ ENTRYPOINT ["/usr/local/bin/entry.sh"] ## Torrust-Index (debug) -FROM runtime as debug +FROM runtime AS debug ENV RUNTIME="debug" COPY --from=test_debug /app/ /usr/ RUN env CMD ["sh"] ## Torrust-Index (release) (default) -FROM runtime as release +FROM runtime AS release ENV RUNTIME="release" COPY --from=test /app/ /usr/ -# HEALTHCHECK CMD ["/usr/bin/wget", "--no-verbose", "--tries=1", "--spider", "localhost:${API_PORT}/version"] +HEALTHCHECK --interval=5s --timeout=5s --start-period=3s --retries=3 \ + CMD /usr/bin/health_check http://localhost:${API_PORT}/health_check && /usr/bin/health_check http://localhost:${IMPORTER_API_PORT}/health_check || exit 1 CMD ["/usr/bin/torrust-index"] diff --git a/README.md b/README.md index 50030d01..7bbfba2d 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ # Torrust Index -[![container_wf_b]][container_wf] [![coverage_wf_b]][coverage_wf] [![deployment_wf_b]][deployment_wf] [![testing_wf_b]][testing_wf] +[![container_wf_b]][container_wf] [![coverage_wf_b]][coverage_wf] [![deployment_wf_b]][deployment_wf] [![testing_wf_b]][testing_wf] [![labels_wf_b]][labels_wf] -__Torrust Index__, is a library for [BitTorrent][bittorrent] Files. Written in [Rust Language][rust] with the [axum] web framework. ___This index aims to be respectful to established standards, (both [formal][BEP 00] and [otherwise][torrent_source_felid]).___ +__Torrust Index__ is a library for [BitTorrent][bittorrent] Files. Written in [Rust Language][rust] with the [Axum] web framework. ___This index aims to be respectful to established standards, (both [formal][BEP 00] and [otherwise][torrent_source_felid]).___ > This is a [Torrust][torrust] project and is in active development. It is community supported as well as sponsored by [Nautilus Cyberneering][nautilus]. ## About -The core purpose of a [BitTorrent][bittorrent] Index is to maintain a database that connects torrent files with useful metadata. Allowing a community of users to keep track their torrents in a well organized and informative manner. -The __Torrust Index__ serves a [high-level api][api] for our [Torrust Index GUI][gui] client. It also connects to the [management api][api_tracker] of our [Torrust Tracker][tracker], to provide statistic and whitelisting functionally. +The core purpose of a [BitTorrent][bittorrent] Index is to maintain a database that connects torrent files with useful metadata. Allowing a community of users to keep track of their torrents in a well-organized and informative manner. + +The __Torrust Index__ serves as a [high-level API][API] for our [Torrust Index GUI][gui] client. It also connects to the [management api][api_tracker] of our [Torrust Tracker][tracker], to provide statistics and whitelisting functionally. + +![Torrust Index Architecture](./docs/images/torrust-index-architecture.jpg) ## Key Features @@ -23,30 +26,33 @@ The __Torrust Index__ serves a [high-level api][api] for our [Torrust Index GUI] ## Getting Started ### Upgrading + If you are using `Version 1` of `torrust-tracker-backend`, please view our [upgrading guide][upgrade.md]. ### Container Version The Torrust Index is [deployed to DockerHub][dockerhub], you can run a demo immediately with the following commands: -#### Docker: +#### Docker ```sh docker run -it torrust/index:develop ``` + > Please read our [container guide][containers.md] for more information. -#### Podman: +#### Podman ```sh podman run -it torrust/index:develop ``` + > Please read our [container guide][containers.md] for more information. ### Development Version -- Please assure you have the ___[latest stable (or nightly) version of rust][rust]___. -- Please assure that you computer has enough ram. ___Recommended 16GB.___ +- Please assure you have the ___[latest stable (or nightly) version of Rust][Rust]___. +- Please assure that your computer has enough RAM. ___Recommended 16GB.___ #### Checkout, Test and Run: @@ -65,7 +71,8 @@ cargo test --tests --benches --examples --workspace --all-targets --all-features # Run the index: cargo run ``` -#### Customization: + +#### Customization ```sh # Copy the default configuration into the standard location: @@ -76,29 +83,33 @@ cp ./share/default/config/index.development.sqlite3.toml ./storage/index/etc/ind vim ./storage/index/etc/index.toml # Run the index with the updated configuration: -TORRUST_INDEX_PATH_CONFIG="./storage/index/etc/index.toml" cargo run +TORRUST_INDEX_CONFIG_TOML_PATH="./storage/index/etc/index.toml" cargo run ``` _Optionally, you may choose to supply the entire configuration as an environmental variable:_ ```sh # Use a configuration supplied on an environmental variable: -TORRUST_INDEX_CONFIG=$(cat "./storage/index/etc/index.toml") cargo run +TORRUST_INDEX_CONFIG_TOML=$(cat "./storage/index/etc/index.toml") cargo run ``` -_For deployment you __should__ override the `tracker_api_token` by using an environmental variable:_ +_For deployment, you __should__ override: + +- The `tracker_api_token` and the `index_auth_secret_key` by using environmental variables:_ ```sh # Please use the secret that you generated for the torrust-tracker configuration. # Override secret in configuration using an environmental variable -TORRUST_INDEX_CONFIG=$(cat "./storage/index/etc/index.toml") \ - TORRUST_INDEX_TRACKER_API_TOKEN=$(cat "./storage/tracker/lib/tracker_api_admin_token.secret") \ +TORRUST_INDEX_CONFIG_TOML=$(cat "./storage/index/etc/index.toml") \ + TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN=$(cat "./storage/tracker/lib/tracker_api_admin_token.secret") \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SECRET_KEY="MaxVerstappenWC2021" \ cargo run ``` > Please view our [crate documentation][docs] for more detailed instructions. ### Services + The following services are provided by the default configuration: - API @@ -109,14 +120,15 @@ The following services are provided by the default configuration: - [API (Version 1)][api] ## Contributing + We are happy to support and welcome new people to our project. Please consider our [contributor guide][guide.md].
-This is an open-source community supported project. We welcome contributions from the community! +This is an open-source community-supported project. We welcome contributions from the community! __How can you contribute?__ - Bug reports and feature requests. - Code contributions. You can start by looking at the issues labeled "[good first issues]". -- Documentation improvements. Check the [documentation][docs] and [API documentation][api] for typos, errors, or missing information. +- Documentation improvements. Check the [documentation][docs] and [API documentation][API] for typos, errors, or missing information. - Participation in the community. You can help by answering questions in the [discussions]. ## License @@ -136,11 +148,13 @@ Some files include explicit copyright notices and/or license notices. For prosperity, versions of Torrust Tracker that are older than five years are automatically granted the [MIT-0][MIT_0] license in addition to the existing [AGPL-3.0-only][AGPL_3_0] license. ## Contributor Agreement + The copyright of the Torrust Tracker is retained by the respective authors. -**Contributors agree:** -- That all their contributions be granted a license(s) **compatible** with the [Torrust Trackers License](#License). -- That all contributors signal **clearly** and **explicitly** any other compilable licenses if they are not: *[AGPL-3.0-only with the legacy MIT-0 exception](#License)*. +**Contributors agree that:** + +- All their contributions be granted a license(s) __compatible__ with the [Torrust Trackers License](#license). +- All contributors signal __clearly__ and __explicitly__ any other compilable licenses if they are not: __[AGPL-3.0-only with the legacy MIT-0 exception](#license)__. **The Torrust-Tracker project has no copyright assignment agreement.** @@ -150,8 +164,6 @@ _We kindly ask you to take time and consider The Torrust Project [Contributor Ag This project was a joint effort by [Nautilus Cyberneering GmbH][nautilus] and [Dutch Bits]. - - [container_wf]: ../../actions/workflows/container.yaml [container_wf_b]: ../../actions/workflows/container.yaml/badge.svg [coverage_wf]: ../../actions/workflows/coverage.yaml @@ -160,11 +172,12 @@ This project was a joint effort by [Nautilus Cyberneering GmbH][nautilus] and [D [deployment_wf_b]: ../../actions/workflows/deployment.yaml/badge.svg [testing_wf]: ../../actions/workflows/testing.yaml [testing_wf_b]: ../../actions/workflows/testing.yaml/badge.svg +[labels_wf]: ../../actions/workflows/labels.yaml +[labels_wf_b]: ../../actions/workflows/labels.yaml/badge.svg [bittorrent]: http://bittorrent.org/ [rust]: https://www.rust-lang.org/ [axum]: https://github.com/tokio-rs/axum -[newtrackon]: https://newtrackon.com/ [coverage]: https://app.codecov.io/gh/torrust/torrust-index [torrust]: https://torrust.com/ @@ -176,12 +189,6 @@ This project was a joint effort by [Nautilus Cyberneering GmbH][nautilus] and [D [torrent_source_felid]: https://github.com/qbittorrent/qBittorrent/discussions/19406 [BEP 00]: https://www.bittorrent.org/beps/bep_0000.html -[BEP 03]: https://www.bittorrent.org/beps/bep_0003.html -[BEP 07]: https://www.bittorrent.org/beps/bep_0007.html -[BEP 15]: https://www.bittorrent.org/beps/bep_0015.html -[BEP 23]: https://www.bittorrent.org/beps/bep_0023.html -[BEP 27]: https://www.bittorrent.org/beps/bep_0027.html -[BEP 48]: https://www.bittorrent.org/beps/bep_0048.html [containers.md]: ./docs/containers.md [upgrade.md]: ./upgrades/from_v1_0_0_to_v2_0_0/README.md @@ -202,6 +209,3 @@ This project was a joint effort by [Nautilus Cyberneering GmbH][nautilus] and [D [nautilus]: https://github.com/orgs/Nautilus-Cyberneering/ [Dutch Bits]: https://dutchbits.nl -[Naim A.]: https://github.com/naim94a/udpt -[greatest-ape]: https://github.com/greatest-ape/aquatic -[Power2All]: https://github.com/power2all diff --git a/compose.yaml b/compose.yaml index 613ed0b0..3f63dde5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,20 +2,26 @@ name: torrust services: index: - image: torrust-index:release + build: + context: . + dockerfile: ./Containerfile + target: release tty: true environment: - - TORRUST_INDEX_CONFIG=${TORRUST_TRACKER_CONFIG} - - TORRUST_INDEX_DATABASE_DRIVER=${TORRUST_TRACKER_DATABASE_DRIVER:-sqlite3} - - TORRUST_INDEX_TRACKER_API_TOKEN=${TORRUST_INDEX_TRACKER_API_TOKEN:-MyAccessToken} + - USER_ID=${USER_ID} + - TORRUST_INDEX_CONFIG_TOML=${TORRUST_INDEX_CONFIG_TOML} + - TORRUST_INDEX_DATABASE=${TORRUST_INDEX_DATABASE:-e2e_testing_sqlite3} + - TORRUST_INDEX_DATABASE_DRIVER=${TORRUST_INDEX_DATABASE_DRIVER:-sqlite3} + - TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN=${TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN:-MyAccessToken} + - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__USER_CLAIM_TOKEN_PEPPER=${TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__USER_CLAIM_TOKEN_PEPPER:-MaxVerstappenWC2021} networks: - server_side ports: - 3001:3001 volumes: - - ./storage/tracker/lib:/var/lib/torrust/index:Z - - ./storage/tracker/log:/var/log/torrust/index:Z - - ./storage/tracker/etc:/etc/torrust/index:Z + - ./storage/index/lib:/var/lib/torrust/index:Z + - ./storage/index/log:/var/log/torrust/index:Z + - ./storage/index/etc:/etc/torrust/index:Z depends_on: - tracker - mailcatcher @@ -25,9 +31,11 @@ services: image: torrust/tracker:develop tty: true environment: - - TORRUST_TRACKER_CONFIG=${TORRUST_TRACKER_CONFIG} - - TORRUST_TRACKER_DATABASE_DRIVER=${TORRUST_TRACKER_DATABASE_DRIVER:-sqlite3} - - TORRUST_TRACKER_API_ADMIN_TOKEN=${TORRUST_TRACKER_API_ADMIN_TOKEN:-MyAccessToken} + - USER_ID=${USER_ID} + - TORRUST_TRACKER_CONFIG_TOML=${TORRUST_TRACKER_CONFIG_TOML} + - TORRUST_TRACKER_DATABASE=${TORRUST_TRACKER_DATABASE:-e2e_testing_sqlite3} + - TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER=${TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER:-Sqlite3} + - TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN=${TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN:-MyAccessToken} networks: - server_side ports: @@ -64,7 +72,7 @@ services: environment: - MYSQL_ROOT_HOST=% - MYSQL_ROOT_PASSWORD=root_secret_password - - MYSQL_DATABASE=${TORRUST_IDX_BACK_MYSQL_DATABASE:-torrust_index_e2e_testing} + - MYSQL_DATABASE=${TORRUST_INDEX_MYSQL_DATABASE:-torrust_index_e2e_testing} - MYSQL_USER=db_user - MYSQL_PASSWORD=db_user_secret_password networks: diff --git a/contrib/dev-tools/container/build.sh b/contrib/dev-tools/container/build.sh index 21be00a3..c6c28635 100755 --- a/contrib/dev-tools/container/build.sh +++ b/contrib/dev-tools/container/build.sh @@ -1,13 +1,10 @@ #!/bin/bash -TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} -TORRUST_IDX_BACK_RUN_AS_USER=${TORRUST_IDX_BACK_RUN_AS_USER:-appuser} +USER_ID=${USER_ID:-1000} echo "Building docker image ..." -echo "TORRUST_IDX_BACK_USER_UID: $TORRUST_IDX_BACK_USER_UID" -echo "TORRUST_IDX_BACK_RUN_AS_USER: $TORRUST_IDX_BACK_RUN_AS_USER" +echo "USER_ID: $USER_ID" docker build \ - --build-arg UID="$TORRUST_IDX_BACK_USER_UID" \ - --build-arg RUN_AS_USER="$TORRUST_IDX_BACK_RUN_AS_USER" \ + --build-arg UID="$USER_ID" \ -t torrust-index . diff --git a/contrib/dev-tools/container/e2e/mysql/e2e-env-down.sh b/contrib/dev-tools/container/e2e/mysql/e2e-env-down.sh new file mode 100755 index 00000000..00a4728e --- /dev/null +++ b/contrib/dev-tools/container/e2e/mysql/e2e-env-down.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.mysql.toml) \ +TORRUST_TRACKER_CONFIG_TOML=$(cat ./share/default/config/tracker.public.e2e.container.sqlite3.toml) \ + docker compose down diff --git a/contrib/dev-tools/container/e2e/mysql/e2e-env-reset.sh b/contrib/dev-tools/container/e2e/mysql/e2e-env-reset.sh deleted file mode 100755 index afe138ac..00000000 --- a/contrib/dev-tools/container/e2e/mysql/e2e-env-reset.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash - -# Delete the databases and recreate them. - -docker compose down - -# Index - -# Database credentials -MYSQL_USER="root" -MYSQL_PASSWORD="root_secret_password" -MYSQL_HOST="localhost" -MYSQL_DATABASE="torrust_index_e2e_testing" - -# Create the MySQL database for the index. Assumes MySQL client is installed. -echo "Creating MySQL database $MYSQL_DATABASE for E2E testing ..." -mysql -h $MYSQL_HOST -u $MYSQL_USER -p$MYSQL_PASSWORD -e "DROP DATABASE IF EXISTS $MYSQL_DATABASE; CREATE DATABASE $MYSQL_DATABASE;" - -# Tracker - -# Delete tracker database -rm -f ./storage/tracker/lib/database/torrust_tracker_e2e_testing.db - -# Generate storage directory if it does not exist -mkdir -p "./storage/tracker/lib/database" - -# Generate the sqlite database for the tracker if it does not exist -if ! [ -f "./storage/tracker/lib/database/torrust_tracker_e2e_testing.db" ]; then - sqlite3 ./storage/tracker/lib/database/torrust_tracker_e2e_testing.db "VACUUM;" -fi - -./docker/bin/e2e/mysql/e2e-env-up.sh diff --git a/contrib/dev-tools/container/e2e/mysql/e2e-env-restart.sh b/contrib/dev-tools/container/e2e/mysql/e2e-env-restart.sh deleted file mode 100755 index 92088547..00000000 --- a/contrib/dev-tools/container/e2e/mysql/e2e-env-restart.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -docker compose down -./docker/bin/e2e/mysql/e2e-env-up.sh diff --git a/contrib/dev-tools/container/e2e/mysql/e2e-env-up.sh b/contrib/dev-tools/container/e2e/mysql/e2e-env-up.sh index 9b83c782..328ee71a 100755 --- a/contrib/dev-tools/container/e2e/mysql/e2e-env-up.sh +++ b/contrib/dev-tools/container/e2e/mysql/e2e-env-up.sh @@ -1,13 +1,16 @@ #!/bin/bash -TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ +TORRUST_INDEX_CONFIG=$(cat ./share/default/config/index.public.e2e.container.mysql.toml) \ docker compose build -TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ - TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.mysql.local.toml) \ - TORRUST_IDX_BACK_MYSQL_DATABASE="torrust_index_e2e_testing" \ - TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ - TORRUST_TRACKER_DATABASE_DRIVER=${TORRUST_TRACKER_DATABASE_DRIVER:-mysql} \ - TORRUST_TRACKER_API_ADMIN_TOKEN=${TORRUST_TRACKER_API_ADMIN_TOKEN:-MyAccessToken} \ - docker compose up -d - +USER_ID=${USER_ID:-1000} \ + TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.mysql.toml) \ + TORRUST_INDEX_DATABASE="torrust_index_e2e_testing" \ + TORRUST_INDEX_DATABASE_DRIVER="mysql" \ + TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ + TORRUST_INDEX_MYSQL_DATABASE="torrust_index_e2e_testing" \ + TORRUST_TRACKER_CONFIG_TOML=$(cat ./share/default/config/tracker.public.e2e.container.sqlite3.toml) \ + TORRUST_TRACKER_DATABASE="e2e_testing_sqlite3" \ + TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER="sqlite3" \ + TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN="MyAccessToken" \ + docker compose up --detach --pull always --remove-orphans diff --git a/contrib/dev-tools/container/e2e/mysql/install.sh b/contrib/dev-tools/container/e2e/mysql/install.sh new file mode 100755 index 00000000..5cbb5a09 --- /dev/null +++ b/contrib/dev-tools/container/e2e/mysql/install.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# This script is only intended to be used for E2E testing environment. + +## Index + +# Database credentials +MYSQL_USER="root" +MYSQL_PASSWORD="root_secret_password" +MYSQL_HOST="127.0.0.1" +MYSQL_DATABASE=$TORRUST_INDEX_DATABASE + +# Create the MySQL database for the index. Assumes MySQL client is installed. +# The docker compose configuration already creates the database the first time +# the container is created. +echo "Creating MySQL database '$MYSQL_DATABASE' for for E2E testing ..." +MYSQL_PWD=$MYSQL_PASSWORD mysql -h $MYSQL_HOST -u $MYSQL_USER -e "CREATE DATABASE IF NOT EXISTS $MYSQL_DATABASE;" + +## Tracker + +# Generate the Tracker sqlite database directory and file if it does not exist +mkdir -p ./storage/tracker/lib/database + +if ! [ -f "./storage/tracker/lib/database/${TORRUST_TRACKER_DATABASE}.db" ]; then + echo "Creating tracker database '${TORRUST_TRACKER_DATABASE}.db'" + sqlite3 "./storage/tracker/lib/database/${TORRUST_TRACKER_DATABASE}.db" "VACUUM;" +fi diff --git a/contrib/dev-tools/container/e2e/mysql/run-e2e-tests.sh b/contrib/dev-tools/container/e2e/mysql/run-e2e-tests.sh new file mode 100755 index 00000000..046862d5 --- /dev/null +++ b/contrib/dev-tools/container/e2e/mysql/run-e2e-tests.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +CURRENT_USER_NAME=$(whoami) +CURRENT_USER_ID=$(id -u) +echo "User name: $CURRENT_USER_NAME" +echo "User id: $CURRENT_USER_ID" + +USER_ID=$CURRENT_USER_ID +TORRUST_TRACKER_USER_UID=$CURRENT_USER_ID +export USER_ID +export TORRUST_TRACKER_USER_UID + +export TORRUST_INDEX_DATABASE="torrust_index_e2e_testing" +export TORRUST_TRACKER_DATABASE="e2e_testing_sqlite3" + +# Install tool to create torrent files. +# It's needed by some tests to generate and parse test torrent files. +cargo install imdl || exit 1 + +# Install app (no docker) that will run the test suite against the E2E testing +# environment (in docker). +cp .env.local .env || exit 1 + +# TEST USING MYSQL +echo "Running E2E tests using MySQL ..." + +# Start E2E testing environment +./contrib/dev-tools/container/e2e/mysql/e2e-env-up.sh || exit 1 + +# Wait for conatiners to be healthy +./contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh torrust-mysql-1 10 3 || exit 1 +./contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh torrust-tracker-1 10 3 || exit 1 +./contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh torrust-index-1 10 3 || exit 1 + +# Just to make sure that everything is up and running +docker ps + +# Install MySQL database for the index +./contrib/dev-tools/container/e2e/mysql/install.sh || exit 1 + +# Run E2E tests with shared app instance +TORRUST_INDEX_E2E_SHARED=true \ + TORRUST_INDEX_CONFIG_TOML_PATH="./share/default/config/index.public.e2e.container.mysql.toml" \ + TORRUST_INDEX_E2E_DB_CONNECT_URL="mysql://root:root_secret_password@127.0.0.1:3306/torrust_index_e2e_testing" \ + cargo test || + { + ./contrib/dev-tools/container/e2e/mysql/e2e-env-down.sh + exit 1 + } + +# Stop E2E testing environment +./contrib/dev-tools/container/e2e/mysql/e2e-env-down.sh || exit 1 diff --git a/contrib/dev-tools/container/e2e/run-e2e-tests.sh b/contrib/dev-tools/container/e2e/run-e2e-tests.sh deleted file mode 100755 index cca2640a..00000000 --- a/contrib/dev-tools/container/e2e/run-e2e-tests.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/bin/bash - -CURRENT_USER_NAME=$(whoami) -CURRENT_USER_ID=$(id -u) -echo "User name: $CURRENT_USER_NAME" -echo "User id: $CURRENT_USER_ID" - -TORRUST_IDX_BACK_USER_UID=$CURRENT_USER_ID -TORRUST_TRACKER_USER_UID=$CURRENT_USER_ID -export TORRUST_IDX_BACK_USER_UID -export TORRUST_TRACKER_USER_UID - -wait_for_container_to_be_healthy() { - local container_name="$1" - local max_retries="$2" - local retry_interval="$3" - local retry_count=0 - - while [ $retry_count -lt "$max_retries" ]; do - container_health="$(docker inspect --format='{{json .State.Health}}' "$container_name")" - if [ "$container_health" != "{}" ]; then - container_status="$(echo "$container_health" | jq -r '.Status')" - if [ "$container_status" == "healthy" ]; then - echo "Container $container_name is healthy" - return 0 - fi - fi - - retry_count=$((retry_count + 1)) - echo "Waiting for container $container_name to become healthy (attempt $retry_count of $max_retries)..." - sleep "$retry_interval" - done - - echo "Timeout reached, container $container_name is not healthy" - return 1 -} - -# Install tool to create torrent files. -# It's needed by some tests to generate and parse test torrent files. -cargo install imdl || exit 1 - -# Install app (no docker) that will run the test suite against the E2E testing -# environment (in docker). -cp .env.local .env || exit 1 -./bin/install.sh || exit 1 - -# TEST USING SQLITE -echo "Running E2E tests using SQLite ..." - -# Start E2E testing environment -./docker/bin/e2e/sqlite/e2e-env-up.sh || exit 1 - -wait_for_container_to_be_healthy torrust-mysql-1 10 3 -# todo: implement healthchecks for tracker and index and wait until they are healthy -#wait_for_container torrust-tracker-1 10 3 -#wait_for_container torrust-idx-back-1 10 3 -sleep 20s - -# Just to make sure that everything is up and running -docker ps - -# Run E2E tests with shared app instance -TORRUST_IDX_BACK_E2E_SHARED=true TORRUST_IDX_BACK_E2E_CONFIG_PATH="./config-idx-back.sqlite.local.toml" cargo test || exit 1 - -# Stop E2E testing environment -docker compose down - -# TEST USING MYSQL -echo "Running E2E tests using MySQL ..." - -# Start E2E testing environment -./docker/bin/e2e/mysql/e2e-env-up.sh || exit 1 - -wait_for_container_to_be_healthy torrust-mysql-1 10 3 -# todo: implement healthchecks for tracker and index and wait until they are healthy -#wait_for_container torrust-tracker-1 10 3 -#wait_for_container torrust-idx-back-1 10 3 -sleep 20s - -# Just to make sure that everything is up and running -docker ps - -# Database credentials -MYSQL_USER="root" -MYSQL_PASSWORD="root_secret_password" -MYSQL_HOST="localhost" -MYSQL_DATABASE="torrust_index_e2e_testing" - -# Create the MySQL database for the index. Assumes MySQL client is installed. -echo "Creating MySQL database $MYSQL_DATABASE for for E2E testing ..." -mysql -h $MYSQL_HOST -u $MYSQL_USER -p$MYSQL_PASSWORD -e "CREATE DATABASE IF NOT EXISTS $MYSQL_DATABASE;" - -# Run E2E tests with shared app instance -TORRUST_IDX_BACK_E2E_SHARED=true TORRUST_IDX_BACK_E2E_CONFIG_PATH="./config-idx-back.mysql.local.toml" cargo test || exit 1 - -# Stop E2E testing environment -docker compose down diff --git a/contrib/dev-tools/container/e2e/sqlite/e2e-env-reset.sh b/contrib/dev-tools/container/e2e/sqlite/e2e-env-reset.sh deleted file mode 100755 index f0ff3a2d..00000000 --- a/contrib/dev-tools/container/e2e/sqlite/e2e-env-reset.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash - -# Delete the databases and recreate them. - -docker compose down - -rm -f ./storage/database/torrust_index_e2e_testing.db -rm -f ./storage/tracker/lib/database/torrust_tracker_e2e_testing.db - -# Generate storage directory if it does not exist -mkdir -p "./storage/database" - -# Generate the sqlite database for the index if it does not exist -if ! [ -f "./storage/database/torrust_index_e2e_testing.db" ]; then - sqlite3 ./storage/database/torrust_index_e2e_testing.db "VACUUM;" -fi - -# Generate the sqlite database for the tracker if it does not exist -if ! [ -f "./storage/tracker/lib/database/torrust_tracker_e2e_testing.db" ]; then - sqlite3 ./storage/tracker/lib/database/torrust_tracker_e2e_testing.db "VACUUM;" -fi - -./docker/bin/e2e/sqlite/e2e-env-up.sh diff --git a/contrib/dev-tools/container/e2e/sqlite/e2e-env-restart.sh b/contrib/dev-tools/container/e2e/sqlite/e2e-env-restart.sh deleted file mode 100755 index 768f50cb..00000000 --- a/contrib/dev-tools/container/e2e/sqlite/e2e-env-restart.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -docker compose down -./docker/bin/e2e/sqlite/e2e-env-up.sh diff --git a/contrib/dev-tools/container/e2e/sqlite/e2e-env-up.sh b/contrib/dev-tools/container/e2e/sqlite/e2e-env-up.sh deleted file mode 100755 index b55cd564..00000000 --- a/contrib/dev-tools/container/e2e/sqlite/e2e-env-up.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ - docker compose build - -TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ - TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.sqlite.local.toml) \ - TORRUST_IDX_BACK_MYSQL_DATABASE="torrust_index_e2e_testing" \ - TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ - TORRUST_TRACKER_DATABASE_DRIVER=${TORRUST_TRACKER_DATABASE_DRIVER:-sqlite3} \ - TORRUST_TRACKER_API_ADMIN_TOKEN=${TORRUST_TRACKER_API_ADMIN_TOKEN:-MyAccessToken} \ - docker compose up -d - diff --git a/contrib/dev-tools/container/e2e/sqlite/install.sh b/contrib/dev-tools/container/e2e/sqlite/install.sh new file mode 100755 index 00000000..24bb5cd7 --- /dev/null +++ b/contrib/dev-tools/container/e2e/sqlite/install.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# This script is only intended to be used for E2E testing environment. + +## Index + +# Generate the Index sqlite database directory and file if it does not exist +mkdir -p ./storage/index/lib/database + +if ! [ -f "./storage/index/lib/database/${TORRUST_INDEX_DATABASE}.db" ]; then + echo "Creating index database '${TORRUST_INDEX_DATABASE}.db'" + sqlite3 "./storage/index/lib/database/${TORRUST_INDEX_DATABASE}.db" "VACUUM;" +fi + +## Tracker + +# Generate the Tracker sqlite database directory and file if it does not exist +mkdir -p ./storage/tracker/lib/database + +if ! [ -f "./storage/tracker/lib/database/${TORRUST_TRACKER_DATABASE}.db" ]; then + echo "Creating tracker database '${TORRUST_TRACKER_DATABASE}.db'" + sqlite3 "./storage/tracker/lib/database/${TORRUST_TRACKER_DATABASE}.db" "VACUUM;" +fi diff --git a/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-down.sh b/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-down.sh new file mode 100755 index 00000000..6be1fc22 --- /dev/null +++ b/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-down.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.private.e2e.container.sqlite3.toml) \ +TORRUST_TRACKER_CONFIG_TOML=$(cat ./share/default/config/tracker.private.e2e.container.sqlite3.toml) \ + docker compose down diff --git a/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-up.sh b/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-up.sh new file mode 100755 index 00000000..f5151dc8 --- /dev/null +++ b/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-up.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.private.e2e.container.sqlite3.toml) \ + docker compose build + +USER_ID=${USER_ID:-1000} \ + TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.private.e2e.container.sqlite3.toml) \ + TORRUST_INDEX_DATABASE="e2e_testing_sqlite3" \ + TORRUST_INDEX_DATABASE_DRIVER="sqlite3" \ + TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__USER_CLAIM_TOKEN_PEPPER="MaxVerstappenWC2021" \ + TORRUST_TRACKER_CONFIG_TOML=$(cat ./share/default/config/tracker.private.e2e.container.sqlite3.toml) \ + TORRUST_TRACKER_DATABASE="e2e_testing_sqlite3" \ + TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER="sqlite3" \ + TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN="MyAccessToken" \ + docker compose up --detach --pull always --remove-orphans diff --git a/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-down.sh b/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-down.sh new file mode 100755 index 00000000..ad55685f --- /dev/null +++ b/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-down.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.sqlite3.toml) \ +TORRUST_TRACKER_CONFIG_TOML=$(cat ./share/default/config/tracker.public.e2e.container.sqlite3.toml) \ + docker compose down diff --git a/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-up.sh b/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-up.sh new file mode 100755 index 00000000..ebaae531 --- /dev/null +++ b/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-up.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.sqlite3.toml) \ + docker compose build + +USER_ID=${USER_ID:-1000} \ + TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.sqlite3.toml) \ + TORRUST_INDEX_DATABASE="e2e_testing_sqlite3" \ + TORRUST_INDEX_DATABASE_DRIVER="sqlite3" \ + TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__USER_CLAIM_TOKEN_PEPPER="MaxVerstappenWC2021" \ + TORRUST_TRACKER_CONFIG_TOML=$(cat ./share/default/config/tracker.public.e2e.container.sqlite3.toml) \ + TORRUST_TRACKER_DATABASE="e2e_testing_sqlite3" \ + TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER="sqlite3" \ + TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN="MyAccessToken" \ + docker compose up --detach --pull always --remove-orphans diff --git a/contrib/dev-tools/container/e2e/sqlite/run-e2e-tests.sh b/contrib/dev-tools/container/e2e/sqlite/run-e2e-tests.sh new file mode 100755 index 00000000..024e7ae3 --- /dev/null +++ b/contrib/dev-tools/container/e2e/sqlite/run-e2e-tests.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +CURRENT_USER_NAME=$(whoami) +CURRENT_USER_ID=$(id -u) +echo "User name: $CURRENT_USER_NAME" +echo "User id: $CURRENT_USER_ID" + +USER_ID=$CURRENT_USER_ID +TORRUST_TRACKER_USER_UID=$CURRENT_USER_ID +export USER_ID +export TORRUST_TRACKER_USER_UID + +export TORRUST_INDEX_DATABASE="e2e_testing_sqlite3" +export TORRUST_TRACKER_DATABASE="e2e_testing_sqlite3" + +# Install tool to create torrent files. +# It's needed by some tests to generate and parse test torrent files. +cargo install imdl || exit 1 + +# Install app (no docker) that will run the test suite against the E2E testing +# environment (in docker). +cp .env.local .env || exit 1 +./contrib/dev-tools/container/e2e/sqlite/install.sh || exit 1 + +# TEST USING SQLITE +echo "Running E2E tests using SQLite ..." + +# TEST USING A PUBLIC TRACKER +echo "Running E2E tests with a public tracker ..." + +# Start E2E testing environment +./contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-up.sh || exit 1 + +# Wait for conatiners to be healthy +./contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh torrust-mysql-1 10 3 || exit 1 +./contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh torrust-tracker-1 10 3 || exit 1 +./contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh torrust-index-1 10 3 || exit 1 + +# Just to make sure that everything is up and running +docker ps + +# Run E2E tests with shared app instance +TORRUST_INDEX_E2E_SHARED=true \ + TORRUST_INDEX_CONFIG_TOML_PATH="./share/default/config/index.public.e2e.container.sqlite3.toml" \ + TORRUST_INDEX_E2E_DB_CONNECT_URL="sqlite://./storage/index/lib/database/e2e_testing_sqlite3.db?mode=rwc" \ + cargo test || + { + ./contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-down.sh + exit 1 + } + +# Stop E2E testing environment +./contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-down.sh || exit 1 + +# TEST USING A PRIVATE TRACKER +echo "Running E2E tests with a private tracker ..." + +# Start E2E testing environment +./contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-up.sh || exit 1 + +# Wait for conatiners to be healthy +./contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh torrust-mysql-1 10 3 || exit 1 +./contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh torrust-tracker-1 10 3 || exit 1 +./contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh torrust-index-1 10 3 || exit 1 + +# Just to make sure that everything is up and running +docker ps + +# Run E2E tests with shared app instance +TORRUST_INDEX_E2E_SHARED=true \ + TORRUST_INDEX_CONFIG_TOML_PATH="./share/default/config/index.private.e2e.container.sqlite3.toml" \ + TORRUST_INDEX_E2E_DB_CONNECT_URL="sqlite://./storage/index/lib/database/e2e_testing_sqlite3.db?mode=rwc" \ + cargo test || + { + ./contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-down.sh + exit 1 + } + +# Stop E2E testing environment +./contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-down.sh || exit 1 diff --git a/contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh b/contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh new file mode 100755 index 00000000..9e67a434 --- /dev/null +++ b/contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +wait_for_container_to_be_healthy() { + local container_name="$1" + local max_retries="$2" + local retry_interval="$3" + local retry_count=0 + + while [ $retry_count -lt "$max_retries" ]; do + container_health="$(docker inspect --format='{{json .State.Health}}' "$container_name")" + if [ "$container_health" != "{}" ]; then + container_status="$(echo "$container_health" | jq -r '.Status')" + if [ "$container_status" == "healthy" ]; then + echo "Container $container_name is healthy" + return 0 + fi + fi + + retry_count=$((retry_count + 1)) + echo "Waiting for container $container_name to become healthy (attempt $retry_count of $max_retries)..." + sleep "$retry_interval" + done + + echo "Timeout reached, container $container_name is not healthy" + return 1 +} \ No newline at end of file diff --git a/contrib/dev-tools/container/install.sh b/contrib/dev-tools/container/install.sh deleted file mode 100755 index a5896937..00000000 --- a/contrib/dev-tools/container/install.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -./docker/bin/build.sh -./bin/install.sh diff --git a/contrib/dev-tools/container/run.sh b/contrib/dev-tools/container/run.sh index 19df5d3a..64f7c543 100755 --- a/contrib/dev-tools/container/run.sh +++ b/contrib/dev-tools/container/run.sh @@ -1,11 +1,11 @@ #!/bin/bash -TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} -TORRUST_IDX_BACK_CONFIG=$(cat config.toml) +USER_ID=${USER_ID:-1000} +TORRUST_INDEX_CONFIG_TOML=$(cat config.toml) docker run -it \ - --user="$TORRUST_IDX_BACK_USER_UID" \ + --user="$USER_ID" \ --publish 3001:3001/tcp \ - --env TORRUST_IDX_BACK_CONFIG="$TORRUST_IDX_BACK_CONFIG" \ + --env TORRUST_INDEX_CONFIG_TOML="$TORRUST_INDEX_CONFIG_TOML" \ --volume "$(pwd)/storage":"/app/storage" \ torrust-index diff --git a/contrib/dev-tools/init/install-local.sh b/contrib/dev-tools/init/install-local.sh old mode 100755 new mode 100644 index 3396c047..caeb27ef --- a/contrib/dev-tools/init/install-local.sh +++ b/contrib/dev-tools/init/install-local.sh @@ -1,12 +1,11 @@ #!/bin/bash -# This script is only intended to be used for local development or testing environments. +# This script is only intended to be used for development environment. -# Generate storage directory if it does not exist +# Generate the Index sqlite database directory and file if it does not exist mkdir -p ./storage/index/lib/database -# Generate the sqlite database if it does not exist if ! [ -f "./storage/index/lib/database/sqlite3.db" ]; then - # todo: it should get the path from tracker.toml and only do it when we use sqlite - sqlite3 ./storage/index/lib/database/sqlite3.db "VACUUM;" + echo "Creating index database 'sqlite3.db'" + sqlite3 "./storage/index/lib/database/sqlite3.db" "VACUUM;" fi diff --git a/docs/containers.md b/docs/containers.md index 576f6149..5039d363 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -1,10 +1,10 @@ # Containers (Docker or Podman) ## Demo environment + It is simple to setup the index with the default configuration and run it using the pre-built public docker image: - With Docker: ```sh @@ -17,11 +17,12 @@ or with Podman: podman run -it torrust/index:latest ``` - ## Requirements + - Tested with recent versions of Docker or Podman. ## Volumes + The [Containerfile](../Containerfile) (i.e. the Dockerfile) Defines Three Volumes: ```Dockerfile @@ -38,7 +39,8 @@ When instancing the container image with the `docker run` or `podman run` comman > NOTE: You can adjust this mapping for your preference, however this mapping is the default in our guides and scripts. -### Pre-Create Host-Mapped Folders: +### Pre-Create Host-Mapped Folders + Please run this command where you wish to run the container: ```sh @@ -46,11 +48,13 @@ mkdir -p ./storage/index/lib/ ./storage/index/log/ ./storage/index/etc/ ``` ### Matching Ownership ID's of Host Storage and Container Volumes + It is important that the `torrust` user has the same uid `$(id -u)` as the host mapped folders. In our [entry script](../share/container/entry_script_sh), installed to `/usr/local/bin/entry.sh` inside the container, switches to the `torrust` user created based upon the `USER_UID` environmental variable. When running the container, you may use the `--env USER_ID="$(id -u)"` argument that gets the current user-id and passes to the container. ### Mapped Tree Structure + Using the standard mapping defined above produces this following mapped tree: ```s @@ -78,6 +82,7 @@ git clone https://github.com/torrust/torrust-index.git; cd torrust-index ``` ### (Docker) Setup Context + Before starting, if you are using docker, it is helpful to reset the context to the default: ```sh @@ -107,6 +112,7 @@ podman build --target debug --tag torrust-index:debug --file Containerfile . ## Running the Container ### Basic Run + No arguments are needed for simply checking the container image works: #### (Docker) Run Basic @@ -118,6 +124,7 @@ docker run -it torrust-index:release # Debug Mode docker run -it torrust-index:debug ``` + #### (Podman) Run Basic ```sh @@ -129,24 +136,27 @@ podman run -it torrust-index:debug ``` ### Arguments + The arguments need to be placed before the image tag. i.e. `run [arguments] torrust-index:release` #### Environmental Variables: + Environmental variables are loaded through the `--env`, in the format `--env VAR="value"`. The following environmental variables can be set: -- `TORRUST_INDEX_PATH_CONFIG` - The in-container path to the index configuration file, (default: `"/etc/torrust/index/index.toml"`). -- `TORRUST_INDEX_TRACKER_API_TOKEN` - Override of the admin token. If set, this value overrides any value set in the config. +- `TORRUST_INDEX_CONFIG_TOML_PATH` - The in-container path to the index configuration file, (default: `"/etc/torrust/index/index.toml"`). +- `TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN` - Override of the admin token. If set, this value overrides any value set in the config. +- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SECRET_KEY` - Override of the auth secret key. If set, this value overrides any value set in the config. - `TORRUST_INDEX_DATABASE_DRIVER` - The database type used for the container, (options: `sqlite3`, `mysql`, default `sqlite3`). Please Note: This dose not override the database configuration within the `.toml` config file. -- `TORRUST_INDEX_CONFIG` - Load config from this environmental variable instead from a file, (i.e: `TORRUST_INDEX_CONFIG=$(cat index-index.toml)`). +- `TORRUST_INDEX_CONFIG_TOML` - Load config from this environmental variable instead from a file, (i.e: `TORRUST_INDEX_CONFIG_TOML=$(cat index-index.toml)`). - `USER_ID` - The user id for the runtime crated `torrust` user. Please Note: This user id should match the ownership of the host-mapped volumes, (default `1000`). - `API_PORT` - The port for the index API. This should match the port used in the configuration, (default `3001`). - ### Sockets + Socket ports used internally within the container can be mapped to with the `--publish` argument. The format is: `--publish [optional_host_ip]:[host_port]:[container_port]/[optional_protocol]`, for example: `--publish 127.0.0.1:8080:80/tcp`. @@ -159,8 +169,9 @@ The default ports can be mapped with the following: > NOTE: Inside the container it is necessary to expose a socket with the wildcard address `0.0.0.0` so that it may be accessible from the host. Verify that the configuration that the sockets are wildcard. -### Volumes -By default the container will use install volumes for `/var/lib/torrust/index`, `/var/log/torrust/index`, and `/etc/torrust/index`, however for better administration it good to make these volumes host-mapped. +### Mapped Volumes + +By default the container will install volumes for `/var/lib/torrust/index`, `/var/log/torrust/index`, and `/etc/torrust/index`, however for better administration it good to make these volumes host-mapped. The argument to host-map volumes is `--volume`, with the format: `--volume=[host-src:]container-dest[:]`. @@ -172,10 +183,9 @@ The default mapping can be supplied with the following arguments: --volume ./storage/index/etc:/etc/torrust/index:Z \ ``` - Please not the `:Z` at the end of the podman `--volume` mapping arguments, this is to give read-write permission on SELinux enabled systemd, if this doesn't work on your system, you can use `:rw` instead. -## Complete Example: +## Complete Example ### With Docker @@ -191,7 +201,8 @@ mkdir -p ./storage/index/lib/ ./storage/index/log/ ./storage/index/etc/ ## Run Torrust Index Container Image docker run -it \ - --env TORRUST_INDEX_TRACKER_API_TOKEN="MySecretToken" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MySecretToken" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SECRET_KEY="MaxVerstappenWC2021" \ --env USER_ID="$(id -u)" \ --publish 0.0.0.0:3001:3001/tcp \ --volume ./storage/index/lib:/var/lib/torrust/index:Z \ @@ -211,7 +222,7 @@ mkdir -p ./storage/index/lib/ ./storage/index/log/ ./storage/index/etc/ ## Run Torrust Index Container Image podman run -it \ - --env TORRUST_INDEX_TRACKER_API_TOKEN="MySecretToken" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MySecretToken" \ --env USER_ID="$(id -u)" \ --publish 0.0.0.0:3001:3001/tcp \ --volume ./storage/index/lib:/var/lib/torrust/index:Z \ diff --git a/docs/images/torrust-index-architecture.jpg b/docs/images/torrust-index-architecture.jpg new file mode 100644 index 00000000..c6baf696 Binary files /dev/null and b/docs/images/torrust-index-architecture.jpg differ diff --git a/migrations/mysql/20230921211523_torrust_add_creation_date_field_to_torrent.sql b/migrations/mysql/20230921211523_torrust_add_creation_date_field_to_torrent.sql new file mode 100644 index 00000000..8a286401 --- /dev/null +++ b/migrations/mysql/20230921211523_torrust_add_creation_date_field_to_torrent.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_torrents ADD COLUMN creation_date BIGINT NULL; diff --git a/migrations/mysql/20230921211551_torrust_add_created_by_to_torrent.sql b/migrations/mysql/20230921211551_torrust_add_created_by_to_torrent.sql new file mode 100644 index 00000000..33cb06d9 --- /dev/null +++ b/migrations/mysql/20230921211551_torrust_add_created_by_to_torrent.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_torrents ADD COLUMN created_by TEXT NULL; diff --git a/migrations/mysql/20230921211559_torrust_add_encoding_to_torrent.sql b/migrations/mysql/20230921211559_torrust_add_encoding_to_torrent.sql new file mode 100644 index 00000000..5ee5af02 --- /dev/null +++ b/migrations/mysql/20230921211559_torrust_add_encoding_to_torrent.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_torrents ADD COLUMN `encoding` TEXT NULL; diff --git a/migrations/mysql/20240304104106_torrust_add_http_seeds_to_torrent.sql b/migrations/mysql/20240304104106_torrust_add_http_seeds_to_torrent.sql new file mode 100644 index 00000000..696f5ad6 --- /dev/null +++ b/migrations/mysql/20240304104106_torrust_add_http_seeds_to_torrent.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS torrust_torrent_http_seeds ( + http_seed_id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + torrent_id INTEGER NOT NULL, + seed_url VARCHAR(256) NOT NULL, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE +) diff --git a/migrations/mysql/20240304165035_torrust_add_nodes_to_torrent.sql b/migrations/mysql/20240304165035_torrust_add_nodes_to_torrent.sql new file mode 100644 index 00000000..66e7a7df --- /dev/null +++ b/migrations/mysql/20240304165035_torrust_add_nodes_to_torrent.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS torrust_torrent_nodes ( + node_id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + torrent_id INTEGER NOT NULL, + node_ip VARCHAR(256) NOT NULL, + node_port INTEGER NOT NULL, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE +) diff --git a/migrations/mysql/20240305110750_torrust_bep_rename_root_hash_to_is_bep_30.sql b/migrations/mysql/20240305110750_torrust_bep_rename_root_hash_to_is_bep_30.sql new file mode 100644 index 00000000..f50075bc --- /dev/null +++ b/migrations/mysql/20240305110750_torrust_bep_rename_root_hash_to_is_bep_30.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_torrents CHANGE COLUMN root_hash is_bep_30 BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/mysql/20240305120015_torrust_add_independent_root_hash_field.sql b/migrations/mysql/20240305120015_torrust_add_independent_root_hash_field.sql new file mode 100644 index 00000000..efde400c --- /dev/null +++ b/migrations/mysql/20240305120015_torrust_add_independent_root_hash_field.sql @@ -0,0 +1,4 @@ +ALTER TABLE torrust_torrents ADD COLUMN root_hash LONGTEXT; + +-- Make `pieces` nullable. BEP 30 torrents does have this field. +ALTER TABLE torrust_torrents MODIFY COLUMN pieces LONGTEXT; diff --git a/migrations/mysql/20240312130530_torrust_add_update_data_to_tracker_stats.sql b/migrations/mysql/20240312130530_torrust_add_update_data_to_tracker_stats.sql new file mode 100644 index 00000000..76b306de --- /dev/null +++ b/migrations/mysql/20240312130530_torrust_add_update_data_to_tracker_stats.sql @@ -0,0 +1,4 @@ +-- New field to track when stats were updated from the tracker +ALTER TABLE torrust_torrent_tracker_stats ADD COLUMN updated_at DATETIME DEFAULT NULL; +UPDATE torrust_torrent_tracker_stats SET updated_at = '1000-01-01 00:00:00'; +ALTER TABLE torrust_torrent_tracker_stats MODIFY COLUMN updated_at DATETIME NOT NULL; \ No newline at end of file diff --git a/migrations/sqlite3/20230921211523_torrust_add_creation_date_field_to_torrent.sql b/migrations/sqlite3/20230921211523_torrust_add_creation_date_field_to_torrent.sql new file mode 100644 index 00000000..d413f4b0 --- /dev/null +++ b/migrations/sqlite3/20230921211523_torrust_add_creation_date_field_to_torrent.sql @@ -0,0 +1 @@ +ALTER TABLE "torrust_torrents" ADD COLUMN "creation_date" BIGINT NULL; diff --git a/migrations/sqlite3/20230921211551_torrust_add_created_by_to_torrent.sql b/migrations/sqlite3/20230921211551_torrust_add_created_by_to_torrent.sql new file mode 100644 index 00000000..8f34bce2 --- /dev/null +++ b/migrations/sqlite3/20230921211551_torrust_add_created_by_to_torrent.sql @@ -0,0 +1 @@ +ALTER TABLE "torrust_torrents" ADD COLUMN "created_by" TEXT NULL; diff --git a/migrations/sqlite3/20230921211559_torrust_add_encoding_to_torrent.sql b/migrations/sqlite3/20230921211559_torrust_add_encoding_to_torrent.sql new file mode 100644 index 00000000..d15b9369 --- /dev/null +++ b/migrations/sqlite3/20230921211559_torrust_add_encoding_to_torrent.sql @@ -0,0 +1 @@ +ALTER TABLE "torrust_torrents" ADD COLUMN `encoding` TEXT NULL; \ No newline at end of file diff --git a/migrations/sqlite3/20240304104106_torrust_add_http_seeds_to_torrent.sql b/migrations/sqlite3/20240304104106_torrust_add_http_seeds_to_torrent.sql new file mode 100644 index 00000000..04fa7713 --- /dev/null +++ b/migrations/sqlite3/20240304104106_torrust_add_http_seeds_to_torrent.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS torrust_torrent_http_seeds ( + http_seed_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + torrent_id INTEGER NOT NULL, + seed_url TEXT NOT NULL, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE +) diff --git a/migrations/sqlite3/20240304165035_torrust_add_nodes_to_torrent.sql b/migrations/sqlite3/20240304165035_torrust_add_nodes_to_torrent.sql new file mode 100644 index 00000000..4efc0966 --- /dev/null +++ b/migrations/sqlite3/20240304165035_torrust_add_nodes_to_torrent.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS torrust_torrent_nodes ( + node_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + torrent_id INTEGER NOT NULL, + node_ip TEXT NOT NULL, + node_port INTEGER NOT NULL, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE +) \ No newline at end of file diff --git a/migrations/sqlite3/20240305110750_torrust_bep_rename_root_hash_to_is_bep_30.sql b/migrations/sqlite3/20240305110750_torrust_bep_rename_root_hash_to_is_bep_30.sql new file mode 100644 index 00000000..0a33c94b --- /dev/null +++ b/migrations/sqlite3/20240305110750_torrust_bep_rename_root_hash_to_is_bep_30.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_torrents RENAME COLUMN root_hash TO is_bep_30; diff --git a/migrations/sqlite3/20240305120015_torrust_add_independent_root_hash_field.sql b/migrations/sqlite3/20240305120015_torrust_add_independent_root_hash_field.sql new file mode 100644 index 00000000..b792d0f4 --- /dev/null +++ b/migrations/sqlite3/20240305120015_torrust_add_independent_root_hash_field.sql @@ -0,0 +1,160 @@ +PRAGMA foreign_keys = off; + +-- Step 1: backup secondary tables. They will be truncated because of the DELETE ON CASCADE +CREATE TEMPORARY TABLE IF NOT EXISTS "torrust_torrent_files_backup" ( + "file_id" INTEGER NOT NULL, + "torrent_id" INTEGER NOT NULL, + "md5sum" TEXT DEFAULT NULL, + "length" BIGINT NOT NULL, + "path" TEXT DEFAULT NULL +); +INSERT INTO torrust_torrent_files_backup SELECT * FROM torrust_torrent_files; + +CREATE TEMPORARY TABLE IF NOT EXISTS "torrust_torrent_announce_urls_backup" ( + "announce_url_id" INTEGER NOT NULL, + "torrent_id" INTEGER NOT NULL, + "tracker_url" TEXT NOT NULL +); +INSERT INTO torrust_torrent_announce_urls_backup SELECT * FROM torrust_torrent_announce_urls; + +CREATE TEMPORARY TABLE IF NOT EXISTS "torrust_torrent_info_backup" ( + "torrent_id" INTEGER NOT NULL, + "title" VARCHAR(256) NOT NULL UNIQUE, + "description" TEXT DEFAULT NULL +); +INSERT INTO torrust_torrent_info_backup SELECT * FROM torrust_torrent_info; + +CREATE TEMPORARY TABLE IF NOT EXISTS "torrust_torrent_tracker_stats_backup" ( + "torrent_id" INTEGER NOT NULL, + "tracker_url" VARCHAR(256) NOT NULL, + "seeders" INTEGER NOT NULL DEFAULT 0, + "leechers" INTEGER NOT NULL DEFAULT 0 +); +INSERT INTO torrust_torrent_tracker_stats_backup SELECT * FROM torrust_torrent_tracker_stats; + +CREATE TEMPORARY TABLE IF NOT EXISTS "torrust_torrent_tag_links_backup" ( + "torrent_id" INTEGER NOT NULL, + "tag_id" INTEGER NOT NULL +); +INSERT INTO torrust_torrent_tag_links_backup SELECT * FROM torrust_torrent_tag_links; + +CREATE TEMPORARY TABLE IF NOT EXISTS "torrust_torrent_info_hashes_backup" ( + "info_hash" TEXT NOT NULL, + "canonical_info_hash" TEXT NOT NULL, + "original_is_known" BOOLEAN NOT NULL +); +INSERT INTO torrust_torrent_info_hashes_backup SELECT * FROM torrust_torrent_info_hashes; + +CREATE TEMPORARY TABLE IF NOT EXISTS "torrust_torrent_http_seeds_backup" ( + "http_seed_id" INTEGER NOT NULL, + "torrent_id" INTEGER NOT NULL, + "seed_url" TEXT NOT NULL +); +INSERT INTO torrust_torrent_http_seeds_backup SELECT * FROM torrust_torrent_http_seeds; + +CREATE TEMPORARY TABLE IF NOT EXISTS "torrust_torrent_nodes_backup" ( + "node_id" INTEGER NOT NULL, + "torrent_id" INTEGER NOT NULL, + "node_ip" TEXT NOT NULL, + "node_port" INTEGER NOT NULL +); +INSERT INTO torrust_torrent_nodes_backup SELECT * FROM torrust_torrent_nodes; + +-- Step 2: Add field `root_hash` and make `pieces` nullable +CREATE TABLE + IF NOT EXISTS "torrust_torrents_new" ( + "torrent_id" INTEGER NOT NULL, + "uploader_id" INTEGER NOT NULL, + "category_id" INTEGER, + "info_hash" TEXT NOT NULL UNIQUE, + "size" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "pieces" TEXT, + "root_hash" TEXT, + "piece_length" INTEGER NOT NULL, + "private" BOOLEAN DEFAULT NULL, + "is_bep_30" INT NOT NULL DEFAULT 0, + "date_uploaded" TEXT NOT NULL, + "source" TEXT DEFAULT NULL, + "comment" TEXT, + "creation_date" BIGINT, + "created_by" TEXT, + "encoding" TEXT, + FOREIGN KEY ("uploader_id") REFERENCES "torrust_users" ("user_id") ON DELETE CASCADE, + FOREIGN KEY ("category_id") REFERENCES "torrust_categories" ("category_id") ON DELETE SET NULL, + PRIMARY KEY ("torrent_id" AUTOINCREMENT) + ); + +-- Step 3: Copy data from the old table to the new table +INSERT INTO + torrust_torrents_new ( + torrent_id, + uploader_id, + category_id, + info_hash, + size, + name, + pieces, + piece_length, + private, + root_hash, + date_uploaded, + source, + comment, + creation_date, + created_by, + encoding + ) +SELECT + torrent_id, + uploader_id, + category_id, + info_hash, + size, + name, + CASE + WHEN is_bep_30 = 0 THEN pieces + ELSE NULL + END, + piece_length, + private, + CASE + WHEN is_bep_30 = 1 THEN pieces + ELSE NULL + END, + date_uploaded, + source, + comment, + creation_date, + created_by, + encoding +FROM + torrust_torrents; + +-- Step 4: Drop the old table +DROP TABLE torrust_torrents; + +-- Step 5: Rename the new table to the original name +ALTER TABLE torrust_torrents_new RENAME TO torrust_torrents; + +-- Step 6: Repopulate secondary tables from backup tables +INSERT INTO torrust_torrent_files SELECT * FROM torrust_torrent_files_backup; +INSERT INTO torrust_torrent_announce_urls SELECT * FROM torrust_torrent_announce_urls_backup; +INSERT INTO torrust_torrent_info SELECT * FROM torrust_torrent_info_backup; +INSERT INTO torrust_torrent_tracker_stats SELECT * FROM torrust_torrent_tracker_stats_backup; +INSERT INTO torrust_torrent_tag_links SELECT * FROM torrust_torrent_tag_links_backup; +INSERT INTO torrust_torrent_info_hashes SELECT * FROM torrust_torrent_info_hashes_backup; +INSERT INTO torrust_torrent_http_seeds SELECT * FROM torrust_torrent_http_seeds_backup; +INSERT INTO torrust_torrent_nodes SELECT * FROM torrust_torrent_nodes_backup; + +-- Step 7: Drop temporary secondary table backups +DROP TABLE torrust_torrent_files_backup; +DROP TABLE torrust_torrent_announce_urls_backup; +DROP TABLE torrust_torrent_info_backup; +DROP TABLE torrust_torrent_tracker_stats_backup; +DROP TABLE torrust_torrent_tag_links_backup; +DROP TABLE torrust_torrent_info_hashes_backup; +DROP TABLE torrust_torrent_http_seeds_backup; +DROP TABLE torrust_torrent_nodes_backup; + +PRAGMA foreign_keys = on; \ No newline at end of file diff --git a/migrations/sqlite3/20240312130530_torrust_add_update_data_to_tracker_stats.sql b/migrations/sqlite3/20240312130530_torrust_add_update_data_to_tracker_stats.sql new file mode 100644 index 00000000..b376d945 --- /dev/null +++ b/migrations/sqlite3/20240312130530_torrust_add_update_data_to_tracker_stats.sql @@ -0,0 +1,2 @@ +-- New field to track when stats were updated from the tracker +ALTER TABLE torrust_torrent_tracker_stats ADD COLUMN updated_at TEXT DEFAULT "1000-01-01 00:00:00"; diff --git a/packages/located-error/Cargo.toml b/packages/located-error/Cargo.toml index d8065bca..6cc26bbe 100644 --- a/packages/located-error/Cargo.toml +++ b/packages/located-error/Cargo.toml @@ -15,7 +15,8 @@ rust-version.workspace = true version.workspace = true [dependencies] -log = { version = "0", features = ["release_max_level_info"] } +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.18", features = ["json"] } [dev-dependencies] thiserror = "1.0" diff --git a/packages/located-error/src/lib.rs b/packages/located-error/src/lib.rs index bf861868..2844d69d 100644 --- a/packages/located-error/src/lib.rs +++ b/packages/located-error/src/lib.rs @@ -90,7 +90,7 @@ where source: Arc::new(self.0), location: Box::new(*std::panic::Location::caller()), }; - log::debug!("{e}"); + tracing::debug!("{e}"); e } } diff --git a/project-words.txt b/project-words.txt index 74669479..01908fce 100644 --- a/project-words.txt +++ b/project-words.txt @@ -8,6 +8,8 @@ Benoit binascii btih buildx +camino +Casbin chrono clippy codecov @@ -25,6 +27,7 @@ Dont dotless dtolnay elif +Eray grcov Grünwald hasher @@ -39,6 +42,7 @@ indexmap infohash Intermodal jsonwebtoken +Karatay leechers Leechers LEECHERS @@ -48,6 +52,7 @@ luckythelab mailcatcher mandelbrotset metainfo +Mgmt migth nanos NCCA @@ -59,6 +64,7 @@ oneshot openbittorrent opentrackr ppassword +programatik proxied rapppid reqwest @@ -68,6 +74,8 @@ rowid RUSTDOCFLAGS RUSTFLAGS rustfmt +rustls +rustversion serde sgxj singlepart @@ -91,6 +99,7 @@ upgrader Uragqm urlencoding uroot +uuidgen Verstappen waivable webseeding diff --git a/share/container/entry_script_sh b/share/container/entry_script_sh index 5f8d9d21..1afd9e6b 100644 --- a/share/container/entry_script_sh +++ b/share/container/entry_script_sh @@ -73,6 +73,7 @@ if [ -e "/usr/share/torrust/container/message" ]; then fi # Load message of the day from Profile +# shellcheck disable=SC2016 echo '[ ! -z "$TERM" -a -r /etc/motd ] && cat /etc/motd' >> /etc/profile cd /home/torrust || exit 1 diff --git a/share/default/config/index.container.mysql.toml b/share/default/config/index.container.mysql.toml index 1999c4a1..9bc28f58 100644 --- a/share/default/config/index.container.mysql.toml +++ b/share/default/config/index.container.mysql.toml @@ -1,50 +1,28 @@ -log_level = "info" - -[website] -name = "Torrust" - -# Please override the tracker token setting the -# `TORRUST_INDEX_TRACKER_API_TOKEN` -# environmental variable! +[metadata] +app = "torrust-index" +purpose = "configuration" +schema_version = "2.0.0" + +[logging] +#threshold = "off" +#threshold = "error" +#threshold = "warn" +threshold = "info" +#threshold = "debug" +#threshold = "trace" [tracker] -url = "udp://tracker:6969" -mode = "Public" -api_url = "http://tracker:1212" token = "MyAccessToken" -token_valid_seconds = 7257600 - -[net] -port = 3001 [auth] -email_on_signup = "Optional" -min_password_length = 6 -max_password_length = 64 -secret_key = "MaxVerstappenWC2021" +user_claim_token_pepper = "MaxVerstappenWC2021" [database] -connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_e2e_testing" +connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index" -[mail] -email_verification_enabled = false -from = "example@email.com" -reply_to = "noreply@email.com" -username = "" -password = "" -server = "mailcatcher" +[mail.smtp] port = 1025 +server = "mailcatcher" -[image_cache] -max_request_timeout_ms = 1000 -capacity = 128000000 -entry_size_limit = 4000000 -user_quota_period_seconds = 3600 -user_quota_bytes = 64000000 - -[api] -default_torrent_page_size = 10 -max_torrent_page_size = 30 - -[tracker_statistics_importer] -torrent_info_update_interval = 3600 +[registration] +[registration.email] diff --git a/share/default/config/index.container.sqlite3.toml b/share/default/config/index.container.sqlite3.toml index c0cb6002..ab223343 100644 --- a/share/default/config/index.container.sqlite3.toml +++ b/share/default/config/index.container.sqlite3.toml @@ -1,50 +1,28 @@ -log_level = "info" - -[website] -name = "Torrust" - -# Please override the tracker token setting the -# `TORRUST_INDEX_TRACKER_API_TOKEN` -# environmental variable! +[metadata] +app = "torrust-index" +purpose = "configuration" +schema_version = "2.0.0" + +[logging] +#threshold = "off" +#threshold = "error" +#threshold = "warn" +threshold = "info" +#threshold = "debug" +#threshold = "trace" [tracker] -url = "udp://tracker:6969" -mode = "Public" -api_url = "http://tracker:1212" token = "MyAccessToken" -token_valid_seconds = 7257600 - -[net] -port = 3001 [auth] -email_on_signup = "Optional" -min_password_length = 6 -max_password_length = 64 -secret_key = "MaxVerstappenWC2021" +user_claim_token_pepper = "MaxVerstappenWC2021" [database] connect_url = "sqlite:///var/lib/torrust/index/database/sqlite3.db?mode=rwc" -[mail] -email_verification_enabled = false -from = "example@email.com" -reply_to = "noreply@email.com" -username = "" -password = "" -server = "mailcatcher" +[mail.smtp] port = 1025 +server = "mailcatcher" -[image_cache] -max_request_timeout_ms = 1000 -capacity = 128000000 -entry_size_limit = 4000000 -user_quota_period_seconds = 3600 -user_quota_bytes = 64000000 - -[api] -default_torrent_page_size = 10 -max_torrent_page_size = 30 - -[tracker_statistics_importer] -torrent_info_update_interval = 3600 +[registration] +[registration.email] diff --git a/share/default/config/index.development.sqlite3.toml b/share/default/config/index.development.sqlite3.toml index 06f89a3c..4b4af3aa 100644 --- a/share/default/config/index.development.sqlite3.toml +++ b/share/default/config/index.development.sqlite3.toml @@ -1,46 +1,26 @@ -log_level = "info" - -[website] -name = "Torrust" +[metadata] +app = "torrust-index" +purpose = "configuration" +schema_version = "2.0.0" + +[logging] +#threshold = "off" +#threshold = "error" +#threshold = "warn" +threshold = "info" +#threshold = "debug" +#threshold = "trace" [tracker] -url = "udp://localhost:6969" -mode = "Public" -api_url = "http://localhost:1212" token = "MyAccessToken" -token_valid_seconds = 7257600 - -[net] -port = 3001 [auth] -email_on_signup = "Optional" -min_password_length = 6 -max_password_length = 64 -secret_key = "MaxVerstappenWC2021" - -[database] -connect_url = "sqlite://data.db?mode=rwc" - -[mail] -email_verification_enabled = false -from = "example@email.com" -reply_to = "noreply@email.com" -username = "" -password = "" -server = "" -port = 25 - -[image_cache] -max_request_timeout_ms = 1000 -capacity = 128000000 -entry_size_limit = 4000000 -user_quota_period_seconds = 3600 -user_quota_bytes = 64000000 +user_claim_token_pepper = "MaxVerstappenWC2021" -[api] -default_torrent_page_size = 10 -max_torrent_page_size = 30 +# Uncomment if you want to enable TSL for development +#[net.tsl] +#ssl_cert_path = "./storage/index/lib/tls/localhost.crt" +#ssl_key_path = "./storage/index/lib/tls/localhost.key" -[tracker_statistics_importer] -torrent_info_update_interval = 3600 +[registration] +[registration.email] diff --git a/share/default/config/index.private.e2e.container.sqlite3.toml b/share/default/config/index.private.e2e.container.sqlite3.toml new file mode 100644 index 00000000..608bd419 --- /dev/null +++ b/share/default/config/index.private.e2e.container.sqlite3.toml @@ -0,0 +1,32 @@ +[metadata] +app = "torrust-index" +purpose = "configuration" +schema_version = "2.0.0" + +[logging] +#threshold = "off" +#threshold = "error" +#threshold = "warn" +threshold = "info" +#threshold = "debug" +#threshold = "trace" + +[tracker] +api_url = "http://tracker:1212" +listed = false +private = true +token = "MyAccessToken" +url = "http://tracker:7070" + +[auth] +user_claim_token_pepper = "MaxVerstappenWC2021" + +[database] +connect_url = "sqlite:///var/lib/torrust/index/database/e2e_testing_sqlite3.db?mode=rwc" + +[mail.smtp] +port = 1025 +server = "mailcatcher" + +[registration] +[registration.email] diff --git a/share/default/config/index.public.e2e.container.mysql.toml b/share/default/config/index.public.e2e.container.mysql.toml new file mode 100644 index 00000000..c6b4550e --- /dev/null +++ b/share/default/config/index.public.e2e.container.mysql.toml @@ -0,0 +1,30 @@ +[metadata] +app = "torrust-index" +purpose = "configuration" +schema_version = "2.0.0" + +[logging] +#threshold = "off" +#threshold = "error" +#threshold = "warn" +threshold = "info" +#threshold = "debug" +#threshold = "trace" + +[tracker] +api_url = "http://tracker:1212" +token = "MyAccessToken" +url = "udp://tracker:6969" + +[auth] +user_claim_token_pepper = "MaxVerstappenWC2021" + +[database] +connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_e2e_testing" + +[mail.smtp] +port = 1025 +server = "mailcatcher" + +[registration] +[registration.email] diff --git a/share/default/config/index.public.e2e.container.sqlite3.toml b/share/default/config/index.public.e2e.container.sqlite3.toml new file mode 100644 index 00000000..1b807154 --- /dev/null +++ b/share/default/config/index.public.e2e.container.sqlite3.toml @@ -0,0 +1,30 @@ +[metadata] +app = "torrust-index" +purpose = "configuration" +schema_version = "2.0.0" + +[logging] +#threshold = "off" +#threshold = "error" +#threshold = "warn" +threshold = "info" +#threshold = "debug" +#threshold = "trace" + +[tracker] +api_url = "http://tracker:1212" +token = "MyAccessToken" +url = "udp://tracker:6969" + +[auth] +user_claim_token_pepper = "MaxVerstappenWC2021" + +[database] +connect_url = "sqlite:///var/lib/torrust/index/database/e2e_testing_sqlite3.db?mode=rwc" + +[mail.smtp] +port = 1025 +server = "mailcatcher" + +[registration] +[registration.email] diff --git a/share/default/config/tracker.container.mysql.toml b/share/default/config/tracker.container.mysql.toml deleted file mode 100644 index fb9cbf78..00000000 --- a/share/default/config/tracker.container.mysql.toml +++ /dev/null @@ -1,38 +0,0 @@ -announce_interval = 120 -db_driver = "MySQL" -db_path = "mysql://db_user:db_user_secret_password@mysql:3306/torrust_tracker" -external_ip = "0.0.0.0" -inactive_peer_cleanup_interval = 600 -log_level = "info" -max_peer_timeout = 900 -min_announce_interval = 120 -mode = "public" -on_reverse_proxy = false -persistent_torrent_completed_stat = false -remove_peerless_torrents = true -tracker_usage_statistics = true - -[[udp_trackers]] -bind_address = "0.0.0.0:6969" -enabled = false - -[[http_trackers]] -bind_address = "0.0.0.0:7070" -enabled = false -ssl_cert_path = "/var/lib/torrust/tracker/tls/localhost.crt" -ssl_enabled = false -ssl_key_path = "/var/lib/torrust/tracker/tls/localhost.key" - -[http_api] -bind_address = "0.0.0.0:1212" -enabled = true -ssl_cert_path = "/var/lib/torrust/tracker/tls/localhost.crt" -ssl_enabled = false -ssl_key_path = "/var/lib/torrust/tracker/tls/localhost.key" - -# Please override the admin token setting the -# `TORRUST_TRACKER_API_ADMIN_TOKEN` -# environmental variable! - -[http_api.access_tokens] -admin = "MyAccessToken" diff --git a/share/default/config/tracker.container.sqlite3.toml b/share/default/config/tracker.container.sqlite3.toml deleted file mode 100644 index 54cfd402..00000000 --- a/share/default/config/tracker.container.sqlite3.toml +++ /dev/null @@ -1,38 +0,0 @@ -announce_interval = 120 -db_driver = "Sqlite3" -db_path = "/var/lib/torrust/tracker/database/sqlite3.db" -external_ip = "0.0.0.0" -inactive_peer_cleanup_interval = 600 -log_level = "info" -max_peer_timeout = 900 -min_announce_interval = 120 -mode = "public" -on_reverse_proxy = false -persistent_torrent_completed_stat = false -remove_peerless_torrents = true -tracker_usage_statistics = true - -[[udp_trackers]] -bind_address = "0.0.0.0:6969" -enabled = false - -[[http_trackers]] -bind_address = "0.0.0.0:7070" -enabled = false -ssl_cert_path = "/var/lib/torrust/tracker/tls/localhost.crt" -ssl_enabled = false -ssl_key_path = "/var/lib/torrust/tracker/tls/localhost.key" - -[http_api] -bind_address = "0.0.0.0:1212" -enabled = true -ssl_cert_path = "/var/lib/torrust/tracker/tls/localhost.crt" -ssl_enabled = false -ssl_key_path = "/var/lib/torrust/tracker/tls/localhost.key" - -# Please override the admin token setting the -# `TORRUST_TRACKER_API_ADMIN_TOKEN` -# environmental variable! - -[http_api.access_tokens] -admin = "MyAccessToken" diff --git a/share/default/config/tracker.private.e2e.container.sqlite3.toml b/share/default/config/tracker.private.e2e.container.sqlite3.toml new file mode 100644 index 00000000..647d5cee --- /dev/null +++ b/share/default/config/tracker.private.e2e.container.sqlite3.toml @@ -0,0 +1,25 @@ +[metadata] +schema_version = "2.0.0" + +[logging] +threshold = "info" + +[tracker] +token = "MyAccessToken" + +[auth] +user_claim_token_pepper = "MaxVerstappenWC2021" + +[core] +listed = false +private = true + +[core.database] +driver = "sqlite3" +path = "/var/lib/torrust/tracker/database/e2e_testing_sqlite3.db" + +[[udp_trackers]] +bind_address = "0.0.0.0:6969" + +[http_api] +bind_address = "0.0.0.0:1212" diff --git a/share/default/config/tracker.public.e2e.container.sqlite3.toml b/share/default/config/tracker.public.e2e.container.sqlite3.toml new file mode 100644 index 00000000..e3f73d0b --- /dev/null +++ b/share/default/config/tracker.public.e2e.container.sqlite3.toml @@ -0,0 +1,25 @@ +[metadata] +schema_version = "2.0.0" + +[logging] +threshold = "info" + +[tracker] +token = "MyAccessToken" + +[auth] +user_claim_token_pepper = "MaxVerstappenWC2021" + +[core] +listed = false +private = false + +[core.database] +driver = "sqlite3" +path = "/var/lib/torrust/tracker/database/e2e_testing_sqlite3.db" + +[[http_trackers]] +bind_address = "0.0.0.0:7070" + +[http_api] +bind_address = "0.0.0.0:1212" diff --git a/share/tls/localhost.crt b/share/tls/localhost.crt new file mode 100755 index 00000000..54f35ac7 --- /dev/null +++ b/share/tls/localhost.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDzCCAfegAwIBAgIUQVYeAGfczJZDxiP/55P1V+hxLjgwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI0MDUxNTE2MTUxNloXDTI0MDYx +NDE2MTUxNlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAsiVY2ny8JkTXvM1FSEp47UUNZcRCpQ3/JR1KYscK4yFk +t+2Fntqn7oYPFo17BU0fHZfJ/4ZFwgSCO2p41+plyAWjp9yjwA1Rgqs1eSvGceQG +cWZA8nIiehTdimOqV9gSr2lUpFUPvZhvfkoKUPH8kgnSsK6Vh5AHhOtMHJrTfSHi +SMyZlBMNm8XcHPI4Yc56rX56j0edQ+etmW+yF/sHxp4VuYLRg8Gy9LSBLhVYP2jb +3lHjraSpC6P1OQZPg+yDIJ67LPF3Io0POQQOqahHqKNXprakWNZzGKHklx5wSycW +LBBbwceEGFfoAap88czkh5RPVGkzaG9qI5nGjwT+iQIDAQABo1kwVzAUBgNVHREE +DTALgglsb2NhbGhvc3QwCwYDVR0PBAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMB +MB0GA1UdDgQWBBTNfmPhC1eBckwBVRUKFZXV94I4SDANBgkqhkiG9w0BAQsFAAOC +AQEADY9Z/RPdex3uSdo8gbEKkxzLFTE/DKiOk4ynpIjEmAm3PQ5JGX1bkXQU29WB +YFStue7OemFT1wCadv8xO4Y1WZdEDRAu1kAR+X30aL4hk03nOH3BOIlp972/yCjF +biAqUNJ1VbQkJHjBMFl/9pdsvrO1nz8ObgJrgyszCh+UXDk+mySEeJqiGYCCoZ3x +aQYnAO7+JVUgdXBmWd9BjNQAui8AwN+K5JelDecbwwh5Evykoa9Ey7W8yW23wuoK +MoVnti84JiF9eK/bQSRxdP9N8bECsHUSHWMOoA7+axOq1Q1L8oe67NCiBo//s28T +ZmJAlAeGXy1QqVTIslM8J+ceNQ== +-----END CERTIFICATE----- diff --git a/share/tls/localhost.key b/share/tls/localhost.key new file mode 100755 index 00000000..77e9e14f --- /dev/null +++ b/share/tls/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCyJVjafLwmRNe8 +zUVISnjtRQ1lxEKlDf8lHUpixwrjIWS37YWe2qfuhg8WjXsFTR8dl8n/hkXCBII7 +anjX6mXIBaOn3KPADVGCqzV5K8Zx5AZxZkDyciJ6FN2KY6pX2BKvaVSkVQ+9mG9+ +SgpQ8fySCdKwrpWHkAeE60wcmtN9IeJIzJmUEw2bxdwc8jhhznqtfnqPR51D562Z +b7IX+wfGnhW5gtGDwbL0tIEuFVg/aNveUeOtpKkLo/U5Bk+D7IMgnrss8XcijQ85 +BA6pqEeoo1emtqRY1nMYoeSXHnBLJxYsEFvBx4QYV+gBqnzxzOSHlE9UaTNob2oj +mcaPBP6JAgMBAAECggEAAPMoUB+ga3mHoqgSGaO3cMWQn91s4Php2UbPj5RorQXr +IPx+71GbtVNLX5X7PjjZneg0a8yk57cQJ0TyWJIVXyET/ylptz3a7/lrbrY/Cgz8 +6GC8DQ7gceWelVhP1jLscgJpefpCIKfN+86uZa+EnYPdCSXXb/lQVYVhXRSJrdll +1LJuNAvW88c1zXKWJ+L05H3Q+O98F/6PpEcwln0mX9Qp7QyBNjeP1B1eQc8+S6CD +hgRifcY7KKdecDWh1i8haNqRUtXL7XAksesHJbxtIwaeu+8AXSQunpT2JOYFlzpy +yllEDcT2s+JutBqclINWggBEn1eHtksQKNLWrTVaiQKBgQDFdp8BwWRIYji9mAx5 +te4dwOTj+POSm6DCi9wXssNsKdaGXFhNw3Wla2AvWZ5P/t1Z+zrvqag8sAjEl+nI +7WHra3voOojDdZ1Kf6QhMQ/ZD1vm0mFa32tsRIUZ5vYP5qyXsgPEb2OE0QnKGCAM +DD1X96C/CEecunQyioAOaJ+AmwKBgQDm9LvmY0rSEGe/oiBvnrYjIyHUn59FcIlU +kGvTW1ynPtGT6vrOyZGDnw8uOEI00/E7YB8psdJLQ8aOgT4xUc2p7haNri/V794W +hhWs2+qvDWvURSRMF0PZeV1b2bDqDB3AP2XiwaHR3MQpc1t4chNNNB5vuD0TJVrB +NIXi0S41qwKBgQCR3l/17wQCyLQ7sn+8xV2ikyVDF1vveJHYRXMP+pmMZJe556u/ +vl1BFsIWGHDvjUm9N+7Arqa+Nhg0CjjEmj+UpnEBC4SOR2srZoE7l7+qTENKjy0l +8RetAi0FBm3NL01ePj20Ncjhi35c0VeTLtN+EUqo9Bfauo4t68xPWJBDcwKBgENk +3v/XsZmi1+N/t99afOO7+L9G5P8qW6iljBFc86iKGDYFt7Jn92JlI9Tk7czkm9wr +rGxKS4dS+7nR1QgnStBvfX1Sevr+x9vivKh4c/8o93I1yuW5VD89vxRybcGeT4At +/9kvj7zhowxFcUewYhmBP/Bx3sCbgeQnI3qQd9+JAoGAFgzLLXw5fdwjz1oz9Cwz +WetpWujjMImgsD7b/7XmKeKCG82uorsaFI5rBb4eJdgJHoqaNAEkFuNdhRcuqVh1 +uZG02rb8HICnhPV/4wgyhf6pZEWrpmF9q4aqoH67hfrRMuVUD250px3y2Ozs77JJ +c7S9s1qUr+vPk7+ywFh5xRk= +-----END PRIVATE KEY----- diff --git a/src/app.rs b/src/app.rs index 353ce274..9ae58cd8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,29 +2,34 @@ use std::net::SocketAddr; use std::sync::Arc; use tokio::task::JoinHandle; +use tracing::info; use crate::bootstrap::logging; use crate::cache::image::manager::ImageCacheService; use crate::common::AppData; +use crate::config::validator::Validator; use crate::config::Configuration; use crate::databases::database; use crate::services::authentication::{DbUserAuthenticationRepository, JsonWebToken, Service}; +use crate::services::authorization::{CasbinConfiguration, CasbinEnforcer}; use crate::services::category::{self, DbCategoryRepository}; use crate::services::tag::{self, DbTagRepository}; use crate::services::torrent::{ DbCanonicalInfoHashGroupRepository, DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, DbTorrentTagRepository, }; -use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; -use crate::services::{proxy, settings, torrent}; +use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository, Repository}; +use crate::services::{about, authorization, proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; -use crate::web::api::v1::auth::Authentication; -use crate::web::api::{start, Version}; -use crate::{mailer, tracker}; +use crate::web::api::server::signals::Halted; +use crate::web::api::server::v1::auth::Authentication; +use crate::web::api::Version; +use crate::{console, mailer, tracker, web}; pub struct Running { pub api_socket_addr: SocketAddr, - pub api_server: Option>>, + pub api_server: JoinHandle>, + pub api_server_halt_task: tokio::sync::oneshot::Sender, pub tracker_data_importer_handle: tokio::task::JoinHandle<()>, } @@ -35,9 +40,11 @@ pub struct Running { /// It panics if there is an error connecting to the database. #[allow(clippy::too_many_lines)] pub async fn run(configuration: Configuration, api_version: &Version) -> Running { - let log_level = configuration.settings.read().await.log_level.clone(); + let threshold = configuration.settings.read().await.logging.threshold.clone(); - logging::setup(&log_level); + logging::setup(&threshold); + + log_configuration(&configuration).await; let configuration = Arc::new(configuration); @@ -46,10 +53,18 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running let settings = configuration.settings.read().await; - let database_connect_url = settings.database.connect_url.clone(); - let torrent_info_update_interval = settings.tracker_statistics_importer.torrent_info_update_interval; - let net_ip = "0.0.0.0".to_string(); - let net_port = settings.net.port; + settings.validate().expect("invalid settings"); + + // From [database] config + let database_connect_url = settings.database.connect_url.clone().to_string(); + // From [importer] config + let importer_torrent_info_update_interval = settings.tracker_statistics_importer.torrent_info_update_interval; + let importer_port = settings.tracker_statistics_importer.port; + // From [net] config + let config_bind_address = settings.net.bind_address; + let opt_net_tsl = settings.net.tsl.clone(); + // Unstable config + let unstable = settings.unstable.clone(); // IMPORTANT: drop settings before starting server to avoid read locks that // leads to requests hanging. @@ -64,7 +79,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running // Repositories let category_repository = Arc::new(DbCategoryRepository::new(database.clone())); let tag_repository = Arc::new(DbTagRepository::new(database.clone())); - let user_repository = Arc::new(DbUserRepository::new(database.clone())); + let user_repository: Arc> = Arc::new(Box::new(DbUserRepository::new(database.clone()))); let user_authentication_repository = Arc::new(DbUserAuthenticationRepository::new(database.clone())); let user_profile_repository = Arc::new(DbUserProfileRepository::new(database.clone())); let torrent_repository = Arc::new(DbTorrentRepository::new(database.clone())); @@ -75,17 +90,35 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running let torrent_tag_repository = Arc::new(DbTorrentTagRepository::new(database.clone())); let torrent_listing_generator = Arc::new(DbTorrentListingGenerator::new(database.clone())); let banned_user_list = Arc::new(DbBannedUserList::new(database.clone())); + let casbin_enforcer = Arc::new( + if let Some(casbin) = unstable + .as_ref() + .and_then(|u| u.auth.as_ref()) + .and_then(|auth| auth.casbin.as_ref()) + { + CasbinEnforcer::with_configuration(CasbinConfiguration::new(&casbin.model, &casbin.policy)).await + } else { + CasbinEnforcer::with_default_configuration().await + }, + ); // Services + let authorization_service = Arc::new(authorization::Service::new(user_repository.clone(), casbin_enforcer.clone())); let tracker_service = Arc::new(tracker::service::Service::new(configuration.clone(), database.clone()).await); let tracker_statistics_importer = Arc::new(StatisticsImporter::new(configuration.clone(), tracker_service.clone(), database.clone()).await); let mailer_service = Arc::new(mailer::Service::new(configuration.clone()).await); let image_cache_service: Arc = Arc::new(ImageCacheService::new(configuration.clone()).await); - let category_service = Arc::new(category::Service::new(category_repository.clone(), user_repository.clone())); - let tag_service = Arc::new(tag::Service::new(tag_repository.clone(), user_repository.clone())); - let proxy_service = Arc::new(proxy::Service::new(image_cache_service.clone(), user_repository.clone())); - let settings_service = Arc::new(settings::Service::new(configuration.clone(), user_repository.clone())); + let category_service = Arc::new(category::Service::new( + category_repository.clone(), + authorization_service.clone(), + )); + let tag_service = Arc::new(tag::Service::new(tag_repository.clone(), authorization_service.clone())); + let proxy_service = Arc::new(proxy::Service::new( + image_cache_service.clone(), + authorization_service.clone(), + )); + let settings_service = Arc::new(settings::Service::new(configuration.clone(), authorization_service.clone())); let torrent_index = Arc::new(torrent::Index::new( configuration.clone(), tracker_statistics_importer.clone(), @@ -99,6 +132,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running torrent_announce_url_repository.clone(), torrent_tag_repository.clone(), torrent_listing_generator.clone(), + authorization_service.clone(), )); let registration_service = Arc::new(user::RegistrationService::new( configuration.clone(), @@ -106,10 +140,15 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running user_repository.clone(), user_profile_repository.clone(), )); + let profile_service = Arc::new(user::ProfileService::new( + configuration.clone(), + user_authentication_repository.clone(), + authorization_service.clone(), + )); let ban_service = Arc::new(user::BanService::new( - user_repository.clone(), user_profile_repository.clone(), banned_user_list.clone(), + authorization_service.clone(), )); let authentication_service = Arc::new(Service::new( configuration.clone(), @@ -119,6 +158,8 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running user_authentication_repository.clone(), )); + let about_service = Arc::new(about::Service::new(authorization_service.clone())); + // Build app container let app_data = Arc::new(AppData::new( @@ -150,35 +191,34 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running settings_service, torrent_index, registration_service, + profile_service, ban_service, + about_service, )); - // Start repeating task to import tracker torrent data and updating + // Start cronjob to import tracker torrent data and updating // seeders and leechers info. - - let weak_tracker_statistics_importer = Arc::downgrade(&tracker_statistics_importer); - - let tracker_statistics_importer_handle = tokio::spawn(async move { - let interval = std::time::Duration::from_secs(torrent_info_update_interval); - let mut interval = tokio::time::interval(interval); - interval.tick().await; // first tick is immediate... - loop { - interval.tick().await; - if let Some(tracker) = weak_tracker_statistics_importer.upgrade() { - drop(tracker.import_all_torrents_statistics().await); - } else { - break; - } - } - }); + let tracker_statistics_importer_handle = console::cronjobs::tracker_statistics_importer::start( + importer_port, + importer_torrent_info_update_interval, + &tracker_statistics_importer, + ); // Start API server + let running_api = web::api::start(app_data, config_bind_address, opt_net_tsl, api_version).await; - let running_api = start(app_data, &net_ip, net_port, api_version).await; - + // Full running application Running { api_socket_addr: running_api.socket_addr, - api_server: running_api.api_server, + api_server: running_api.task, + api_server_halt_task: running_api.halt_task, tracker_data_importer_handle: tracker_statistics_importer_handle, } } + +/// It logs the final configuration removing secrets. +async fn log_configuration(configuration: &Configuration) { + let mut setting = configuration.get_all().await.clone(); + setting.remove_secrets(); + info!("Configuration:\n{}", setting.to_json()); +} diff --git a/src/bin/create_test_torrent.rs b/src/bin/create_test_torrent.rs new file mode 100644 index 00000000..494cbcd3 --- /dev/null +++ b/src/bin/create_test_torrent.rs @@ -0,0 +1,74 @@ +//! Command line tool to create a test torrent file. +//! +//! It's only used for debugging purposes. +use std::env; +use std::fs::File; +use std::io::Write; +use std::path::Path; + +use torrust_index::models::torrent_file::{Torrent, TorrentFile, TorrentInfoDictionary}; +use torrust_index::services::hasher::sha1; // DevSkim: ignore DS126858 +use torrust_index::utils::parse_torrent; +use uuid::Uuid; + +fn main() { + let args: Vec = env::args().collect(); + + if args.len() != 2 { + eprintln!("Usage: cargo run --bin create_test_torrent "); + eprintln!("Example: cargo run --bin create_test_torrent ./output/test/torrents"); + std::process::exit(1); + } + + let destination_folder = &args[1]; + + let id = Uuid::new_v4(); + + // Content of the file from which the torrent will be generated. + // We use the UUID as the content of the file. + let file_contents = format!("{id}\n"); + let file_name = format!("file-{id}.txt"); + + let torrent = Torrent { + info: TorrentInfoDictionary::with( + &file_name, + 16384, + None, + 0, + &sha1(&file_contents), // DevSkim: ignore DS126858 + &[TorrentFile { + path: vec![file_name.clone()], // Adjusted to include the actual file name + length: i64::try_from(file_contents.len()).expect("file contents size in bytes cannot exceed i64::MAX"), + md5sum: None, // DevSkim: ignore DS126858 + }], + ), + announce: Some("https://tracker.torrust-demo.com/announce".to_string()), + nodes: Some(vec![("99.236.6.144".to_string(), 6881), ("91.109.195.156".to_string(), 1996)]), + encoding: None, + httpseeds: Some(vec!["https://seeder.torrust-demo.com/seed".to_string()]), + announce_list: Some(vec![vec!["https://tracker.torrust-demo.com/announce".to_string()]]), + creation_date: None, + comment: None, + created_by: None, + }; + + match parse_torrent::encode_torrent(&torrent) { + Ok(bytes) => { + // Construct the path where the torrent file will be saved + let file_path = Path::new(destination_folder).join(format!("{file_name}.torrent")); + + // Attempt to create and write to the file + let mut file = match File::create(&file_path) { + Ok(file) => file, + Err(e) => panic!("Failed to create file {file_path:?}: {e}"), + }; + + if let Err(e) = file.write_all(&bytes) { + panic!("Failed to write to file {file_path:?}: {e}"); + } + + println!("File successfully written to {file_path:?}"); + } + Err(e) => panic!("Error encoding torrent: {e}"), + }; +} diff --git a/src/bin/health_check.rs b/src/bin/health_check.rs new file mode 100644 index 00000000..1ac6652c --- /dev/null +++ b/src/bin/health_check.rs @@ -0,0 +1,42 @@ +//! Minimal `curl` or `wget` to be used for container health checks. +//! +//! It's convenient to avoid using third-party libraries because: +//! +//! - They are harder to maintain. +//! - They introduce new attack vectors. +use std::time::Duration; +use std::{env, process}; + +use reqwest::Client; + +#[tokio::main] +async fn main() { + let args: Vec = env::args().collect(); + if args.len() != 2 { + eprintln!("Usage: cargo run --bin health_check "); + eprintln!("Example: cargo run --bin health_check http://127.0.0.1:3001/health_check"); + std::process::exit(1); + } + + println!("Health check ..."); + + let url = &args[1].clone(); + + let client = Client::builder().timeout(Duration::from_secs(5)).build().unwrap(); + + match client.get(url).send().await { + Ok(response) => { + if response.status().is_success() { + println!("STATUS: {}", response.status()); + process::exit(0); + } else { + println!("Non-success status received."); + process::exit(1); + } + } + Err(err) => { + println!("ERROR: {err}"); + process::exit(1); + } + } +} diff --git a/src/bin/import_tracker_statistics.rs b/src/bin/import_tracker_statistics.rs index a405248b..07a0a0c6 100644 --- a/src/bin/import_tracker_statistics.rs +++ b/src/bin/import_tracker_statistics.rs @@ -1,11 +1,11 @@ //! Import Tracker Statistics command. //! -//! It imports the number of seeders and leechers for all torrent from the linked tracker. +//! It imports the number of seeders and leechers for all torrents from the linked tracker. //! //! You can execute it with: `cargo run --bin import_tracker_statistics` -use torrust_index::console::commands::import_tracker_statistics::run_importer; +use torrust_index::console::commands::tracker_statistics_importer::app::run; #[tokio::main] async fn main() { - run_importer().await; + run().await; } diff --git a/src/bin/seeder.rs b/src/bin/seeder.rs new file mode 100644 index 00000000..ae512041 --- /dev/null +++ b/src/bin/seeder.rs @@ -0,0 +1,7 @@ +//! Program to upload random torrents to a live Index API. +use torrust_index::console::commands::seeder::app; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + app::run().await +} diff --git a/src/bootstrap/config.rs b/src/bootstrap/config.rs index 49f661ac..aaef11df 100644 --- a/src/bootstrap/config.rs +++ b/src/bootstrap/config.rs @@ -6,28 +6,18 @@ use crate::config::{Configuration, Info}; -/// The whole `index.toml` file content. It has priority over the config file. -/// Even if the file is not on the default path. -const ENV_VAR_CONFIG: &str = "TORRUST_INDEX_CONFIG"; - -/// Token needed to communicate with the Torrust Tracker -const ENV_VAR_API_ADMIN_TOKEN: &str = "TORRUST_INDEX_TRACKER_API_TOKEN"; - -/// The `index.toml` file location. -pub const ENV_VAR_PATH_CONFIG: &str = "TORRUST_INDEX_PATH_CONFIG"; - // Default values pub const DEFAULT_PATH_CONFIG: &str = "./share/default/config/index.development.sqlite3.toml"; /// If present, CORS will be permissive. -pub const ENV_VAR_CORS_PERMISSIVE: &str = "TORRUST_INDEX_BACK_CORS_PERMISSIVE"; +pub const ENV_VAR_CORS_PERMISSIVE: &str = "TORRUST_INDEX_API_CORS_PERMISSIVE"; /// It loads the application configuration from the environment. /// /// There are two methods to inject the configuration: /// /// 1. By using a config file: `index.toml`. -/// 2. Environment variable: `TORRUST_INDEX_CONFIG`. The variable contains the same contents as the `index.toml` file. +/// 2. Environment variable: `TORRUST_INDEX_CONFIG_TOML`. The variable contains the same contents as the `index.toml` file. /// /// Environment variable has priority over the config file. /// @@ -36,16 +26,10 @@ pub const ENV_VAR_CORS_PERMISSIVE: &str = "TORRUST_INDEX_BACK_CORS_PERMISSIVE"; /// # Panics /// /// Will panic if it can't load the configuration from either -/// `./index.toml` file or the env var `TORRUST_INDEX_CONFIG`. +/// `./index.toml` file or the env var `TORRUST_INDEX_CONFIG_TOML`. #[must_use] pub fn initialize_configuration() -> Configuration { - let info = Info::new( - ENV_VAR_CONFIG.to_string(), - ENV_VAR_PATH_CONFIG.to_string(), - DEFAULT_PATH_CONFIG.to_string(), - ENV_VAR_API_ADMIN_TOKEN.to_string(), - ) - .unwrap(); + let info = Info::new(DEFAULT_PATH_CONFIG.to_string()).unwrap(); Configuration::load(&info).unwrap() } diff --git a/src/bootstrap/logging.rs b/src/bootstrap/logging.rs index 8546720f..d9d141d9 100644 --- a/src/bootstrap/logging.rs +++ b/src/bootstrap/logging.rs @@ -6,49 +6,61 @@ //! - `Info` //! - `Debug` //! - `Trace` -use std::str::FromStr; use std::sync::Once; -use log::{info, LevelFilter}; +use tracing::info; +use tracing::level_filters::LevelFilter; + +use crate::config::Threshold; static INIT: Once = Once::new(); -pub fn setup(log_level: &Option) { - let level = config_level_or_default(log_level); +pub fn setup(threshold: &Threshold) { + let tracing_level_filter: LevelFilter = threshold.clone().into(); - if level == log::LevelFilter::Off { + if tracing_level_filter == LevelFilter::OFF { return; } INIT.call_once(|| { - stdout_config(level); + tracing_stdout_init(tracing_level_filter, &TraceStyle::Default); }); } -fn config_level_or_default(log_level: &Option) -> LevelFilter { - match log_level { - None => log::LevelFilter::Info, - Some(level) => LevelFilter::from_str(level).unwrap(), - } +fn tracing_stdout_init(filter: LevelFilter, style: &TraceStyle) { + let builder = tracing_subscriber::fmt().with_max_level(filter); + + let () = match style { + TraceStyle::Default => builder.init(), + TraceStyle::Pretty(display_filename) => builder.pretty().with_file(*display_filename).init(), + TraceStyle::Compact => builder.compact().init(), + TraceStyle::Json => builder.json().init(), + }; + + info!("Logging initialized"); } -fn stdout_config(level: LevelFilter) { - if let Err(_err) = fern::Dispatch::new() - .format(|out, message, record| { - out.finish(format_args!( - "{} [{}][{}] {}", - chrono::Local::now().format("%+"), - record.target(), - record.level(), - message - )); - }) - .level(level) - .chain(std::io::stdout()) - .apply() - { - panic!("Failed to initialize logging.") - } +#[derive(Debug)] +pub enum TraceStyle { + Default, + Pretty(bool), + Compact, + Json, +} + +impl std::fmt::Display for TraceStyle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let style = match self { + TraceStyle::Default => "Default Style", + TraceStyle::Pretty(path) => match path { + true => "Pretty Style with File Paths", + false => "Pretty Style without File Paths", + }, - info!("logging initialized."); + TraceStyle::Compact => "Compact Style", + TraceStyle::Json => "Json Format", + }; + + f.write_str(style) + } } diff --git a/src/cache/image/manager.rs b/src/cache/image/manager.rs index 24a7e771..e954bcbd 100644 --- a/src/cache/image/manager.rs +++ b/src/cache/image/manager.rs @@ -7,7 +7,7 @@ use tokio::sync::RwLock; use crate::cache::BytesCache; use crate::config::Configuration; -use crate::models::user::UserCompact; +use crate::models::user::UserId; pub enum Error { UrlIsUnreachable, @@ -125,33 +125,26 @@ impl ImageCacheService { /// # Errors /// /// Return a `Error::Unauthenticated` if the user has not been authenticated. - pub async fn get_image_by_url(&self, url: &str, opt_user: Option) -> Result { + pub async fn get_image_by_url(&self, url: &str, user_id: UserId) -> Result { if let Some(entry) = self.image_cache.read().await.get(url).await { return Ok(entry.bytes); } + self.check_user_quota(&user_id).await?; - match opt_user { - None => Err(Error::Unauthenticated), + let image_bytes = self.get_image_from_url_as_bytes(url).await?; - Some(user) => { - self.check_user_quota(&user).await?; + self.check_image_size(&image_bytes).await?; - let image_bytes = self.get_image_from_url_as_bytes(url).await?; + // These two functions could be executed after returning the image to the client, + // but than we would need a dedicated task or thread that executes these functions. + // This can be problematic if a task is spawned after every user request. + // Since these functions execute very fast, I don't see a reason to further optimize this. + // For now. + self.update_image_cache(url, &image_bytes).await?; - self.check_image_size(&image_bytes).await?; + self.update_user_quota(&user_id, image_bytes.len()).await?; - // These two functions could be executed after returning the image to the client, - // but than we would need a dedicated task or thread that executes these functions. - // This can be problematic if a task is spawned after every user request. - // Since these functions execute very fast, I don't see a reason to further optimize this. - // For now. - self.update_image_cache(url, &image_bytes).await?; - - self.update_user_quota(&user, image_bytes.len()).await?; - - Ok(image_bytes) - } - } + Ok(image_bytes) } async fn get_image_from_url_as_bytes(&self, url: &str) -> Result { @@ -176,8 +169,8 @@ impl ImageCacheService { res.bytes().await.map_err(|_| Error::UrlIsNotAnImage) } - async fn check_user_quota(&self, user: &UserCompact) -> Result<(), Error> { - if let Some(quota) = self.user_quotas.read().await.get(&user.user_id) { + async fn check_user_quota(&self, user_id: &UserId) -> Result<(), Error> { + if let Some(quota) = self.user_quotas.read().await.get(user_id) { if quota.is_reached() { return Err(Error::UserQuotaMet); } @@ -211,24 +204,24 @@ impl ImageCacheService { Ok(()) } - async fn update_user_quota(&self, user: &UserCompact, amount: usize) -> Result<(), Error> { + async fn update_user_quota(&self, user_id: &UserId, amount: usize) -> Result<(), Error> { let settings = self.cfg.settings.read().await; let mut quota = self .user_quotas .read() .await - .get(&user.user_id) + .get(user_id) .cloned() .unwrap_or(ImageCacheQuota::new( - user.user_id, + *user_id, settings.image_cache.user_quota_bytes, settings.image_cache.user_quota_period_seconds, )); let _ = quota.add_usage(amount); - let _ = self.user_quotas.write().await.insert(user.user_id, quota); + let _ = self.user_quotas.write().await.insert(*user_id, quota); Ok(()) } diff --git a/src/common.rs b/src/common.rs index bf16889a..f7bb65d6 100644 --- a/src/common.rs +++ b/src/common.rs @@ -10,11 +10,12 @@ use crate::services::torrent::{ DbCanonicalInfoHashGroupRepository, DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, DbTorrentTagRepository, }; -use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; -use crate::services::{proxy, settings, torrent}; +use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, Repository}; +use crate::services::{about, proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; -use crate::web::api::v1::auth::Authentication; +use crate::web::api::server::v1::auth::Authentication; use crate::{mailer, tracker}; + pub type Username = String; pub struct AppData { @@ -30,7 +31,7 @@ pub struct AppData { // Repositories pub category_repository: Arc, pub tag_repository: Arc, - pub user_repository: Arc, + pub user_repository: Arc>, pub user_authentication_repository: Arc, pub user_profile_repository: Arc, pub torrent_repository: Arc, @@ -48,7 +49,9 @@ pub struct AppData { pub settings_service: Arc, pub torrent_service: Arc, pub registration_service: Arc, + pub profile_service: Arc, pub ban_service: Arc, + pub about_service: Arc, } impl AppData { @@ -66,7 +69,7 @@ impl AppData { // Repositories category_repository: Arc, tag_repository: Arc, - user_repository: Arc, + user_repository: Arc>, user_authentication_repository: Arc, user_profile_repository: Arc, torrent_repository: Arc, @@ -84,7 +87,9 @@ impl AppData { settings_service: Arc, torrent_service: Arc, registration_service: Arc, + profile_service: Arc, ban_service: Arc, + about_service: Arc, ) -> AppData { AppData { cfg, @@ -117,7 +122,9 @@ impl AppData { settings_service, torrent_service, registration_service, + profile_service, ban_service, + about_service, } } } diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 941c3921..00000000 --- a/src/config.rs +++ /dev/null @@ -1,524 +0,0 @@ -//! Configuration for the application. -use std::path::Path; -use std::sync::Arc; -use std::{env, fs}; - -use config::{Config, ConfigError, File, FileFormat}; -use log::warn; -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use tokio::sync::RwLock; -use torrust_index_located_error::{Located, LocatedError}; - -/// Information required for loading config -#[derive(Debug, Default, Clone)] -pub struct Info { - index_toml: String, - tracker_api_token: Option, -} - -impl Info { - /// Build Configuration Info - /// - /// # Examples - /// - /// ```no_run - /// # use torrust_index::config::Info; - /// # let (env_var_config, env_var_path_config, default_path_config, env_var_tracker_api_token) = ("".to_string(), "".to_string(), "".to_string(), "".to_string()); - /// let result = Info::new(env_var_config, env_var_path_config, default_path_config, env_var_tracker_api_token); - /// ``` - /// - /// # Errors - /// - /// Will return `Err` if unable to obtain a configuration. - /// - #[allow(clippy::needless_pass_by_value)] - pub fn new( - env_var_config: String, - env_var_path_config: String, - default_path_config: String, - env_var_tracker_api_token: String, - ) -> Result { - let index_toml = if let Ok(index_toml) = env::var(&env_var_config) { - println!("Loading configuration from env var {env_var_config} ..."); - - index_toml - } else { - let config_path = if let Ok(config_path) = env::var(env_var_path_config) { - println!("Loading configuration file: `{config_path}` ..."); - - config_path - } else { - println!("Loading default configuration file: `{default_path_config}` ..."); - - default_path_config - }; - - fs::read_to_string(config_path) - .map_err(|e| Error::UnableToLoadFromConfigFile { - source: (Arc::new(e) as Arc).into(), - })? - .parse() - .map_err(|_e: std::convert::Infallible| Error::Infallible)? - }; - let tracker_api_token = env::var(env_var_tracker_api_token).ok(); - - Ok(Self { - index_toml, - tracker_api_token, - }) - } -} - -/// Errors that can occur when loading the configuration. -#[derive(Error, Debug)] -pub enum Error { - /// Unable to load the configuration from the environment variable. - /// This error only occurs if there is no configuration file and the - /// `TORRUST_TRACKER_CONFIG` environment variable is not set. - #[error("Unable to load from Environmental Variable: {source}")] - UnableToLoadFromEnvironmentVariable { - source: LocatedError<'static, dyn std::error::Error + Send + Sync>, - }, - - #[error("Unable to load from Config File: {source}")] - UnableToLoadFromConfigFile { - source: LocatedError<'static, dyn std::error::Error + Send + Sync>, - }, - - /// Unable to load the configuration from the configuration file. - #[error("Failed processing the configuration: {source}")] - ConfigError { source: LocatedError<'static, ConfigError> }, - - #[error("The error for errors that can never happen.")] - Infallible, -} - -impl From for Error { - #[track_caller] - fn from(err: ConfigError) -> Self { - Self::ConfigError { - source: Located(err).into(), - } - } -} - -/// Information displayed to the user in the website. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Website { - /// The name of the website. - pub name: String, -} - -impl Default for Website { - fn default() -> Self { - Self { - name: "Torrust".to_string(), - } - } -} - -/// See `TrackerMode` in [`torrust-tracker-primitives`](https://docs.rs/torrust-tracker-primitives) -/// crate for more information. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum TrackerMode { - // todo: use https://crates.io/crates/torrust-tracker-primitives - /// Will track every new info hash and serve every peer. - Public, - /// Will only serve authenticated peers. - Private, - /// Will only track whitelisted info hashes. - Whitelisted, - /// Will only track whitelisted info hashes and serve authenticated peers. - PrivateWhitelisted, -} - -impl Default for TrackerMode { - fn default() -> Self { - Self::Public - } -} - -/// Configuration for the associated tracker. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Tracker { - /// Connection string for the tracker. For example: `udp://TRACKER_IP:6969`. - pub url: String, - /// The mode of the tracker. For example: `Public`. - /// See `TrackerMode` in [`torrust-tracker-primitives`](https://docs.rs/torrust-tracker-primitives) - /// crate for more information. - pub mode: TrackerMode, - /// The url of the tracker API. For example: `http://localhost:1212`. - pub api_url: String, - /// The token used to authenticate with the tracker API. - pub token: String, - /// The amount of seconds the token is valid. - pub token_valid_seconds: u64, -} - -impl Tracker { - fn override_tracker_api_token(&mut self, tracker_api_token: &str) { - self.token = tracker_api_token.to_string(); - } -} - -impl Default for Tracker { - fn default() -> Self { - Self { - url: "udp://localhost:6969".to_string(), - mode: TrackerMode::default(), - api_url: "http://localhost:1212".to_string(), - token: "MyAccessToken".to_string(), - token_valid_seconds: 7_257_600, - } - } -} - -/// Port number representing that the OS will choose one randomly from the available ports. -/// -/// It's the port number `0` -pub const FREE_PORT: u16 = 0; - -/// The the base URL for the API. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Network { - /// The port to listen on. Default to `3001`. - pub port: u16, - /// The base URL for the API. For example: `http://localhost`. - /// If not set, the base URL will be inferred from the request. - pub base_url: Option, -} - -impl Default for Network { - fn default() -> Self { - Self { - port: 3001, - base_url: None, - } - } -} - -/// Whether the email is required on signup or not. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum EmailOnSignup { - /// The email is required on signup. - Required, - /// The email is optional on signup. - Optional, - /// The email is not allowed on signup. It will only be ignored if provided. - None, // code-review: rename to `Ignored`? -} - -impl Default for EmailOnSignup { - fn default() -> Self { - Self::Optional - } -} - -/// Authentication options. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Auth { - /// Whether or not to require an email on signup. - pub email_on_signup: EmailOnSignup, - /// The minimum password length. - pub min_password_length: usize, - /// The maximum password length. - pub max_password_length: usize, - /// The secret key used to sign JWT tokens. - pub secret_key: String, -} - -impl Default for Auth { - fn default() -> Self { - Self { - email_on_signup: EmailOnSignup::default(), - min_password_length: 6, - max_password_length: 64, - secret_key: "MaxVerstappenWC2021".to_string(), - } - } -} - -/// Database configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Database { - /// The connection string for the database. For example: `sqlite://data.db?mode=rwc`. - pub connect_url: String, -} - -impl Default for Database { - fn default() -> Self { - Self { - connect_url: "sqlite://data.db?mode=rwc".to_string(), - } - } -} - -/// SMTP configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Mail { - /// Whether or not to enable email verification on signup. - pub email_verification_enabled: bool, - /// The email address to send emails from. - pub from: String, - /// The email address to reply to. - pub reply_to: String, - /// The username to use for SMTP authentication. - pub username: String, - /// The password to use for SMTP authentication. - pub password: String, - /// The SMTP server to use. - pub server: String, - /// The SMTP port to use. - pub port: u16, -} - -impl Default for Mail { - fn default() -> Self { - Self { - email_verification_enabled: false, - from: "example@email.com".to_string(), - reply_to: "noreply@email.com".to_string(), - username: String::default(), - password: String::default(), - server: String::default(), - port: 25, - } - } -} - -/// Configuration for the image proxy cache. -/// -/// Users have a cache quota per period. For example: 100MB per day. -/// When users are navigating the site, they will be downloading images that are -/// embedded in the torrent description. These images will be cached in the -/// proxy. The proxy will not download new images if the user has reached the -/// quota. -#[allow(clippy::module_name_repetitions)] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ImageCache { - /// Maximum time in seconds to wait for downloading the image form the original source. - pub max_request_timeout_ms: u64, - /// Cache size in bytes. - pub capacity: usize, - /// Maximum size in bytes for a single image. - pub entry_size_limit: usize, - /// Users have a cache quota per period. For example: 100MB per day. - /// This is the period in seconds (1 day in seconds). - pub user_quota_period_seconds: u64, - /// Users have a cache quota per period. For example: 100MB per day. - /// This is the maximum size in bytes (100MB in bytes). - pub user_quota_bytes: usize, -} - -/// Core configuration for the API -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Api { - /// The default page size for torrent lists. - pub default_torrent_page_size: u8, - /// The maximum page size for torrent lists. - pub max_torrent_page_size: u8, -} - -impl Default for Api { - fn default() -> Self { - Self { - default_torrent_page_size: 10, - max_torrent_page_size: 30, - } - } -} - -/// Configuration for the tracker statistics importer. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TrackerStatisticsImporter { - /// The interval in seconds to get statistics from the tracker. - pub torrent_info_update_interval: u64, -} - -impl Default for TrackerStatisticsImporter { - fn default() -> Self { - Self { - torrent_info_update_interval: 3600, - } - } -} - -impl Default for ImageCache { - fn default() -> Self { - Self { - max_request_timeout_ms: 1000, - capacity: 128_000_000, - entry_size_limit: 4_000_000, - user_quota_period_seconds: 3600, - user_quota_bytes: 64_000_000, - } - } -} - -/// The whole configuration for the index. -#[derive(Debug, Default, Clone, Serialize, Deserialize)] -pub struct TorrustIndex { - /// Logging level. Possible values are: `Off`, `Error`, `Warn`, `Info`, - /// `Debug` and `Trace`. Default is `Info`. - pub log_level: Option, - /// The website customizable values. - pub website: Website, - /// The tracker configuration. - pub tracker: Tracker, - /// The network configuration. - pub net: Network, - /// The authentication configuration. - pub auth: Auth, - /// The database configuration. - pub database: Database, - /// The SMTP configuration. - pub mail: Mail, - /// The image proxy cache configuration. - pub image_cache: ImageCache, - /// The API configuration. - pub api: Api, - /// The tracker statistics importer job configuration. - pub tracker_statistics_importer: TrackerStatisticsImporter, -} - -impl TorrustIndex { - fn override_tracker_api_token(&mut self, tracker_api_token: &str) { - self.tracker.override_tracker_api_token(tracker_api_token); - } -} - -/// The configuration service. -#[derive(Debug)] -pub struct Configuration { - /// The state of the configuration. - pub settings: RwLock, - /// The path to the configuration file. This is `None` if the configuration - /// was loaded from the environment. - pub config_path: Option, -} - -impl Default for Configuration { - fn default() -> Configuration { - Configuration { - settings: RwLock::new(TorrustIndex::default()), - config_path: None, - } - } -} - -impl Configuration { - /// Loads the configuration from the configuration file. - /// - /// # Errors - /// - /// This function will return an error no configuration in the `CONFIG_PATH` exists, and a new file is is created. - /// This function will return an error if the `config` is not a valid `TorrustConfig` document. - pub async fn load_from_file(config_path: &str) -> Result { - let config_builder = Config::builder(); - - #[allow(unused_assignments)] - let mut config = Config::default(); - - if Path::new(config_path).exists() { - config = config_builder.add_source(File::with_name(config_path)).build()?; - } else { - warn!("No config file found. Creating default config file ..."); - - let config = Configuration::default(); - let () = config.save_to_file(config_path).await; - - return Err(ConfigError::Message(format!( - "No config file found. Created default config file in {config_path}. Edit the file and start the application." - ))); - } - - let torrust_config: TorrustIndex = match config.try_deserialize() { - Ok(data) => Ok(data), - Err(e) => Err(ConfigError::Message(format!("Errors while processing config: {e}."))), - }?; - - Ok(Configuration { - settings: RwLock::new(torrust_config), - config_path: Some(config_path.to_string()), - }) - } - - /// Loads the configuration from the `Info` struct. The whole - /// configuration in toml format is included in the `info.index_toml` string. - /// - /// Optionally will override the tracker api token. - /// - /// # Errors - /// - /// Will return `Err` if the environment variable does not exist or has a bad configuration. - pub fn load(info: &Info) -> Result { - let config_builder = Config::builder() - .add_source(File::from_str(&info.index_toml, FileFormat::Toml)) - .build()?; - let mut index_config: TorrustIndex = config_builder.try_deserialize()?; - - if let Some(ref token) = info.tracker_api_token { - index_config.override_tracker_api_token(token); - }; - - Ok(Configuration { - settings: RwLock::new(index_config), - config_path: None, - }) - } - - /// Returns the save to file of this [`Configuration`]. - /// - /// # Panics - /// - /// This function will panic if it can't write to the file. - pub async fn save_to_file(&self, config_path: &str) { - let settings = self.settings.read().await; - - let toml_string = toml::to_string(&*settings).expect("Could not encode TOML value"); - - drop(settings); - - fs::write(config_path, toml_string).expect("Could not write to file!"); - } - - pub async fn get_all(&self) -> TorrustIndex { - let settings_lock = self.settings.read().await; - - settings_lock.clone() - } - - pub async fn get_public(&self) -> ConfigurationPublic { - let settings_lock = self.settings.read().await; - - ConfigurationPublic { - website_name: settings_lock.website.name.clone(), - tracker_url: settings_lock.tracker.url.clone(), - tracker_mode: settings_lock.tracker.mode.clone(), - email_on_signup: settings_lock.auth.email_on_signup.clone(), - } - } - - pub async fn get_site_name(&self) -> String { - let settings_lock = self.settings.read().await; - - settings_lock.website.name.clone() - } - - pub async fn get_api_base_url(&self) -> Option { - let settings_lock = self.settings.read().await; - - settings_lock.net.base_url.clone() - } -} - -/// The public index configuration. -/// There is an endpoint to get this configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConfigurationPublic { - website_name: String, - tracker_url: String, - tracker_mode: TrackerMode, - email_on_signup: EmailOnSignup, -} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 00000000..a5935242 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,638 @@ +//! Configuration for the application. +pub mod v2; +pub mod validator; + +use std::env; +use std::sync::Arc; + +use camino::Utf8PathBuf; +use derive_more::Display; +use figment::providers::{Env, Format, Serialized, Toml}; +use figment::Figment; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, NoneAsEmptyString}; +use thiserror::Error; +use tokio::sync::RwLock; +use torrust_index_located_error::LocatedError; + +use crate::web::api::server::DynError; + +pub type Settings = v2::Settings; + +pub type Api = v2::api::Api; + +pub type Registration = v2::registration::Registration; +pub type Email = v2::registration::Email; + +pub type Auth = v2::auth::Auth; +pub type SecretKey = v2::auth::ClaimTokenPepper; +pub type PasswordConstraints = v2::auth::PasswordConstraints; + +pub type Database = v2::database::Database; + +pub type ImageCache = v2::image_cache::ImageCache; + +pub type Mail = v2::mail::Mail; +pub type Smtp = v2::mail::Smtp; +pub type Credentials = v2::mail::Credentials; + +pub type Network = v2::net::Network; + +pub type TrackerStatisticsImporter = v2::tracker_statistics_importer::TrackerStatisticsImporter; + +pub type Tracker = v2::tracker::Tracker; +pub type ApiToken = v2::tracker::ApiToken; + +pub type Logging = v2::logging::Logging; +pub type Threshold = v2::logging::Threshold; + +pub type Website = v2::website::Website; + +/// Configuration version +const VERSION_2: &str = "2.0.0"; + +/// Prefix for env vars that overwrite configuration options. +const CONFIG_OVERRIDE_PREFIX: &str = "TORRUST_INDEX_CONFIG_OVERRIDE_"; + +/// Path separator in env var names for nested values in configuration. +const CONFIG_OVERRIDE_SEPARATOR: &str = "__"; + +/// The whole `index.toml` file content. It has priority over the config file. +/// Even if the file is not on the default path. +pub const ENV_VAR_CONFIG_TOML: &str = "TORRUST_INDEX_CONFIG_TOML"; + +/// The `index.toml` file location. +pub const ENV_VAR_CONFIG_TOML_PATH: &str = "TORRUST_INDEX_CONFIG_TOML_PATH"; + +pub const LATEST_VERSION: &str = "2.0.0"; + +/// Info about the configuration specification. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)] +#[display(fmt = "Metadata(app: {app}, purpose: {purpose}, schema_version: {schema_version})")] +pub struct Metadata { + /// The application this configuration is valid for. + #[serde(default = "Metadata::default_app")] + app: App, + + /// The purpose of this parsed file. + #[serde(default = "Metadata::default_purpose")] + purpose: Purpose, + + /// The schema version for the configuration. + #[serde(default = "Metadata::default_schema_version")] + #[serde(flatten)] + schema_version: Version, +} + +impl Default for Metadata { + fn default() -> Self { + Self { + app: Self::default_app(), + purpose: Self::default_purpose(), + schema_version: Self::default_schema_version(), + } + } +} + +impl Metadata { + fn default_app() -> App { + App::TorrustIndex + } + + fn default_purpose() -> Purpose { + Purpose::Configuration + } + + fn default_schema_version() -> Version { + Version::latest() + } +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum App { + TorrustIndex, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)] +#[serde(rename_all = "lowercase")] +pub enum Purpose { + Configuration, +} + +/// The configuration version. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)] +#[serde(rename_all = "lowercase")] +pub struct Version { + #[serde(default = "Version::default_semver")] + schema_version: String, +} + +impl Default for Version { + fn default() -> Self { + Self { + schema_version: Self::default_semver(), + } + } +} + +impl Version { + fn new(semver: &str) -> Self { + Self { + schema_version: semver.to_owned(), + } + } + + fn latest() -> Self { + Self { + schema_version: LATEST_VERSION.to_string(), + } + } + + fn default_semver() -> String { + LATEST_VERSION.to_string() + } +} + +/// Information required for loading config +#[derive(Debug, Default, Clone)] +pub struct Info { + config_toml: Option, + config_toml_path: String, +} + +impl Info { + /// Build configuration Info. + /// + /// # Errors + /// + /// Will return `Err` if unable to obtain a configuration. + /// + #[allow(clippy::needless_pass_by_value)] + pub fn new(default_config_toml_path: String) -> Result { + let env_var_config_toml = ENV_VAR_CONFIG_TOML.to_string(); + let env_var_config_toml_path = ENV_VAR_CONFIG_TOML_PATH.to_string(); + + let config_toml = if let Ok(config_toml) = env::var(env_var_config_toml) { + println!("Loading extra configuration from environment variable {config_toml} ..."); + Some(config_toml) + } else { + None + }; + + let config_toml_path = if let Ok(config_toml_path) = env::var(env_var_config_toml_path) { + println!("Loading extra configuration from file: `{config_toml_path}` ..."); + config_toml_path + } else { + println!("Loading extra configuration from default configuration file: `{default_config_toml_path}` ..."); + default_config_toml_path + }; + + Ok(Self { + config_toml, + config_toml_path, + }) + } +} + +/// Errors that can occur when loading the configuration. +#[derive(Error, Debug)] +pub enum Error { + /// Unable to load the configuration from the environment variable. + /// This error only occurs if there is no configuration file and the + /// `TORRUST_INDEX_CONFIG_TOML` environment variable is not set. + #[error("Unable to load from Environmental Variable: {source}")] + UnableToLoadFromEnvironmentVariable { + source: LocatedError<'static, dyn std::error::Error + Send + Sync>, + }, + + #[error("Unable to load from Config File: {source}")] + UnableToLoadFromConfigFile { + source: LocatedError<'static, dyn std::error::Error + Send + Sync>, + }, + + /// Unable to load the configuration from the configuration file. + #[error("Failed processing the configuration: {source}")] + ConfigError { + source: LocatedError<'static, dyn std::error::Error + Send + Sync>, + }, + + #[error("The error for errors that can never happen.")] + Infallible, + + #[error("Unsupported configuration version: {version}")] + UnsupportedVersion { version: Version }, + + #[error("Missing mandatory configuration option. Option path: {path}")] + MissingMandatoryOption { path: String }, +} + +impl From for Error { + #[track_caller] + fn from(err: figment::Error) -> Self { + Self::ConfigError { + source: (Arc::new(err) as DynError).into(), + } + } +} + +/// Port number representing that the OS will choose one randomly from the available ports. +/// +/// It's the port number `0` +pub const FREE_PORT: u16 = 0; + +#[serde_as] +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default)] +pub struct Tsl { + /// Path to the SSL certificate file. + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default = "Tsl::default_ssl_cert_path")] + pub ssl_cert_path: Option, + /// Path to the SSL key file. + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default = "Tsl::default_ssl_key_path")] + pub ssl_key_path: Option, +} + +impl Tsl { + #[allow(clippy::unnecessary_wraps)] + fn default_ssl_cert_path() -> Option { + Some(Utf8PathBuf::new()) + } + + #[allow(clippy::unnecessary_wraps)] + fn default_ssl_key_path() -> Option { + Some(Utf8PathBuf::new()) + } +} + +/// The configuration service. +#[derive(Debug)] +pub struct Configuration { + /// The state of the configuration. + pub settings: RwLock, +} + +impl Default for Configuration { + fn default() -> Configuration { + Configuration { + settings: RwLock::new(Settings::default()), + } + } +} + +impl Configuration { + /// Loads the configuration from the `Info` struct. + /// + /// # Errors + /// + /// Will return `Err` if the environment variable does not exist or has a bad configuration. + pub fn load(info: &Info) -> Result { + let settings = Self::load_settings(info)?; + + Ok(Configuration { + settings: RwLock::new(settings), + }) + } + + /// Loads the settings from the `Info` struct. The whole + /// configuration in toml format is included in the `info.index_toml` string. + /// + /// Configuration provided via env var has priority over config file path. + /// + /// # Errors + /// + /// Will return `Err` if the environment variable does not exist or has a bad configuration. + pub fn load_settings(info: &Info) -> Result { + // Load configuration provided by the user, prioritizing env vars + let figment = if let Some(config_toml) = &info.config_toml { + // Config in env var has priority over config file path + Figment::from(Toml::string(config_toml)).merge(Env::prefixed(CONFIG_OVERRIDE_PREFIX).split(CONFIG_OVERRIDE_SEPARATOR)) + } else { + Figment::from(Toml::file(&info.config_toml_path)) + .merge(Env::prefixed(CONFIG_OVERRIDE_PREFIX).split(CONFIG_OVERRIDE_SEPARATOR)) + }; + + // Make sure user has provided the mandatory options. + Self::check_mandatory_options(&figment)?; + + // Fill missing options with default values. + let figment = figment.join(Serialized::defaults(Settings::default())); + + // Build final configuration. + let settings: Settings = figment.extract()?; + + if settings.metadata.schema_version != Version::new(VERSION_2) { + return Err(Error::UnsupportedVersion { + version: settings.metadata.schema_version, + }); + } + + Ok(settings) + } + + /// Some configuration options are mandatory. The tracker will panic if + /// the user doesn't provide an explicit value for them from one of the + /// configuration sources: TOML or ENV VARS. + /// + /// # Errors + /// + /// Will return an error if a mandatory configuration option is only + /// obtained by default value (code), meaning the user hasn't overridden it. + fn check_mandatory_options(figment: &Figment) -> Result<(), Error> { + let mandatory_options = [ + "auth.user_claim_token_pepper", + "logging.threshold", + "metadata.schema_version", + "tracker.token", + ]; + + for mandatory_option in mandatory_options { + figment + .find_value(mandatory_option) + .map_err(|_err| Error::MissingMandatoryOption { + path: mandatory_option.to_owned(), + })?; + } + + Ok(()) + } + + pub async fn get_all(&self) -> Settings { + let settings_lock = self.settings.read().await; + + settings_lock.clone() + } + + pub async fn get_site_name(&self) -> String { + let settings_lock = self.settings.read().await; + + settings_lock.website.name.clone() + } + + pub async fn get_api_base_url(&self) -> Option { + let settings_lock = self.settings.read().await; + settings_lock.net.base_url.as_ref().map(std::string::ToString::to_string) + } +} + +#[cfg(test)] +mod tests { + + use url::Url; + + use crate::config::{ApiToken, Configuration, Info, SecretKey, Settings}; + + #[cfg(test)] + fn default_config_toml() -> String { + let config = r#"[metadata] + app = "torrust-index" + purpose = "configuration" + schema_version = "2.0.0" + + [logging] + threshold = "info" + + [website] + name = "Torrust" + + [tracker] + api_url = "http://localhost:1212/" + listed = false + private = false + token = "MyAccessToken" + token_valid_seconds = 7257600 + url = "udp://localhost:6969" + + [net] + bind_address = "0.0.0.0:3001" + + [auth] + user_claim_token_pepper = "MaxVerstappenWC2021" + + [auth.password_constraints] + max_password_length = 64 + min_password_length = 6 + + [database] + connect_url = "sqlite://data.db?mode=rwc" + + [mail] + from = "example@email.com" + reply_to = "noreply@email.com" + + [mail.smtp] + port = 25 + server = "" + + [mail.smtp.credentials] + password = "" + username = "" + + [image_cache] + capacity = 128000000 + entry_size_limit = 4000000 + max_request_timeout_ms = 1000 + user_quota_bytes = 64000000 + user_quota_period_seconds = 3600 + + [api] + default_torrent_page_size = 10 + max_torrent_page_size = 30 + + [tracker_statistics_importer] + port = 3002 + torrent_info_update_interval = 3600 + "# + .lines() + .map(str::trim_start) + .collect::>() + .join("\n"); + config + } + + #[tokio::test] + async fn configuration_should_build_settings_with_default_values() { + let configuration = Configuration::default().get_all().await; + + let toml = toml::to_string(&configuration).expect("Could not encode TOML value for configuration"); + + assert_eq!(toml, default_config_toml()); + } + + #[tokio::test] + async fn configuration_should_return_all_settings() { + let configuration = Configuration::default().get_all().await; + + let toml = toml::to_string(&configuration).expect("Could not encode TOML value for configuration"); + + assert_eq!(toml, default_config_toml()); + } + + #[tokio::test] + async fn configuration_should_return_the_site_name() { + let configuration = Configuration::default(); + assert_eq!(configuration.get_site_name().await, "Torrust".to_string()); + } + + #[tokio::test] + async fn configuration_should_return_the_api_base_url() { + let configuration = Configuration::default(); + assert_eq!(configuration.get_api_base_url().await, None); + + let mut settings_lock = configuration.settings.write().await; + settings_lock.net.base_url = Some(Url::parse("http://localhost").unwrap()); + drop(settings_lock); + + assert_eq!(configuration.get_api_base_url().await, Some("http://localhost/".to_string())); + } + + #[tokio::test] + async fn configuration_could_be_loaded_from_a_toml_string() { + figment::Jail::expect_with(|jail| { + jail.create_dir("templates")?; + jail.create_file("templates/verify.html", "EMAIL TEMPLATE")?; + + let info = Info { + config_toml: Some(default_config_toml()), + config_toml_path: String::new(), + }; + + let settings = Configuration::load_settings(&info).expect("Failed to load configuration from info"); + + assert_eq!(settings, Settings::default()); + + Ok(()) + }); + } + + #[test] + fn configuration_should_use_the_default_values_when_only_the_mandatory_options_are_provided_by_the_user_via_toml_file() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "index.toml", + r#" + [metadata] + schema_version = "2.0.0" + + [logging] + threshold = "info" + + [tracker] + token = "MyAccessToken" + + [auth] + user_claim_token_pepper = "MaxVerstappenWC2021" + "#, + )?; + + let info = Info { + config_toml: None, + config_toml_path: "index.toml".to_string(), + }; + + let settings = Configuration::load_settings(&info).expect("Could not load configuration from file"); + + assert_eq!(settings, Settings::default()); + + Ok(()) + }); + } + + #[test] + fn configuration_should_use_the_default_values_when_only_the_mandatory_options_are_provided_by_the_user_via_toml_content() { + figment::Jail::expect_with(|_jail| { + let config_toml = r#" + [metadata] + schema_version = "2.0.0" + + [logging] + threshold = "info" + + [tracker] + token = "MyAccessToken" + + [auth] + user_claim_token_pepper = "MaxVerstappenWC2021" + "# + .to_string(); + + let info = Info { + config_toml: Some(config_toml), + config_toml_path: String::new(), + }; + + let settings = Configuration::load_settings(&info).expect("Could not load configuration from file"); + + assert_eq!(settings, Settings::default()); + + Ok(()) + }); + } + + #[tokio::test] + async fn configuration_should_allow_to_override_the_tracker_api_token_provided_in_the_toml_file() { + figment::Jail::expect_with(|jail| { + jail.create_dir("templates")?; + jail.create_file("templates/verify.html", "EMAIL TEMPLATE")?; + + jail.set_env("TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN", "OVERRIDDEN API TOKEN"); + + let info = Info { + config_toml: Some(default_config_toml()), + config_toml_path: String::new(), + }; + + let settings = Configuration::load_settings(&info).expect("Could not load configuration from file"); + + assert_eq!(settings.tracker.token, ApiToken::new("OVERRIDDEN API TOKEN")); + + Ok(()) + }); + } + + #[tokio::test] + async fn configuration_should_allow_to_override_the_authentication_user_claim_token_pepper_provided_in_the_toml_file() { + figment::Jail::expect_with(|jail| { + jail.create_dir("templates")?; + jail.create_file("templates/verify.html", "EMAIL TEMPLATE")?; + + jail.set_env( + "TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__USER_CLAIM_TOKEN_PEPPER", + "OVERRIDDEN AUTH SECRET KEY", + ); + + let info = Info { + config_toml: Some(default_config_toml()), + config_toml_path: String::new(), + }; + + let settings = Configuration::load_settings(&info).expect("Could not load configuration from file"); + + assert_eq!( + settings.auth.user_claim_token_pepper, + SecretKey::new("OVERRIDDEN AUTH SECRET KEY") + ); + + Ok(()) + }); + } + + mod semantic_validation { + use url::Url; + + use crate::config::validator::Validator; + use crate::config::Configuration; + + #[tokio::test] + async fn udp_trackers_in_private_mode_are_not_supported() { + let configuration = Configuration::default(); + + let mut settings_lock = configuration.settings.write().await; + settings_lock.tracker.private = true; + settings_lock.tracker.url = Url::parse("udp://localhost:6969").unwrap(); + + assert!(settings_lock.validate().is_err()); + } + } +} diff --git a/src/config/v2/api.rs b/src/config/v2/api.rs new file mode 100644 index 00000000..678c52d7 --- /dev/null +++ b/src/config/v2/api.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +/// Core configuration for the API +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Api { + /// The default page size for torrent lists. + #[serde(default = "Api::default_default_torrent_page_size")] + pub default_torrent_page_size: u8, + + /// The maximum page size for torrent lists. + #[serde(default = "Api::default_max_torrent_page_size")] + pub max_torrent_page_size: u8, +} + +impl Default for Api { + fn default() -> Self { + Self { + default_torrent_page_size: Api::default_default_torrent_page_size(), + max_torrent_page_size: Api::default_max_torrent_page_size(), + } + } +} + +impl Api { + fn default_default_torrent_page_size() -> u8 { + 10 + } + + fn default_max_torrent_page_size() -> u8 { + 30 + } +} diff --git a/src/config/v2/auth.rs b/src/config/v2/auth.rs new file mode 100644 index 00000000..ec123582 --- /dev/null +++ b/src/config/v2/auth.rs @@ -0,0 +1,104 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// Authentication options. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Auth { + /// The secret key used to sign JWT tokens. + #[serde(default = "Auth::default_user_claim_token_pepper")] + pub user_claim_token_pepper: ClaimTokenPepper, + + /// The password constraints + #[serde(default = "Auth::default_password_constraints")] + pub password_constraints: PasswordConstraints, +} + +impl Default for Auth { + fn default() -> Self { + Self { + password_constraints: Self::default_password_constraints(), + user_claim_token_pepper: Self::default_user_claim_token_pepper(), + } + } +} + +impl Auth { + pub fn override_user_claim_token_pepper(&mut self, user_claim_token_pepper: &str) { + self.user_claim_token_pepper = ClaimTokenPepper::new(user_claim_token_pepper); + } + + fn default_user_claim_token_pepper() -> ClaimTokenPepper { + ClaimTokenPepper::new("MaxVerstappenWC2021") + } + + fn default_password_constraints() -> PasswordConstraints { + PasswordConstraints::default() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ClaimTokenPepper(String); + +impl ClaimTokenPepper { + /// # Panics + /// + /// Will panic if the key if empty. + #[must_use] + pub fn new(key: &str) -> Self { + assert!(!key.is_empty(), "secret key cannot be empty"); + + Self(key.to_owned()) + } + + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +impl fmt::Display for ClaimTokenPepper { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PasswordConstraints { + /// The maximum password length. + #[serde(default = "PasswordConstraints::default_max_password_length")] + pub max_password_length: usize, + /// The minimum password length. + #[serde(default = "PasswordConstraints::default_min_password_length")] + pub min_password_length: usize, +} + +impl Default for PasswordConstraints { + fn default() -> Self { + Self { + max_password_length: Self::default_max_password_length(), + min_password_length: Self::default_min_password_length(), + } + } +} + +impl PasswordConstraints { + fn default_min_password_length() -> usize { + 6 + } + + fn default_max_password_length() -> usize { + 64 + } +} + +#[cfg(test)] +mod tests { + use super::ClaimTokenPepper; + + #[test] + #[should_panic(expected = "secret key cannot be empty")] + fn secret_key_can_not_be_empty() { + drop(ClaimTokenPepper::new("")); + } +} diff --git a/src/config/v2/database.rs b/src/config/v2/database.rs new file mode 100644 index 00000000..7dfd5a31 --- /dev/null +++ b/src/config/v2/database.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; +use url::Url; + +/// Database configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Database { + /// The connection URL for the database. For example: + /// + /// Sqlite: `sqlite://data.db?mode=rwc`. + /// Mysql: `mysql://root:root_secret_password@mysql:3306/torrust_index_e2e_testing`. + #[serde(default = "Database::default_connect_url")] + pub connect_url: Url, +} + +impl Default for Database { + fn default() -> Self { + Self { + connect_url: Self::default_connect_url(), + } + } +} + +impl Database { + fn default_connect_url() -> Url { + Url::parse("sqlite://data.db?mode=rwc").unwrap() + } +} diff --git a/src/config/v2/image_cache.rs b/src/config/v2/image_cache.rs new file mode 100644 index 00000000..6f9accf1 --- /dev/null +++ b/src/config/v2/image_cache.rs @@ -0,0 +1,68 @@ +use serde::{Deserialize, Serialize}; + +/// Configuration for the image proxy cache. +/// +/// Users have a cache quota per period. For example: 100MB per day. +/// When users are navigating the site, they will be downloading images that are +/// embedded in the torrent description. These images will be cached in the +/// proxy. The proxy will not download new images if the user has reached the +/// quota. +#[allow(clippy::module_name_repetitions)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ImageCache { + /// Cache size in bytes. + #[serde(default = "ImageCache::default_capacity")] + pub capacity: usize, + + /// Maximum size in bytes for a single image. + #[serde(default = "ImageCache::default_entry_size_limit")] + pub entry_size_limit: usize, + + /// Maximum time in seconds to wait for downloading the image form the original source. + #[serde(default = "ImageCache::default_max_request_timeout_ms")] + pub max_request_timeout_ms: u64, + + /// Users have a cache quota per period. For example: 100MB per day. + /// This is the maximum size in bytes (100MB in bytes). + #[serde(default = "ImageCache::default_user_quota_bytes")] + pub user_quota_bytes: usize, + + /// Users have a cache quota per period. For example: 100MB per day. + /// This is the period in seconds (1 day in seconds). + #[serde(default = "ImageCache::default_user_quota_period_seconds")] + pub user_quota_period_seconds: u64, +} + +impl Default for ImageCache { + fn default() -> Self { + Self { + max_request_timeout_ms: Self::default_max_request_timeout_ms(), + capacity: Self::default_capacity(), + entry_size_limit: Self::default_entry_size_limit(), + user_quota_period_seconds: Self::default_user_quota_period_seconds(), + user_quota_bytes: Self::default_user_quota_bytes(), + } + } +} + +impl ImageCache { + fn default_max_request_timeout_ms() -> u64 { + 1000 + } + + fn default_capacity() -> usize { + 128_000_000 + } + + fn default_entry_size_limit() -> usize { + 4_000_000 + } + + fn default_user_quota_period_seconds() -> u64 { + 3600 + } + + fn default_user_quota_bytes() -> usize { + 64_000_000 + } +} diff --git a/src/config/v2/logging.rs b/src/config/v2/logging.rs new file mode 100644 index 00000000..ec33ad3d --- /dev/null +++ b/src/config/v2/logging.rs @@ -0,0 +1,76 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use tracing::level_filters::LevelFilter; + +/// Core configuration for the API +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Logging { + /// Logging level. Possible values are: `Off`, `Error`, `Warn`, `Info`, `Debug`, `Trace`. + #[serde(default = "Logging::default_threshold")] + pub threshold: Threshold, +} + +impl Default for Logging { + fn default() -> Self { + Self { + threshold: Logging::default_threshold(), + } + } +} + +impl Logging { + fn default_threshold() -> Threshold { + Threshold::Info + } +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Debug, Hash, Clone)] +#[serde(rename_all = "lowercase")] +pub enum Threshold { + /// A level lower than all log security levels. + Off, + /// Corresponds to the `Error` log security level. + Error, + /// Corresponds to the `Warn` log security level. + Warn, + /// Corresponds to the `Info` log security level. + Info, + /// Corresponds to the `Debug` log security level. + Debug, + /// Corresponds to the `Trace` log security level. + Trace, +} + +impl Default for Threshold { + fn default() -> Self { + Self::Info + } +} + +impl fmt::Display for Threshold { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let display_str = match self { + Threshold::Off => "off", + Threshold::Error => "error", + Threshold::Warn => "warn", + Threshold::Info => "info", + Threshold::Debug => "debug", + Threshold::Trace => "trace", + }; + write!(f, "{display_str}") + } +} + +impl From for LevelFilter { + fn from(threshold: Threshold) -> Self { + match threshold { + Threshold::Off => LevelFilter::OFF, + Threshold::Error => LevelFilter::ERROR, + Threshold::Warn => LevelFilter::WARN, + Threshold::Info => LevelFilter::INFO, + Threshold::Debug => LevelFilter::DEBUG, + Threshold::Trace => LevelFilter::TRACE, + } + } +} diff --git a/src/config/v2/mail.rs b/src/config/v2/mail.rs new file mode 100644 index 00000000..296b4d19 --- /dev/null +++ b/src/config/v2/mail.rs @@ -0,0 +1,110 @@ +use lettre::message::Mailbox; +use serde::{Deserialize, Serialize}; + +/// SMTP configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Mail { + /// The email address to send emails from. + #[serde(default = "Mail::default_from")] + pub from: Mailbox, + + /// The email address to reply to. + #[serde(default = "Mail::default_reply_to")] + pub reply_to: Mailbox, + + /// The SMTP server configuration. + #[serde(default = "Mail::default_smtp")] + pub smtp: Smtp, +} + +impl Default for Mail { + fn default() -> Self { + Self { + from: Self::default_from(), + reply_to: Self::default_reply_to(), + smtp: Self::default_smtp(), + } + } +} + +impl Mail { + fn default_from() -> Mailbox { + "example@email.com".parse().expect("valid mailbox") + } + + fn default_reply_to() -> Mailbox { + "noreply@email.com".parse().expect("valid mailbox") + } + + fn default_smtp() -> Smtp { + Smtp::default() + } +} + +/// SMTP configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Smtp { + /// The SMTP port to use. + #[serde(default = "Smtp::default_port")] + pub port: u16, + /// The SMTP server to use. + #[serde(default = "Smtp::default_server")] + pub server: String, + /// The SMTP server credentials. + #[serde(default = "Smtp::default_credentials")] + pub credentials: Credentials, +} + +impl Default for Smtp { + fn default() -> Self { + Self { + server: Self::default_server(), + port: Self::default_port(), + credentials: Self::default_credentials(), + } + } +} + +impl Smtp { + fn default_server() -> String { + String::default() + } + + fn default_port() -> u16 { + 25 + } + + fn default_credentials() -> Credentials { + Credentials::default() + } +} + +/// SMTP configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Credentials { + /// The password to use for SMTP authentication. + #[serde(default = "Credentials::default_password")] + pub password: String, + /// The username to use for SMTP authentication. + #[serde(default = "Credentials::default_username")] + pub username: String, +} + +impl Default for Credentials { + fn default() -> Self { + Self { + username: Self::default_username(), + password: Self::default_password(), + } + } +} + +impl Credentials { + fn default_username() -> String { + String::default() + } + + fn default_password() -> String { + String::default() + } +} diff --git a/src/config/v2/mod.rs b/src/config/v2/mod.rs new file mode 100644 index 00000000..cf8d185c --- /dev/null +++ b/src/config/v2/mod.rs @@ -0,0 +1,194 @@ +pub mod api; +pub mod auth; +pub mod database; +pub mod image_cache; +pub mod logging; +pub mod mail; +pub mod net; +pub mod registration; +pub mod tracker; +pub mod tracker_statistics_importer; +pub mod unstable; +pub mod website; + +use logging::Logging; +use registration::Registration; +use serde::{Deserialize, Serialize}; +use unstable::Unstable; + +use self::api::Api; +use self::auth::{Auth, ClaimTokenPepper}; +use self::database::Database; +use self::image_cache::ImageCache; +use self::mail::Mail; +use self::net::Network; +use self::tracker::{ApiToken, Tracker}; +use self::tracker_statistics_importer::TrackerStatisticsImporter; +use self::website::Website; +use super::validator::{ValidationError, Validator}; +use super::Metadata; + +/// The whole configuration for the index. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Settings { + /// Configuration metadata. + #[serde(default = "Settings::default_metadata")] + pub metadata: Metadata, + + /// The logging configuration. + #[serde(default = "Settings::default_logging")] + pub logging: Logging, + + /// The website customizable values. + #[serde(default = "Settings::default_website")] + pub website: Website, + + /// The tracker configuration. + #[serde(default = "Settings::default_tracker")] + pub tracker: Tracker, + + /// The network configuration. + #[serde(default = "Settings::default_network")] + pub net: Network, + + /// The authentication configuration. + #[serde(default = "Settings::default_auth")] + pub auth: Auth, + + /// The database configuration. + #[serde(default = "Settings::default_database")] + pub database: Database, + + /// The SMTP configuration. + #[serde(default = "Settings::default_mail")] + pub mail: Mail, + + /// The image proxy cache configuration. + #[serde(default = "Settings::default_image_cache")] + pub image_cache: ImageCache, + + /// The API configuration. + #[serde(default = "Settings::default_api")] + pub api: Api, + + /// The registration configuration. + #[serde(default = "Settings::default_registration")] + pub registration: Option, + + /// The tracker statistics importer job configuration. + #[serde(default = "Settings::default_tracker_statistics_importer")] + pub tracker_statistics_importer: TrackerStatisticsImporter, + + /// The unstable configuration. + #[serde(default = "Settings::default_unstable")] + pub unstable: Option, +} + +impl Default for Settings { + fn default() -> Self { + Self { + metadata: Self::default_metadata(), + logging: Self::default_logging(), + website: Self::default_website(), + tracker: Self::default_tracker(), + net: Self::default_network(), + auth: Self::default_auth(), + database: Self::default_database(), + mail: Self::default_mail(), + image_cache: Self::default_image_cache(), + api: Self::default_api(), + registration: Self::default_registration(), + tracker_statistics_importer: Self::default_tracker_statistics_importer(), + unstable: Self::default_unstable(), + } + } +} + +impl Settings { + pub fn remove_secrets(&mut self) { + self.tracker.token = ApiToken::new("***"); + if let Some(_password) = self.database.connect_url.password() { + let _ = self.database.connect_url.set_password(Some("***")); + } + "***".clone_into(&mut self.mail.smtp.credentials.password); + self.auth.user_claim_token_pepper = ClaimTokenPepper::new("***"); + } + + /// Encodes the configuration to TOML. + /// + /// # Panics + /// + /// Will panic if it can't be converted to TOML. + #[must_use] + pub fn to_toml(&self) -> String { + toml::to_string(self).expect("Could not encode TOML value") + } + + /// Encodes the configuration to JSON. + /// + /// # Panics + /// + /// Will panic if it can't be converted to JSON. + #[must_use] + pub fn to_json(&self) -> String { + serde_json::to_string_pretty(self).expect("Could not encode JSON value") + } + + fn default_metadata() -> Metadata { + Metadata::default() + } + + fn default_logging() -> Logging { + Logging::default() + } + + fn default_website() -> Website { + Website::default() + } + + fn default_tracker() -> Tracker { + Tracker::default() + } + + fn default_network() -> Network { + Network::default() + } + + fn default_auth() -> Auth { + Auth::default() + } + + fn default_database() -> Database { + Database::default() + } + + fn default_mail() -> Mail { + Mail::default() + } + + fn default_image_cache() -> ImageCache { + ImageCache::default() + } + + fn default_api() -> Api { + Api::default() + } + + fn default_registration() -> Option { + None + } + + fn default_tracker_statistics_importer() -> TrackerStatisticsImporter { + TrackerStatisticsImporter::default() + } + + fn default_unstable() -> Option { + None + } +} + +impl Validator for Settings { + fn validate(&self) -> Result<(), ValidationError> { + self.tracker.validate() + } +} diff --git a/src/config/v2/net.rs b/src/config/v2/net.rs new file mode 100644 index 00000000..cb41c046 --- /dev/null +++ b/src/config/v2/net.rs @@ -0,0 +1,63 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::config::Tsl; + +/// The the base URL for the API. +/// +/// NOTICE: that `port` and por in `base_url` does not necessarily match because +/// the application migth be running behind a proxy. The local socket could be +/// bound to, for example, port 80 but the application could be exposed publicly +/// via port 443, which is a very common setup. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Network { + /// The base URL for the API. For example: `http://localhost`. + /// If not set, the base URL will be inferred from the request. + #[serde(default = "Network::default_base_url")] + pub base_url: Option, + + /// The address the tracker will bind to. + /// The format is `ip:port`, for example `0.0.0.0:6969`. If you want to + /// listen to all interfaces, use `0.0.0.0`. If you want the operating + /// system to choose a random port, use port `0`. + #[serde(default = "Network::default_bind_address")] + pub bind_address: SocketAddr, + + /// TSL configuration. + #[serde(default = "Network::default_tsl")] + pub tsl: Option, +} + +impl Default for Network { + fn default() -> Self { + Self { + bind_address: Self::default_bind_address(), + base_url: Self::default_base_url(), + tsl: Self::default_tsl(), + } + } +} + +impl Network { + fn default_bind_address() -> SocketAddr { + SocketAddr::new(Self::default_ip(), Self::default_port()) + } + + fn default_ip() -> IpAddr { + IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)) + } + + fn default_port() -> u16 { + 3001 + } + + fn default_base_url() -> Option { + None + } + + fn default_tsl() -> Option { + None + } +} diff --git a/src/config/v2/registration.rs b/src/config/v2/registration.rs new file mode 100644 index 00000000..fa1fac07 --- /dev/null +++ b/src/config/v2/registration.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; + +/// SMTP configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Registration { + /// Whether or not to enable email verification on signup. + #[serde(default = "Registration::default_email")] + pub email: Option, +} + +impl Default for Registration { + fn default() -> Self { + Self { + email: Self::default_email(), + } + } +} + +impl Registration { + fn default_email() -> Option { + None + } +} + +/// SMTP configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Email { + /// Whether or not email is required on signup. + #[serde(default = "Email::default_required")] + pub required: bool, + + /// Whether or not email is verified. + #[serde(default = "Email::default_verified")] + pub verification_required: bool, +} + +impl Default for Email { + fn default() -> Self { + Self { + required: Self::default_required(), + verification_required: Self::default_verified(), + } + } +} + +impl Email { + fn default_required() -> bool { + false + } + + fn default_verified() -> bool { + false + } +} diff --git a/src/config/v2/tracker.rs b/src/config/v2/tracker.rs new file mode 100644 index 00000000..a2bc8703 --- /dev/null +++ b/src/config/v2/tracker.rs @@ -0,0 +1,124 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use url::Url; + +use super::{ValidationError, Validator}; + +/// Configuration for the associated tracker. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Tracker { + /// The url of the tracker API. For example: `http://localhost:1212/`. + #[serde(default = "Tracker::default_api_url")] + pub api_url: Url, + + /// Whether the tracker is running in listed mode or not. + #[serde(default = "Tracker::default_listed")] + pub listed: bool, + + /// Whether the tracker is running in private mode or not. + #[serde(default = "Tracker::default_private")] + pub private: bool, + + /// The token used to authenticate with the tracker API. + #[serde(default = "Tracker::default_token")] + pub token: ApiToken, + + /// The amount of seconds the tracker API token is valid. + #[serde(default = "Tracker::default_token_valid_seconds")] + pub token_valid_seconds: u64, + + /// Connection string for the tracker. For example: `udp://TRACKER_IP:6969`. + #[serde(default = "Tracker::default_url")] + pub url: Url, +} + +impl Validator for Tracker { + fn validate(&self) -> Result<(), ValidationError> { + if self.private && (self.url.scheme() != "http" && self.url.scheme() != "https") { + return Err(ValidationError::UdpTrackersInPrivateModeNotSupported); + } + + Ok(()) + } +} + +impl Default for Tracker { + fn default() -> Self { + Self { + url: Self::default_url(), + listed: Self::default_listed(), + private: Self::default_private(), + api_url: Self::default_api_url(), + token: Self::default_token(), + token_valid_seconds: Self::default_token_valid_seconds(), + } + } +} + +impl Tracker { + pub fn override_tracker_api_token(&mut self, tracker_api_token: &ApiToken) { + self.token = tracker_api_token.clone(); + } + + fn default_url() -> Url { + Url::parse("udp://localhost:6969").unwrap() + } + + fn default_listed() -> bool { + false + } + + fn default_private() -> bool { + false + } + + fn default_api_url() -> Url { + Url::parse("http://localhost:1212/").unwrap() + } + + fn default_token() -> ApiToken { + ApiToken::new("MyAccessToken") + } + + fn default_token_valid_seconds() -> u64 { + 7_257_600 + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiToken(String); + +impl ApiToken { + /// # Panics + /// + /// Will panic if the tracker API token if empty. + #[must_use] + pub fn new(key: &str) -> Self { + assert!(!key.is_empty(), "tracker API token cannot be empty"); + + Self(key.to_owned()) + } + + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +impl fmt::Display for ApiToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::ApiToken; + + #[test] + #[should_panic(expected = "tracker API token cannot be empty")] + fn apai_token_can_not_be_empty() { + drop(ApiToken::new("")); + } +} diff --git a/src/config/v2/tracker_statistics_importer.rs b/src/config/v2/tracker_statistics_importer.rs new file mode 100644 index 00000000..c9d5c306 --- /dev/null +++ b/src/config/v2/tracker_statistics_importer.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +/// Configuration for the tracker statistics importer. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TrackerStatisticsImporter { + /// The port the Importer API is listening on. Default to `3002`. + #[serde(default = "TrackerStatisticsImporter::default_port")] + pub port: u16, + + /// The interval in seconds to get statistics from the tracker. + #[serde(default = "TrackerStatisticsImporter::default_torrent_info_update_interval")] + pub torrent_info_update_interval: u64, +} + +impl Default for TrackerStatisticsImporter { + fn default() -> Self { + Self { + torrent_info_update_interval: Self::default_torrent_info_update_interval(), + port: Self::default_port(), + } + } +} + +impl TrackerStatisticsImporter { + fn default_torrent_info_update_interval() -> u64 { + 3600 + } + + fn default_port() -> u16 { + 3002 + } +} diff --git a/src/config/v2/unstable.rs b/src/config/v2/unstable.rs new file mode 100644 index 00000000..437c033a --- /dev/null +++ b/src/config/v2/unstable.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +/// Unstable configuration options. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Unstable { + /// The casbin configuration used for authorization. + #[serde(default = "Unstable::default_auth")] + pub auth: Option, +} + +impl Default for Unstable { + fn default() -> Self { + Self { + auth: Self::default_auth(), + } + } +} + +impl Unstable { + fn default_auth() -> Option { + None + } +} + +/// Unstable auth configuration options. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Auth { + /// The casbin configuration used for authorization. + #[serde(default = "Auth::default_casbin")] + pub casbin: Option, +} + +impl Default for Auth { + fn default() -> Self { + Self { + casbin: Self::default_casbin(), + } + } +} + +impl Auth { + fn default_casbin() -> Option { + None + } +} + +/// Authentication options. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Casbin { + /// The model. See . + pub model: String, + + /// The policy. See . + pub policy: String, +} diff --git a/src/config/v2/website.rs b/src/config/v2/website.rs new file mode 100644 index 00000000..489ce265 --- /dev/null +++ b/src/config/v2/website.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +/// Information displayed to the user in the website. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Website { + /// The name of the website. + #[serde(default = "Website::default_name")] + pub name: String, +} + +impl Default for Website { + fn default() -> Self { + Self { + name: Self::default_name(), + } + } +} + +impl Website { + fn default_name() -> String { + "Torrust".to_string() + } +} diff --git a/src/config/validator.rs b/src/config/validator.rs new file mode 100644 index 00000000..3578461d --- /dev/null +++ b/src/config/validator.rs @@ -0,0 +1,16 @@ +//! Trait to validate the whole settings of sections of the settings. +use thiserror::Error; + +/// Errors that can occur validating the configuration. +#[derive(Error, Debug)] +pub enum ValidationError { + #[error("UDP private trackers are not supported. URL schemes for private tracker URLs must be HTTP ot HTTPS")] + UdpTrackersInPrivateModeNotSupported, +} + +pub trait Validator { + /// # Errors + /// + /// Will return an error if the configuration is invalid. + fn validate(&self) -> Result<(), ValidationError>; +} diff --git a/src/console/commands/mod.rs b/src/console/commands/mod.rs index 6dad4966..e218659c 100644 --- a/src/console/commands/mod.rs +++ b/src/console/commands/mod.rs @@ -1 +1,3 @@ -pub mod import_tracker_statistics; +//! Console commands that can be run manually. +pub mod seeder; +pub mod tracker_statistics_importer; diff --git a/src/console/commands/seeder/api.rs b/src/console/commands/seeder/api.rs new file mode 100644 index 00000000..ed35dbc6 --- /dev/null +++ b/src/console/commands/seeder/api.rs @@ -0,0 +1,121 @@ +//! Action that a user can perform on a Index website. +use thiserror::Error; +use tracing::debug; + +use crate::web::api::client::v1::client::Client; +use crate::web::api::client::v1::contexts::category::forms::AddCategoryForm; +use crate::web::api::client::v1::contexts::category::responses::{ListItem, ListResponse}; +use crate::web::api::client::v1::contexts::torrent::forms::UploadTorrentMultipartForm; +use crate::web::api::client::v1::contexts::torrent::responses::{UploadedTorrent, UploadedTorrentResponse}; +use crate::web::api::client::v1::contexts::user::forms::LoginForm; +use crate::web::api::client::v1::contexts::user::responses::{LoggedInUserData, SuccessfulLoginResponse}; +use crate::web::api::client::v1::responses::TextResponse; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Torrent with the same info-hash already exist in the database")] + TorrentInfoHashAlreadyExists, + #[error("Torrent with the same title already exist in the database")] + TorrentTitleAlreadyExists, +} + +/// It uploads a torrent file to the Torrust Index. +/// +/// # Errors +/// +/// It returns an error if the torrent already exists in the database. +/// +/// # Panics +/// +/// Panics if the response body is not a valid JSON. +pub async fn upload_torrent(client: &Client, upload_torrent_form: UploadTorrentMultipartForm) -> Result { + let categories = get_categories(client).await; + + if !contains_category_with_name(&categories, &upload_torrent_form.category) { + add_category(client, &upload_torrent_form.category).await; + } + + // todo: if we receive timeout error we should retry later. Otherwise we + // have to restart the seeder manually. + + let response = client + .upload_torrent(upload_torrent_form.into()) + .await + .expect("API should return a response"); + + debug!(target:"seeder", "response: {}", response.status); + + if response.status == 400 { + if response.body.contains("This torrent already exists in our database") { + return Err(Error::TorrentInfoHashAlreadyExists); + } + + if response.body.contains("This torrent title has already been used") { + return Err(Error::TorrentTitleAlreadyExists); + } + } + + assert!(response.is_json_and_ok(), "Error uploading torrent: {}", response.body); + + let uploaded_torrent_response: UploadedTorrentResponse = + serde_json::from_str(&response.body).expect("a valid JSON response should be returned from the Torrust Index API"); + + Ok(uploaded_torrent_response.data) +} + +/// It logs in the user and returns the user data. +/// +/// # Panics +/// +/// Panics if the response body is not a valid JSON. +pub async fn login(client: &Client, username: &str, password: &str) -> LoggedInUserData { + let response = client + .login_user(LoginForm { + login: username.to_owned(), + password: password.to_owned(), + }) + .await + .expect("API should return a response"); + + let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap_or_else(|_| { + panic!( + "a valid JSON response should be returned after login. Received: {}", + response.body + ) + }); + + res.data +} + +/// It returns all the index categories. +/// +/// # Panics +/// +/// Panics if the response body is not a valid JSON. +pub async fn get_categories(client: &Client) -> Vec { + let response = client.get_categories().await.expect("API should return a response"); + + let res: ListResponse = serde_json::from_str(&response.body).unwrap(); + + res.data +} + +/// It adds a new category. +/// +/// # Panics +/// +/// Will panic if it doesn't get a response form the API. +pub async fn add_category(client: &Client, name: &str) -> TextResponse { + client + .add_category(AddCategoryForm { + name: name.to_owned(), + icon: None, + }) + .await + .expect("API should return a response") +} + +/// It checks if the category list contains the given category. +fn contains_category_with_name(items: &[ListItem], category_name: &str) -> bool { + items.iter().any(|item| item.name == category_name) +} diff --git a/src/console/commands/seeder/app.rs b/src/console/commands/seeder/app.rs new file mode 100644 index 00000000..d8e133f0 --- /dev/null +++ b/src/console/commands/seeder/app.rs @@ -0,0 +1,250 @@ +//! Console app to upload random torrents to a live Index API. +//! +//! Run with: +//! +//! ```text +//! cargo run --bin seeder -- \ +//! --api-base-url \ +//! --number-of-torrents \ +//! --user \ +//! --password \ +//! --interval +//! ``` +//! +//! For example: +//! +//! ```text +//! cargo run --bin seeder -- \ +//! --api-base-url "http://localhost:3001" \ +//! --number-of-torrents 1000 \ +//! --user admin \ +//! --password 12345678 \ +//! --interval 0 +//! ``` +//! +//! That command would upload 1000 random torrents to the Index using the user +//! account admin with password 123456 and waiting 1 second between uploads. +//! +//! The random torrents generated are single-file torrents from a TXT file. +//! All generated torrents used a UUID to identify the test torrent. The torrent +//! is generated on the fly without needing to generate the contents file. +//! However, if you like it, you can generate the contents and the torrent +//! manually with the following commands: +//! +//! ```text +//! cd /tmp +//! mkdir test_torrents +//! cd test_torrents +//! uuidgen +//! echo $'1fd827fb-29dc-47bd-b116-bf96f6466e65' > file-1fd827fb-29dc-47bd-b116-bf96f6466e65.txt +//! imdl torrent create file-1fd827fb-29dc-47bd-b116-bf96f6466e65.txt +//! imdl torrent show file-1fd827fb-29dc-47bd-b116-bf96f6466e65.txt.torrent +//! ``` +//! +//! That could be useful for testing purposes. For example, if you want to seed +//! the torrent with a `BitTorrent` client. +//! +//! Let's explain each line: +//! +//! First, we need to generate the UUID: +//! +//! ```text +//! uuidgen +//! 1fd827fb-29dc-47bd-b116-bf96f6466e65 +//! ```` +//! +//! Then, we need to create a text file and write the UUID into the file: +//! +//! ```text +//! echo $'1fd827fb-29dc-47bd-b116-bf96f6466e65' > file-1fd827fb-29dc-47bd-b116-bf96f6466e65.txt +//! ``` +//! +//! Finally you can use a torrent creator like [Intermodal](https://github.com/casey/intermodal) +//! to generate the torrent file. You can use any `BitTorrent` client or other +//! console tool. +//! +//! ```text +//! imdl torrent create file-1fd827fb-29dc-47bd-b116-bf96f6466e65.txt +//! $ imdl torrent create file-1fd827fb-29dc-47bd-b116-bf96f6466e65.txt +//! [1/3] 🧿 Searching `file-1fd827fb-29dc-47bd-b116-bf96f6466e65.txt` for files… +//! [2/3] 🧮 Hashing pieces… +//! [3/3] 💾 Writing metainfo to `file-1fd827fb-29dc-47bd-b116-bf96f6466e65.txt.torrent`… +//! ✨✨ Done! ✨✨ +//! ```` +//! +//! The torrent meta file contains this information: +//! +//! ```text +//! $ imdl torrent show file-1fd827fb-29dc-47bd-b116-bf96f6466e65.txt.torrent +//! Name file-1fd827fb-29dc-47bd-b116-bf96f6466e65.txt +//! Creation Date 2024-02-07 12:47:32 UTC +//! Created By imdl/0.1.13 +//! Info Hash c8cf845e9771013b5c0e022cb1fc1feebdb24b66 +//! Torrent Size 201 bytes +//! Content Size 37 bytes +//! Private no +//! Piece Size 16 KiB +//! Piece Count 1 +//! File Count 1 +//! Files file-1fd827fb-29dc-47bd-b116-bf96f6466e65.txt +//!```` +//! +//! The torrent generated manually contains this info: +//! +//! ```json +//! { +//! "created by": "imdl/0.1.13", +//! "creation date": 1707304810, +//! "encoding": "UTF-8", +//! "info": { +//! "length": 37, +//! "name": "file-1fd827fb-29dc-47bd-b116-bf96f6466e65.txt", +//! "piece length": 16384, +//! "pieces": "E2 11 4F 69 79 50 1E CC F6 32 91 A5 12 FA D5 6B 49 20 12 D3" +//! } +//! } +//! ``` +//! +//! If you upload that torrent to the Index and you download it, then you +//! get this torrent information: +//! +//! ```json +//! { +//! "announce": "udp://tracker.torrust-demo.com:6969/k24qT2KgWFh9d5e1iHSJ9kOwfK45fH4V", +//! "announce-list": [ +//! [ +//! "udp://tracker.torrust-demo.com:6969/k24qT2KgWFh9d5e1iHSJ9kOwfK45fH4V" +//! ] +//! ], +//! "info": { +//! "length": 37, +//! "name": "file-1fd827fb-29dc-47bd-b116-bf96f6466e65.txt", +//! "piece length": 16384, +//! "pieces": "E2 11 4F 69 79 50 1E CC F6 32 91 A5 12 FA D5 6B 49 20 12 D3" +//! } +//! } +//! ``` +//! +//! As you can see the `info` dictionary is exactly the same, which produces +//! the same info-hash for the torrent. +use std::str::FromStr; +use std::thread::sleep; +use std::time::Duration; + +use anyhow::Context; +use clap::Parser; +use reqwest::Url; +use text_colorizer::Colorize; +use tracing::level_filters::LevelFilter; +use tracing::{debug, info}; +use uuid::Uuid; + +use super::api::Error; +use crate::console::commands::seeder::api::{login, upload_torrent}; +use crate::console::commands::seeder::logging; +use crate::services::torrent_file::generate_random_torrent; +use crate::utils::parse_torrent; +use crate::web::api::client::v1::client::Client; +use crate::web::api::client::v1::contexts::torrent::forms::{BinaryFile, UploadTorrentMultipartForm}; +use crate::web::api::client::v1::contexts::torrent::responses::UploadedTorrent; +use crate::web::api::client::v1::contexts::user::responses::LoggedInUserData; + +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +struct Args { + #[arg(short, long)] + api_base_url: String, + + #[arg(short, long)] + number_of_torrents: i32, + + #[arg(short, long)] + user: String, + + #[arg(short, long)] + password: String, + + #[arg(short, long)] + interval: u64, +} + +/// # Errors +/// +/// Will not return any errors for the time being. +pub async fn run() -> anyhow::Result<()> { + logging::setup(LevelFilter::INFO); + + let args = Args::parse(); + + let api_url = Url::from_str(&args.api_base_url).context("failed to parse API base URL")?; + + let api_user = login_index_api(&api_url, &args.user, &args.password).await; + + let api_client = Client::authenticated(&api_url, &api_user.token); + + info!(target:"seeder", "Uploading { } random torrents to the Torrust Index with a { } seconds interval...", args.number_of_torrents.to_string().yellow(), args.interval.to_string().yellow()); + + for i in 1..=args.number_of_torrents { + info!(target:"seeder", "Uploading torrent #{} ...", i.to_string().yellow()); + + match upload_random_torrent(&api_client).await { + Ok(uploaded_torrent) => { + debug!(target:"seeder", "Uploaded torrent {uploaded_torrent:?}"); + + let json = serde_json::to_string(&uploaded_torrent).context("failed to serialize upload response into JSON")?; + + info!(target:"seeder", "Uploaded torrent: {}", json.yellow()); + } + Err(err) => print!("Error uploading torrent {err:?}"), + }; + + if i != args.number_of_torrents { + sleep(Duration::from_secs(args.interval)); + } + } + + Ok(()) +} + +/// It logs in a user in the Index API. +pub async fn login_index_api(api_url: &Url, username: &str, password: &str) -> LoggedInUserData { + let unauthenticated_client = Client::unauthenticated(api_url); + + info!(target:"seeder", "Trying to login with username: {} ...", username.yellow()); + + let user: LoggedInUserData = login(&unauthenticated_client, username, password).await; + + if user.admin { + info!(target:"seeder", "Logged as admin with account: {} ", username.yellow()); + } else { + info!(target:"seeder", "Logged as {} ", username.yellow()); + } + + user +} + +async fn upload_random_torrent(api_client: &Client) -> Result { + let uuid = Uuid::new_v4(); + + info!(target:"seeder", "Uploading torrent with uuid: {} ...", uuid.to_string().yellow()); + + let torrent_file = generate_random_torrent_file(uuid); + + let upload_form = UploadTorrentMultipartForm { + title: format!("title-{uuid}"), + description: format!("description-{uuid}"), + category: "test".to_string(), + torrent_file, + }; + + upload_torrent(api_client, upload_form).await +} + +/// It returns the bencoded binary data of the torrent meta file. +fn generate_random_torrent_file(uuid: Uuid) -> BinaryFile { + let torrent = generate_random_torrent(uuid); + + let bytes = parse_torrent::encode_torrent(&torrent).expect("msg:the torrent should be bencoded"); + + BinaryFile::from_bytes(torrent.info.name, bytes) +} diff --git a/src/console/commands/seeder/logging.rs b/src/console/commands/seeder/logging.rs new file mode 100644 index 00000000..0f9a9afc --- /dev/null +++ b/src/console/commands/seeder/logging.rs @@ -0,0 +1,12 @@ +//! Logging setup for the `seeder`. +use tracing::debug; +use tracing::level_filters::LevelFilter; + +/// # Panics +/// +/// +pub fn setup(level: LevelFilter) { + tracing_subscriber::fmt().with_max_level(level).init(); + + debug!("Logging initialized"); +} diff --git a/src/console/commands/seeder/mod.rs b/src/console/commands/seeder/mod.rs new file mode 100644 index 00000000..dc37e756 --- /dev/null +++ b/src/console/commands/seeder/mod.rs @@ -0,0 +1,4 @@ +//! Command to upload random torrents to a live Index API. +pub mod api; +pub mod app; +pub mod logging; diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/tracker_statistics_importer/app.rs similarity index 86% rename from src/console/commands/import_tracker_statistics.rs rename to src/console/commands/tracker_statistics_importer/app.rs index 08acbb31..2d2dec53 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/tracker_statistics_importer/app.rs @@ -16,11 +16,11 @@ //! Statistics are also imported: //! //! - Periodically by the importer job. The importer job is executed every hour -//! by default. See [`TrackerStatisticsImporter`](crate::config::TrackerStatisticsImporter) -//! for more details. +//! by default. See [`TrackerStatisticsImporter`](crate::config::TrackerStatisticsImporter) +//! for more details. //! - When a new torrent is added. //! - When the API returns data about a torrent statistics are collected from -//! the tracker in real time. +//! the tracker in real time. use std::env; use std::sync::Arc; @@ -75,7 +75,7 @@ fn print_usage() { /// # Panics /// /// Panics if arguments cannot be parsed. -pub async fn run_importer() { +pub async fn run() { parse_args().expect("unable to parse command arguments"); import().await; } @@ -84,15 +84,15 @@ pub async fn run_importer() { /// /// # Panics /// -/// Panics if `Configuration::load_from_file` has any error. +/// Panics if it can't connect to the database. pub async fn import() { println!("Importing statistics from linked tracker ..."); let configuration = initialize_configuration(); - let log_level = configuration.settings.read().await.log_level.clone(); + let threshold = configuration.settings.read().await.logging.threshold.clone(); - logging::setup(&log_level); + logging::setup(&threshold); let cfg = Arc::new(configuration); @@ -100,10 +100,10 @@ pub async fn import() { let tracker_url = settings.tracker.url.clone(); - eprintln!("Tracker url: {}", tracker_url.green()); + eprintln!("Tracker url: {}", tracker_url.to_string().green()); let database = Arc::new( - database::connect(&settings.database.connect_url) + database::connect(settings.database.connect_url.as_ref()) .await .expect("unable to connect to db"), ); diff --git a/src/console/commands/tracker_statistics_importer/mod.rs b/src/console/commands/tracker_statistics_importer/mod.rs new file mode 100644 index 00000000..309be628 --- /dev/null +++ b/src/console/commands/tracker_statistics_importer/mod.rs @@ -0,0 +1 @@ +pub mod app; diff --git a/src/console/cronjobs/mod.rs b/src/console/cronjobs/mod.rs new file mode 100644 index 00000000..3fe5bab6 --- /dev/null +++ b/src/console/cronjobs/mod.rs @@ -0,0 +1,2 @@ +//! Cronjobs that are executed automatically. +pub mod tracker_statistics_importer; diff --git a/src/console/cronjobs/tracker_statistics_importer.rs b/src/console/cronjobs/tracker_statistics_importer.rs new file mode 100644 index 00000000..2794c4cf --- /dev/null +++ b/src/console/cronjobs/tracker_statistics_importer.rs @@ -0,0 +1,184 @@ +//! Cronjob to import tracker torrent data and updating seeders and leechers +//! info. +//! +//! It has two services: +//! +//! - The importer which is the cronjob executed at regular intervals. +//! - The importer API. +//! +//! The cronjob sends a heartbeat signal to the API each time it is executed. +//! The last heartbeat signal time is used to determine whether the cronjob was +//! executed successfully or not. The API has a `health_check` endpoint which is +//! used when the application is running in containers. +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; + +use axum::extract::State; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use chrono::{DateTime, Utc}; +use serde_json::{json, Value}; +use text_colorizer::Colorize; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tracing::{debug, error, info}; + +use crate::tracker::statistics_importer::StatisticsImporter; +use crate::utils::clock::seconds_ago_utc; + +const IMPORTER_API_IP: &str = "127.0.0.1"; + +#[derive(Clone)] +struct ImporterState { + /// Shared variable to store the timestamp of the last heartbeat sent + /// by the cronjob. + pub last_heartbeat: Arc>>, + /// Interval between importation executions + pub torrent_info_update_interval: u64, +} + +/// # Panics +/// +/// Will panic if it can't start the tracker statistics importer API +#[must_use] +pub fn start( + importer_port: u16, + torrent_stats_update_interval: u64, + tracker_statistics_importer: &Arc, +) -> JoinHandle<()> { + let weak_tracker_statistics_importer = Arc::downgrade(tracker_statistics_importer); + + tokio::spawn(async move { + info!("Tracker statistics importer launcher started"); + + // Start the Importer API + + let _importer_api_handle = tokio::spawn(async move { + let import_state = Arc::new(ImporterState { + last_heartbeat: Arc::new(Mutex::new(Utc::now())), + torrent_info_update_interval: torrent_stats_update_interval, + }); + + let app = Router::new() + .route("/", get(|| async { Json(json!({})) })) + .route("/health_check", get(health_check_handler)) + .with_state(import_state.clone()) + .route("/heartbeat", post(heartbeat_handler)) + .with_state(import_state); + + let addr = format!("{IMPORTER_API_IP}:{importer_port}"); + + info!("Tracker statistics importer API server listening on http://{}", addr); // # DevSkim: ignore DS137138 + + let socket_addr: SocketAddr = addr.parse().expect("importer API to have a valid socket address"); + + let listener = TcpListener::bind(socket_addr) + .await + .expect("importer API TCP listener to bind to socket address"); + + axum::serve(listener, app).await.unwrap(); + }); + + // Start the Importer cronjob + + info!("Tracker statistics importer cronjob starting ..."); + + // code-review: + // + // We set an execution interval to avoid intense polling to the + // database. If we remove the interval we would be constantly queering + // if there are torrent stats pending to update, unless there are + // torrents to update. Maybe we should only sleep for 100 milliseconds + // if we did not update any torrents in the latest execution. With this + // current limit we can only import 50 torrent stats every 2000 seconds, + // which is 500 torrents per second (1800000 torrents per hour). + // + // | Interval (secs) | Number of torrents imported per hour | + // ------------------|--------------------------------------| + // | 1 sec | 50 * (3600/1) = 180000 | + // | 2 sec | 50 * (3600/2) = 90000 | + // | 3 sec | 50 * (3600/3) = 60000 | + // | 4 sec | 50 * (3600/4) = 45000 | + // | 5 sec | 50 * (3600/5) = 36000 | + // + // The `execution_interval_in_milliseconds` could be a config option in + // the future. + + let execution_interval_in_milliseconds = 2000; + let execution_interval_duration = std::time::Duration::from_millis(execution_interval_in_milliseconds); + let mut execution_interval = tokio::time::interval(execution_interval_duration); + + execution_interval.tick().await; // first tick is immediate... + + info!("Running tracker statistics importer every {execution_interval_in_milliseconds} milliseconds ..."); + + loop { + if let Err(e) = send_heartbeat(importer_port).await { + error!("Failed to send heartbeat from importer cronjob: {}", e); + } + + if let Some(statistics_importer) = weak_tracker_statistics_importer.upgrade() { + let one_interval_ago = seconds_ago_utc( + torrent_stats_update_interval + .try_into() + .expect("update interval should be a positive integer"), + ); + let limit = 50; + + debug!( + "Importing torrents statistics not updated since {} limited to a maximum of {} torrents ...", + one_interval_ago.to_string().yellow(), + limit.to_string().yellow() + ); + + match statistics_importer + .import_torrents_statistics_not_updated_since(one_interval_ago, limit) + .await + { + Ok(()) => {} + Err(e) => error!("Failed to import statistics: {:?}", e), + } + + drop(statistics_importer); + } else { + break; + } + + execution_interval.tick().await; + } + }) +} + +/// Endpoint for container health check. +async fn health_check_handler(State(state): State>) -> Json { + let margin_in_seconds = 10; + let now = Utc::now(); + let last_heartbeat = state.last_heartbeat.lock().unwrap(); + + if now.signed_duration_since(*last_heartbeat).num_seconds() + <= (state.torrent_info_update_interval + margin_in_seconds).try_into().unwrap() + { + Json(json!({ "status": "Ok" })) + } else { + Json(json!({ "status": "Error" })) + } +} + +/// The tracker statistics importer cronjob sends a heartbeat on each execution +/// to inform that it's alive. This endpoint handles receiving that signal. +async fn heartbeat_handler(State(state): State>) -> Json { + let now = Utc::now(); + let mut last_heartbeat = state.last_heartbeat.lock().unwrap(); + *last_heartbeat = now; + Json(json!({ "status": "Heartbeat received" })) +} + +/// Send a heartbeat from the importer cronjob to the importer API. +async fn send_heartbeat(importer_port: u16) -> Result<(), reqwest::Error> { + let client = reqwest::Client::new(); + let url = format!("http://{IMPORTER_API_IP}:{importer_port}/heartbeat"); // # DevSkim: ignore DS137138 + + client.post(url).send().await?; + + Ok(()) +} diff --git a/src/console/mod.rs b/src/console/mod.rs index 82b6da3c..486d5d30 100644 --- a/src/console/mod.rs +++ b/src/console/mod.rs @@ -1 +1,3 @@ +//! Console modules. pub mod commands; +pub mod cronjobs; diff --git a/src/databases/database.rs b/src/databases/database.rs index 0d6e8c3e..aed86ecb 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; -use chrono::NaiveDateTime; +use chrono::{DateTime, NaiveDateTime, Utc}; use serde::{Deserialize, Serialize}; +use url::Url; use crate::databases::mysql::Mysql; use crate::databases::sqlite::Sqlite; @@ -81,7 +82,6 @@ pub enum Error { UsernameTaken, EmailTaken, UserNotFound, - CategoryAlreadyExists, CategoryNotFound, TagAlreadyExists, TagNotFound, @@ -131,6 +131,9 @@ pub trait Database: Sync + Send { /// Add new user and return the newly inserted `user_id`. async fn insert_user_and_get_id(&self, username: &str, email: &str, password: &str) -> Result; + /// Change user's password. + async fn change_user_password(&self, user_id: i64, new_password: &str) -> Result<(), Error>; + /// Get `User` from `user_id`. async fn get_user_from_id(&self, user_id: i64) -> Result; @@ -207,7 +210,17 @@ pub trait Database: Sync + Send { let torrent_announce_urls = self.get_torrent_announce_urls_from_id(db_torrent.torrent_id).await?; - Ok(Torrent::from_database(&db_torrent, &torrent_files, torrent_announce_urls)) + let torrent_http_seed_urls = self.get_torrent_http_seed_urls_from_id(db_torrent.torrent_id).await?; + + let torrent_nodes = self.get_torrent_nodes_from_id(db_torrent.torrent_id).await?; + + Ok(Torrent::from_database( + &db_torrent, + &torrent_files, + torrent_announce_urls, + torrent_http_seed_urls, + torrent_nodes, + )) } /// Get `Torrent` from `torrent_id`. @@ -218,7 +231,17 @@ pub trait Database: Sync + Send { let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_id).await?; - Ok(Torrent::from_database(&db_torrent, &torrent_files, torrent_announce_urls)) + let torrent_http_seed_urls = self.get_torrent_http_seed_urls_from_id(db_torrent.torrent_id).await?; + + let torrent_nodes = self.get_torrent_nodes_from_id(db_torrent.torrent_id).await?; + + Ok(Torrent::from_database( + &db_torrent, + &torrent_files, + torrent_announce_urls, + torrent_http_seed_urls, + torrent_nodes, + )) } /// It returns the list of all infohashes producing the same canonical @@ -258,6 +281,12 @@ pub trait Database: Sync + Send { /// Get all torrent's announce urls as `Vec>` from `torrent_id`. async fn get_torrent_announce_urls_from_id(&self, torrent_id: i64) -> Result>, Error>; + /// Get all torrent's HTTP seed urls as `Vec>` from `torrent_id`. + async fn get_torrent_http_seed_urls_from_id(&self, torrent_id: i64) -> Result, Error>; + + /// Get all torrent's nodes as `Vec<(String, i64)>` from `torrent_id`. + async fn get_torrent_nodes_from_id(&self, torrent_id: i64) -> Result, Error>; + /// Get `TorrentListing` from `torrent_id`. async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result; @@ -267,6 +296,13 @@ pub trait Database: Sync + Send { /// Get all torrents as `Vec`. async fn get_all_torrents_compact(&self) -> Result, Error>; + /// Get torrents whose stats have not been imported from the tracker at least since a given datetime. + async fn get_torrents_with_stats_not_updated_since( + &self, + datetime: DateTime, + limit: i64, + ) -> Result, Error>; + /// Update a torrent's title with `torrent_id` and `title`. async fn update_torrent_title(&self, torrent_id: i64, title: &str) -> Result<(), Error>; @@ -304,7 +340,7 @@ pub trait Database: Sync + Send { async fn get_tags_for_torrent_id(&self, torrent_id: i64) -> Result, Error>; /// Update the seeders and leechers info for a torrent with `torrent_id`, `tracker_url`, `seeders` and `leechers`. - async fn update_tracker_info(&self, torrent_id: i64, tracker_url: &str, seeders: i64, leechers: i64) -> Result<(), Error>; + async fn update_tracker_info(&self, torrent_id: i64, tracker_url: &Url, seeders: i64, leechers: i64) -> Result<(), Error>; /// Delete a torrent with `torrent_id`. async fn delete_torrent(&self, torrent_id: i64) -> Result<(), Error>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 0c5175a6..89245ee9 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -2,9 +2,10 @@ use std::str::FromStr; use std::time::Duration; use async_trait::async_trait; -use chrono::NaiveDateTime; +use chrono::{DateTime, NaiveDateTime, Utc}; use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions}; use sqlx::{query, query_as, Acquire, ConnectOptions, MySqlPool}; +use url::Url; use super::database::TABLES_TO_TRUNCATE; use crate::databases::database; @@ -13,12 +14,14 @@ use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::{Metadata, TorrentListing}; -use crate::models::torrent_file::{DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, Torrent, TorrentFile}; +use crate::models::torrent_file::{ + DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentHttpSeedUrl, DbTorrentNode, Torrent, TorrentFile, +}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; use crate::services::torrent::{CanonicalInfoHashGroup, DbTorrentInfoHash}; -use crate::utils::clock; +use crate::utils::clock::{self, datetime_now, DATETIME_FORMAT}; use crate::utils::hex::from_bytes; pub struct Mysql { @@ -34,8 +37,8 @@ impl Database for Mysql { async fn new(database_url: &str) -> Self { let connection_options = MySqlConnectOptions::from_str(database_url) .expect("Unable to create connection options.") - .log_statements(log::LevelFilter::Error) - .log_slow_statements(log::LevelFilter::Warn, Duration::from_secs(1)); + .log_statements(log::LevelFilter::Debug) + .log_slow_statements(log::LevelFilter::Info, Duration::from_secs(1)); let db = MySqlPoolOptions::new() .connect_with(connection_options) @@ -111,6 +114,23 @@ impl Database for Mysql { } } + /// Change user's password. + async fn change_user_password(&self, user_id: i64, new_password: &str) -> Result<(), database::Error> { + query("UPDATE torrust_user_authentication SET password_hash = ? WHERE user_id = ?") + .bind(new_password) + .bind(user_id) + .execute(&self.pool) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::UserNotFound) + } + }) + } + async fn get_user_from_id(&self, user_id: i64) -> Result { query_as::<_, User>("SELECT * FROM torrust_users WHERE user_id = ?") .bind(user_id) @@ -249,17 +269,7 @@ impl Database for Mysql { .execute(&self.pool) .await .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) - .map_err(|e| match e { - sqlx::Error::Database(err) => { - log::error!("DB error: {:?}", err); - if err.message().contains("Duplicate entry") && err.message().contains("name") { - database::Error::CategoryAlreadyExists - } else { - database::Error::Error - } - } - _ => database::Error::Error, - }) + .map_err(|_| database::Error::Error) } async fn get_category_from_id(&self, category_id: i64) -> Result { @@ -387,6 +397,9 @@ impl Database for Mysql { tt.size AS file_size, tt.name, tt.comment, + tt.creation_date, + tt.created_by, + tt.`encoding`, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -443,13 +456,17 @@ impl Database for Mysql { // start db transaction let mut tx = conn.begin().await.map_err(|_| database::Error::Error)?; - // torrent file can only hold a pieces key or a root hash key: http://www.bittorrent.org/beps/bep_0030.html - let (pieces, root_hash): (String, bool) = if let Some(pieces) = &torrent.info.pieces { - (from_bytes(pieces.as_ref()), false) - } else { - let root_hash = torrent.info.root_hash.as_ref().ok_or(database::Error::Error)?; - (root_hash.to_string(), true) - }; + // BEP 30: . + // Torrent file can only hold a `pieces` key or a `root hash` key + let is_bep_30 = !matches!(&torrent.info.pieces, Some(_pieces)); + + let pieces = torrent.info.pieces.as_ref().map(|pieces| from_bytes(pieces.as_ref())); + + let root_hash = torrent + .info + .root_hash + .as_ref() + .map(|root_hash| from_bytes(root_hash.as_ref())); // add torrent let torrent_id = query( @@ -460,13 +477,17 @@ impl Database for Mysql { size, name, pieces, + root_hash, piece_length, private, - root_hash, + is_bep_30, `source`, comment, - date_uploaded - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())", + date_uploaded, + creation_date, + created_by, + `encoding` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(), ?, ?, ?)", ) .bind(uploader_id) .bind(metadata.category_id) @@ -474,17 +495,21 @@ impl Database for Mysql { .bind(torrent.file_size()) .bind(torrent.info.name.to_string()) .bind(pieces) + .bind(root_hash) .bind(torrent.info.piece_length) .bind(torrent.info.private) - .bind(root_hash) + .bind(is_bep_30) .bind(torrent.info.source.clone()) .bind(torrent.comment.clone()) + .bind(torrent.creation_date) + .bind(torrent.created_by.clone()) + .bind(torrent.encoding.clone()) .execute(&mut *tx) .await .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) .map_err(|e| match e { sqlx::Error::Database(err) => { - log::error!("DB error: {:?}", err); + tracing::error!("DB error: {:?}", err); if err.message().contains("Duplicate entry") && err.message().contains("info_hash") { database::Error::TorrentAlreadyExists } else { @@ -505,7 +530,7 @@ impl Database for Mysql { .await .map(|_| ()) .map_err(|err| { - log::error!("DB error: {:?}", err); + tracing::error!("DB error: {:?}", err); database::Error::Error }); @@ -583,7 +608,56 @@ impl Database for Mysql { return Err(e); } - // Insert tags + // add HTTP seeds + + let insert_torrent_http_seeds_result: Result<(), database::Error> = if let Some(http_seeds) = &torrent.httpseeds { + for seed_url in http_seeds { + let () = query("INSERT INTO torrust_torrent_http_seeds (torrent_id, seed_url) VALUES (?, ?)") + .bind(torrent_id) + .bind(seed_url) + .execute(&mut *tx) + .await + .map(|_| ()) + .map_err(|_| database::Error::Error)?; + } + + Ok(()) + } else { + Ok(()) + }; + + // rollback transaction on error + if let Err(e) = insert_torrent_http_seeds_result { + drop(tx.rollback().await); + return Err(e); + } + + // add nodes + + let insert_torrent_nodes_result: Result<(), database::Error> = if let Some(nodes) = &torrent.nodes { + for node in nodes { + let () = query("INSERT INTO torrust_torrent_nodes (torrent_id, node_ip, node_port) VALUES (?, ?, ?)") + .bind(torrent_id) + .bind(node.0.clone()) + .bind(node.1) + .execute(&mut *tx) + .await + .map(|_| ()) + .map_err(|_| database::Error::Error)?; + } + + Ok(()) + } else { + Ok(()) + }; + + // rollback transaction on error + if let Err(e) = insert_torrent_nodes_result { + drop(tx.rollback().await); + return Err(e); + } + + // add tags for tag_id in &metadata.tags { let insert_torrent_tag_result = query("INSERT INTO torrust_torrent_tag_links (torrent_id, tag_id) VALUES (?, ?)") @@ -609,7 +683,7 @@ impl Database for Mysql { .await .map_err(|e| match e { sqlx::Error::Database(err) => { - log::error!("DB error: {:?}", err); + tracing::error!("DB error: {:?}", err); if err.message().contains("Duplicate entry") && err.message().contains("title") { database::Error::TorrentTitleAlreadyExists } else { @@ -741,6 +815,24 @@ impl Database for Mysql { .map_err(|_| database::Error::TorrentNotFound) } + async fn get_torrent_http_seed_urls_from_id(&self, torrent_id: i64) -> Result, database::Error> { + query_as::<_, DbTorrentHttpSeedUrl>("SELECT seed_url FROM torrust_torrent_http_seeds WHERE torrent_id = ?") + .bind(torrent_id) + .fetch_all(&self.pool) + .await + .map(|v| v.iter().map(|a| a.seed_url.to_string()).collect()) + .map_err(|_| database::Error::TorrentNotFound) + } + + async fn get_torrent_nodes_from_id(&self, torrent_id: i64) -> Result, database::Error> { + query_as::<_, DbTorrentNode>("SELECT node_ip, node_port FROM torrust_torrent_nodes WHERE torrent_id = ?") + .bind(torrent_id) + .fetch_all(&self.pool) + .await + .map(|v| v.iter().map(|a| (a.node_ip.to_string(), a.node_port)).collect()) + .map_err(|_| database::Error::TorrentNotFound) + } + async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result { query_as::<_, TorrentListing>( "SELECT @@ -754,6 +846,9 @@ impl Database for Mysql { tt.size AS file_size, tt.name, tt.comment, + tt.creation_date, + tt.created_by, + tt.`encoding`, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -782,6 +877,9 @@ impl Database for Mysql { tt.size AS file_size, tt.name, tt.comment, + tt.creation_date, + tt.created_by, + tt.`encoding`, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -804,6 +902,27 @@ impl Database for Mysql { .map_err(|_| database::Error::Error) } + async fn get_torrents_with_stats_not_updated_since( + &self, + datetime: DateTime, + limit: i64, + ) -> Result, database::Error> { + query_as::<_, TorrentCompact>( + "SELECT tt.torrent_id, tt.info_hash + FROM torrust_torrents tt + LEFT JOIN torrust_torrent_tracker_stats tts ON tt.torrent_id = tts.torrent_id + WHERE tts.updated_at < ? OR tts.updated_at IS NULL + ORDER BY tts.updated_at ASC + LIMIT ? + ", + ) + .bind(datetime.format(DATETIME_FORMAT).to_string()) + .bind(limit) + .fetch_all(&self.pool) + .await + .map_err(|_| database::Error::Error) + } + async fn update_torrent_title(&self, torrent_id: i64, title: &str) -> Result<(), database::Error> { query("UPDATE torrust_torrent_info SET title = ? WHERE torrent_id = ?") .bind(title) @@ -812,7 +931,7 @@ impl Database for Mysql { .await .map_err(|e| match e { sqlx::Error::Database(err) => { - log::error!("DB error: {:?}", err); + tracing::error!("DB error: {:?}", err); if err.message().contains("Duplicate entry") && err.message().contains("title") { database::Error::TorrentTitleAlreadyExists } else { @@ -870,7 +989,7 @@ impl Database for Mysql { .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) .map_err(|e| match e { sqlx::Error::Database(err) => { - log::error!("DB error: {:?}", err); + tracing::error!("DB error: {:?}", err); if err.message().contains("Duplicate entry") && err.message().contains("name") { database::Error::TagAlreadyExists } else { @@ -971,15 +1090,16 @@ impl Database for Mysql { async fn update_tracker_info( &self, torrent_id: i64, - tracker_url: &str, + tracker_url: &Url, seeders: i64, leechers: i64, ) -> Result<(), database::Error> { - query("REPLACE INTO torrust_torrent_tracker_stats (torrent_id, tracker_url, seeders, leechers) VALUES (?, ?, ?, ?)") + query("REPLACE INTO torrust_torrent_tracker_stats (torrent_id, tracker_url, seeders, leechers, updated_at) VALUES (?, ?, ?, ?, ?)") .bind(torrent_id) - .bind(tracker_url) + .bind(tracker_url.to_string()) .bind(seeders) .bind(leechers) + .bind(datetime_now()) .execute(&self.pool) .await .map(|_| ()) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 02abfc24..f9482838 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -2,9 +2,10 @@ use std::str::FromStr; use std::time::Duration; use async_trait::async_trait; -use chrono::NaiveDateTime; +use chrono::{DateTime, NaiveDateTime, Utc}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::{query, query_as, Acquire, ConnectOptions, SqlitePool}; +use url::Url; use super::database::TABLES_TO_TRUNCATE; use crate::databases::database; @@ -13,12 +14,14 @@ use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::{Metadata, TorrentListing}; -use crate::models::torrent_file::{DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, Torrent, TorrentFile}; +use crate::models::torrent_file::{ + DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentHttpSeedUrl, DbTorrentNode, Torrent, TorrentFile, +}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; use crate::services::torrent::{CanonicalInfoHashGroup, DbTorrentInfoHash}; -use crate::utils::clock; +use crate::utils::clock::{self, datetime_now, DATETIME_FORMAT}; use crate::utils::hex::from_bytes; pub struct Sqlite { @@ -34,8 +37,8 @@ impl Database for Sqlite { async fn new(database_url: &str) -> Self { let connection_options = SqliteConnectOptions::from_str(database_url) .expect("Unable to create connection options.") - .log_statements(log::LevelFilter::Error) - .log_slow_statements(log::LevelFilter::Warn, Duration::from_secs(1)); + .log_statements(log::LevelFilter::Debug) + .log_slow_statements(log::LevelFilter::Info, Duration::from_secs(1)); let db = SqlitePoolOptions::new() .connect_with(connection_options) @@ -112,6 +115,23 @@ impl Database for Sqlite { } } + /// Change user's password. + async fn change_user_password(&self, user_id: i64, new_password: &str) -> Result<(), database::Error> { + query("UPDATE torrust_user_authentication SET password_hash = ? WHERE user_id = ?") + .bind(new_password) + .bind(user_id) + .execute(&self.pool) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::UserNotFound) + } + }) + } + async fn get_user_from_id(&self, user_id: i64) -> Result { query_as::<_, User>("SELECT * FROM torrust_users WHERE user_id = ?") .bind(user_id) @@ -239,17 +259,7 @@ impl Database for Sqlite { .execute(&self.pool) .await .map(|v| v.last_insert_rowid()) - .map_err(|e| match e { - sqlx::Error::Database(err) => { - log::error!("DB error: {:?}", err); - if err.message().contains("UNIQUE") && err.message().contains("name") { - database::Error::CategoryAlreadyExists - } else { - database::Error::Error - } - } - _ => database::Error::Error, - }) + .map_err(|_| database::Error::Error) } async fn get_category_from_id(&self, category_id: i64) -> Result { @@ -269,7 +279,7 @@ impl Database for Sqlite { } async fn get_categories(&self) -> Result, database::Error> { - query_as::<_, Category>("SELECT tc.category_id, tc.name, COUNT(tt.category_id) as num_torrents FROM torrust_categories tc LEFT JOIN torrust_torrents tt on tc.category_id = tt.category_id GROUP BY tc.name") + query_as::<_, Category>("SELECT tc.category_id, tc.name, COUNT(tt.category_id) as num_torrents FROM torrust_categories tc LEFT JOIN torrust_torrents tt on tc.category_id = tt.category_id GROUP BY tc.name;") .fetch_all(&self.pool) .await .map_err(|_| database::Error::Error) @@ -377,6 +387,9 @@ impl Database for Sqlite { tt.size AS file_size, tt.name, tt.comment, + tt.creation_date, + tt.created_by, + tt.`encoding`, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -433,13 +446,17 @@ impl Database for Sqlite { // start db transaction let mut tx = conn.begin().await.map_err(|_| database::Error::Error)?; - // torrent file can only hold a pieces key or a root hash key: http://www.bittorrent.org/beps/bep_0030.html - let (pieces, root_hash): (String, bool) = if let Some(pieces) = &torrent.info.pieces { - (from_bytes(pieces.as_ref()), false) - } else { - let root_hash = torrent.info.root_hash.as_ref().ok_or(database::Error::Error)?; - (root_hash.to_string(), true) - }; + // BEP 30: . + // Torrent file can only hold a `pieces` key or a `root hash` key + let is_bep_30 = !matches!(&torrent.info.pieces, Some(_pieces)); + + let pieces = torrent.info.pieces.as_ref().map(|pieces| from_bytes(pieces.as_ref())); + + let root_hash = torrent + .info + .root_hash + .as_ref() + .map(|root_hash| from_bytes(root_hash.as_ref())); // add torrent let torrent_id = query( @@ -450,13 +467,17 @@ impl Database for Sqlite { size, name, pieces, + root_hash, piece_length, private, - root_hash, + is_bep_30, `source`, comment, - date_uploaded - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))", + date_uploaded, + creation_date, + created_by, + `encoding` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')), ?, ?, ?)", ) .bind(uploader_id) .bind(metadata.category_id) @@ -464,17 +485,21 @@ impl Database for Sqlite { .bind(torrent.file_size()) .bind(torrent.info.name.to_string()) .bind(pieces) + .bind(root_hash) .bind(torrent.info.piece_length) .bind(torrent.info.private) - .bind(root_hash) + .bind(is_bep_30) .bind(torrent.info.source.clone()) .bind(torrent.comment.clone()) + .bind(torrent.creation_date) + .bind(torrent.created_by.clone()) + .bind(torrent.encoding.clone()) .execute(&mut *tx) .await .map(|v| v.last_insert_rowid()) .map_err(|e| match e { sqlx::Error::Database(err) => { - log::error!("DB error: {:?}", err); + tracing::error!("DB error: {:?}", err); if err.message().contains("UNIQUE") && err.message().contains("info_hash") { database::Error::TorrentAlreadyExists } else { @@ -495,7 +520,7 @@ impl Database for Sqlite { .await .map(|_| ()) .map_err(|err| { - log::error!("DB error: {:?}", err); + tracing::error!("DB error: {:?}", err); database::Error::Error }); @@ -505,6 +530,8 @@ impl Database for Sqlite { return Err(e); } + // add torrent files + let insert_torrent_files_result = if let Some(length) = torrent.info.length { query("INSERT INTO torrust_torrent_files (md5sum, torrent_id, length) VALUES (?, ?, ?)") .bind(torrent.info.md5sum.clone()) @@ -539,6 +566,8 @@ impl Database for Sqlite { return Err(e); } + // add announce URLs + let insert_torrent_announce_urls_result: Result<(), database::Error> = if let Some(announce_urls) = &torrent.announce_list { // flatten the nested vec (this will however remove the) @@ -573,7 +602,56 @@ impl Database for Sqlite { return Err(e); } - // Insert tags + // add HTTP seeds + + let insert_torrent_http_seeds_result: Result<(), database::Error> = if let Some(http_seeds) = &torrent.httpseeds { + for seed_url in http_seeds { + let () = query("INSERT INTO torrust_torrent_http_seeds (torrent_id, seed_url) VALUES (?, ?)") + .bind(torrent_id) + .bind(seed_url) + .execute(&mut *tx) + .await + .map(|_| ()) + .map_err(|_| database::Error::Error)?; + } + + Ok(()) + } else { + Ok(()) + }; + + // rollback transaction on error + if let Err(e) = insert_torrent_http_seeds_result { + drop(tx.rollback().await); + return Err(e); + } + + // add nodes + + let insert_torrent_nodes_result: Result<(), database::Error> = if let Some(nodes) = &torrent.nodes { + for node in nodes { + let () = query("INSERT INTO torrust_torrent_nodes (torrent_id, node_ip, node_port) VALUES (?, ?, ?)") + .bind(torrent_id) + .bind(node.0.clone()) + .bind(node.1) + .execute(&mut *tx) + .await + .map(|_| ()) + .map_err(|_| database::Error::Error)?; + } + + Ok(()) + } else { + Ok(()) + }; + + // rollback transaction on error + if let Err(e) = insert_torrent_nodes_result { + drop(tx.rollback().await); + return Err(e); + } + + // add tags for tag_id in &metadata.tags { let insert_torrent_tag_result = query("INSERT INTO torrust_torrent_tag_links (torrent_id, tag_id) VALUES (?, ?)") @@ -599,7 +677,7 @@ impl Database for Sqlite { .await .map_err(|e| match e { sqlx::Error::Database(err) => { - log::error!("DB error: {:?}", err); + tracing::error!("DB error: {:?}", err); if err.message().contains("UNIQUE") && err.message().contains("title") { database::Error::TorrentTitleAlreadyExists } else { @@ -731,6 +809,24 @@ impl Database for Sqlite { .map_err(|_| database::Error::TorrentNotFound) } + async fn get_torrent_http_seed_urls_from_id(&self, torrent_id: i64) -> Result, database::Error> { + query_as::<_, DbTorrentHttpSeedUrl>("SELECT seed_url FROM torrust_torrent_http_seeds WHERE torrent_id = ?") + .bind(torrent_id) + .fetch_all(&self.pool) + .await + .map(|v| v.iter().map(|a| a.seed_url.to_string()).collect()) + .map_err(|_| database::Error::TorrentNotFound) + } + + async fn get_torrent_nodes_from_id(&self, torrent_id: i64) -> Result, database::Error> { + query_as::<_, DbTorrentNode>("SELECT node_ip, node_port FROM torrust_torrent_nodes WHERE torrent_id = ?") + .bind(torrent_id) + .fetch_all(&self.pool) + .await + .map(|v| v.iter().map(|a| (a.node_ip.to_string(), a.node_port)).collect()) + .map_err(|_| database::Error::TorrentNotFound) + } + async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result { query_as::<_, TorrentListing>( "SELECT @@ -743,6 +839,9 @@ impl Database for Sqlite { tt.size AS file_size, tt.name, tt.comment, + tt.creation_date, + tt.created_by, + tt.`encoding`, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -770,6 +869,9 @@ impl Database for Sqlite { tt.size AS file_size, tt.name, tt.comment, + tt.creation_date, + tt.created_by, + tt.`encoding`, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -792,6 +894,27 @@ impl Database for Sqlite { .map_err(|_| database::Error::Error) } + async fn get_torrents_with_stats_not_updated_since( + &self, + datetime: DateTime, + limit: i64, + ) -> Result, database::Error> { + query_as::<_, TorrentCompact>( + "SELECT tt.torrent_id, tt.info_hash + FROM torrust_torrents tt + LEFT JOIN torrust_torrent_tracker_stats tts ON tt.torrent_id = tts.torrent_id + WHERE tts.updated_at < ? OR tts.updated_at IS NULL + ORDER BY tts.updated_at ASC + LIMIT ? + ", + ) + .bind(datetime.format(DATETIME_FORMAT).to_string()) + .bind(limit) + .fetch_all(&self.pool) + .await + .map_err(|_| database::Error::Error) + } + async fn update_torrent_title(&self, torrent_id: i64, title: &str) -> Result<(), database::Error> { query("UPDATE torrust_torrent_info SET title = $1 WHERE torrent_id = $2") .bind(title) @@ -800,7 +923,7 @@ impl Database for Sqlite { .await .map_err(|e| match e { sqlx::Error::Database(err) => { - log::error!("DB error: {:?}", err); + tracing::error!("DB error: {:?}", err); if err.message().contains("UNIQUE") && err.message().contains("title") { database::Error::TorrentTitleAlreadyExists } else { @@ -858,7 +981,7 @@ impl Database for Sqlite { .map(|v| v.last_insert_rowid()) .map_err(|e| match e { sqlx::Error::Database(err) => { - log::error!("DB error: {:?}", err); + tracing::error!("DB error: {:?}", err); if err.message().contains("UNIQUE") && err.message().contains("name") { database::Error::TagAlreadyExists } else { @@ -959,15 +1082,16 @@ impl Database for Sqlite { async fn update_tracker_info( &self, torrent_id: i64, - tracker_url: &str, + tracker_url: &Url, seeders: i64, leechers: i64, ) -> Result<(), database::Error> { - query("REPLACE INTO torrust_torrent_tracker_stats (torrent_id, tracker_url, seeders, leechers) VALUES ($1, $2, $3, $4)") + query("REPLACE INTO torrust_torrent_tracker_stats (torrent_id, tracker_url, seeders, leechers, updated_at) VALUES ($1, $2, $3, $4, $5)") .bind(torrent_id) - .bind(tracker_url) + .bind(tracker_url.to_string()) .bind(seeders) .bind(leechers) + .bind(datetime_now()) .execute(&self.pool) .await .map(|_| ()) diff --git a/src/errors.rs b/src/errors.rs index c92a0361..276d9a8d 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -6,6 +6,7 @@ use hyper::StatusCode; use crate::databases::database; use crate::models::torrent::MetadataError; +use crate::tracker::service::TrackerAPIError; use crate::utils::parse_torrent::DecodeTorrentFileError; pub type ServiceResult = Result; @@ -29,6 +30,8 @@ pub enum ServiceError { #[display(fmt = "Invalid username/email or password")] WrongPasswordOrUsername, + #[display(fmt = "Invalid password")] + InvalidPassword, #[display(fmt = "Username not found")] UsernameNotFound, #[display(fmt = "User not found")] @@ -61,7 +64,7 @@ pub enum ServiceError { #[display(fmt = "Username not available")] UsernameTaken, - #[display(fmt = "Username contains illegal characters")] + #[display(fmt = "Invalid username. Usernames must consist of 1-20 alphanumeric characters, dashes, or underscore")] UsernameInvalid, /// email is already taken @@ -84,9 +87,6 @@ pub enum ServiceError { /// token invalid TokenInvalid, - #[display(fmt = "Torrent not found.")] - TorrentNotFound, - #[display(fmt = "Uploaded torrent is not valid.")] InvalidTorrentFile, @@ -109,7 +109,12 @@ pub enum ServiceError { InvalidTag, #[display(fmt = "Unauthorized action.")] - Unauthorized, + UnauthorizedAction, + + #[display( + fmt = "Unauthorized actions for guest users. Try logging in to check if you have permission to perform the action" + )] + UnauthorizedActionForGuests, #[display(fmt = "This torrent already exists in our database.")] InfoHashAlreadyExists, @@ -117,12 +122,12 @@ pub enum ServiceError { #[display(fmt = "A torrent with the same canonical infohash already exists in our database.")] CanonicalInfoHashAlreadyExists, + #[display(fmt = "A torrent with the same original infohash already exists in our database.")] + OriginalInfoHashAlreadyExists, + #[display(fmt = "This torrent title has already been used.")] TorrentTitleAlreadyExists, - #[display(fmt = "Sorry, we have an error with our tracker connection.")] - TrackerOffline, - #[display(fmt = "Could not whitelist torrent.")] WhitelistingError, @@ -141,6 +146,9 @@ pub enum ServiceError { #[display(fmt = "Tag name cannot be empty.")] TagNameEmpty, + #[display(fmt = "Torrent not found.")] + TorrentNotFound, + #[display(fmt = "Category not found.")] CategoryNotFound, @@ -149,6 +157,26 @@ pub enum ServiceError { #[display(fmt = "Database error.")] DatabaseError, + + #[display(fmt = "Authentication error, please sign in")] + LoggedInUserNotFound, + + // Begin tracker errors + #[display(fmt = "Sorry, we have an error with our tracker connection.")] + TrackerOffline, + + #[display(fmt = "Tracker response error. The operation could not be performed.")] + TrackerResponseError, + + #[display(fmt = "Tracker unknown response. Unexpected response from tracker. For example, if it can't be parsed.")] + TrackerUnknownResponse, + + #[display(fmt = "Torrent not found in tracker.")] + TorrentNotFoundInTracker, + + #[display(fmt = "Invalid tracker API token.")] + InvalidTrackerToken, + // End tracker errors } impl From for ServiceError { @@ -228,6 +256,22 @@ impl From for ServiceError { } } +impl From for ServiceError { + fn from(e: TrackerAPIError) -> Self { + eprintln!("{e}"); + match e { + TrackerAPIError::TrackerOffline { error: _ } => ServiceError::TrackerOffline, + TrackerAPIError::InternalServerError | TrackerAPIError::NotFound => ServiceError::TrackerResponseError, + TrackerAPIError::TorrentNotFound => ServiceError::TorrentNotFoundInTracker, + TrackerAPIError::UnexpectedResponseStatus + | TrackerAPIError::MissingResponseBody + | TrackerAPIError::FailedToParseTrackerResponse { body: _ } => ServiceError::TrackerUnknownResponse, + TrackerAPIError::CannotSaveUserKey => ServiceError::DatabaseError, + TrackerAPIError::InvalidToken => ServiceError::InvalidTrackerToken, + } + } +} + #[must_use] pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { #[allow(clippy::match_same_arms)] @@ -236,6 +280,7 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { ServiceError::EmailInvalid => StatusCode::BAD_REQUEST, ServiceError::NotAUrl => StatusCode::BAD_REQUEST, ServiceError::WrongPasswordOrUsername => StatusCode::FORBIDDEN, + ServiceError::InvalidPassword => StatusCode::FORBIDDEN, ServiceError::UsernameNotFound => StatusCode::NOT_FOUND, ServiceError::UserNotFound => StatusCode::NOT_FOUND, ServiceError::AccountNotFound => StatusCode::NOT_FOUND, @@ -260,11 +305,13 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { ServiceError::MissingMandatoryMetadataFields => StatusCode::BAD_REQUEST, ServiceError::InvalidCategory => StatusCode::BAD_REQUEST, ServiceError::InvalidTag => StatusCode::BAD_REQUEST, - ServiceError::Unauthorized => StatusCode::FORBIDDEN, + ServiceError::UnauthorizedAction => StatusCode::FORBIDDEN, + ServiceError::UnauthorizedActionForGuests => StatusCode::UNAUTHORIZED, ServiceError::InfoHashAlreadyExists => StatusCode::BAD_REQUEST, - ServiceError::CanonicalInfoHashAlreadyExists => StatusCode::BAD_REQUEST, + ServiceError::CanonicalInfoHashAlreadyExists => StatusCode::CONFLICT, + ServiceError::OriginalInfoHashAlreadyExists => StatusCode::CONFLICT, ServiceError::TorrentTitleAlreadyExists => StatusCode::BAD_REQUEST, - ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::TrackerOffline => StatusCode::SERVICE_UNAVAILABLE, ServiceError::CategoryNameEmpty => StatusCode::BAD_REQUEST, ServiceError::CategoryAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::TagNameEmpty => StatusCode::BAD_REQUEST, @@ -276,6 +323,11 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { ServiceError::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::CategoryNotFound => StatusCode::NOT_FOUND, ServiceError::TagNotFound => StatusCode::NOT_FOUND, + ServiceError::TrackerResponseError => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::TrackerUnknownResponse => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::TorrentNotFoundInTracker => StatusCode::NOT_FOUND, + ServiceError::InvalidTrackerToken => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::LoggedInUserNotFound => StatusCode::UNAUTHORIZED, } } @@ -288,7 +340,6 @@ pub fn map_database_error_to_service_error(error: &database::Error) -> ServiceEr database::Error::UsernameTaken => ServiceError::UsernameTaken, database::Error::EmailTaken => ServiceError::EmailTaken, database::Error::UserNotFound => ServiceError::UserNotFound, - database::Error::CategoryAlreadyExists => ServiceError::CategoryAlreadyExists, database::Error::CategoryNotFound => ServiceError::InvalidCategory, database::Error::TagAlreadyExists => ServiceError::TagAlreadyExists, database::Error::TagNotFound => ServiceError::InvalidTag, diff --git a/src/lib.rs b/src/lib.rs index 397dc04f..057a4bc7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,7 @@ //! used with by the [Torrust Tracker Index Gui](https://github.com/torrust/torrust-index-gui). //! //! If you are looking for information on how to use the API, please see the -//! [API v1](crate::web::api::v1) section of the documentation. +//! [API v1](crate::web::api::server::v1) section of the documentation. //! //! # Table of contents //! @@ -37,7 +37,7 @@ //! //! From the end-user perspective the Torrust Tracker exposes three different services. //! -//! - A REST [API](crate::web::api::v1) +//! - A REST [API](crate::web::api::server::v1) //! //! From the administrator perspective, the Torrust Index exposes: //! @@ -53,13 +53,13 @@ //! ## Prerequisites //! //! In order the run the index you will need a running torrust tracker. In the -//! configuration you need to fill the `index` section with the following: +//! configuration you need to fill the `tracker` section with the following: //! //! ```toml //! [tracker] //! url = "udp://localhost:6969" -//! mode = "Public" -//! api_url = "http://localhost:1212" +//! +//! api_url = "http://localhost:1212/" //! token = "MyAccessToken" //! token_valid_seconds = 7257600 //! ``` @@ -70,6 +70,16 @@ //! or you can use the docker to run both the tracker and the index. Refer to the //! [Run with docker](#run-with-docker) section for more information. //! +//! You will also need to install this dependency: +//! +//! ```text +//! sudo apt-get install libssl-dev +//! ``` +//! +//! We needed because we are using native TLS support instead of [rustls](https://github.com/rustls/rustls). +//! +//! More info: . +//! //! If you are using `SQLite3` as database driver, you will need to install the //! following dependency: //! @@ -78,8 +88,8 @@ //! ``` //! //! > **NOTICE**: those are the commands for `Ubuntu`. If you are using a -//! different OS, you will need to install the equivalent packages. Please -//! refer to the documentation of your OS. +//! > different OS, you will need to install the equivalent packages. Please +//! > refer to the documentation of your OS. //! //! With the default configuration you will need to create the `storage` directory: //! @@ -110,9 +120,9 @@ //! //! ```text //! mkdir -p ./storage/database \ -//! && export TORRUST_IDX_BACK_USER_UID=1000 \ +//! && export USER_ID=1000 \ //! && docker run -it \ -//! --user="$TORRUST_IDX_BACK_USER_UID" \ +//! --user="$USER_ID" \ //! --publish 3001:3001/tcp \ //! --volume "$(pwd)/storage":"/app/storage" \ //! torrust/index @@ -144,7 +154,7 @@ //! > **WARNING**: The `.env` file is also used by docker-compose. //! //! > **NOTICE**: Refer to the [sqlx-cli](https://github.com/launchbadge/sqlx/tree/main/sqlx-cli) -//! documentation for other commands to create new migrations or run them. +//! > documentation for other commands to create new migrations or run them. //! //! > **NOTICE**: You can run the index with [tmux](https://github.com/tmux/tmux/wiki) with `tmux new -s torrust-index`. //! @@ -160,32 +170,37 @@ //! name = "Torrust" //! //! [tracker] -//! url = "udp://localhost:6969" -//! mode = "Public" -//! api_url = "http://localhost:1212" +//! api_url = "http://localhost:1212/" +//! listed = false +//! private = false //! token = "MyAccessToken" //! token_valid_seconds = 7257600 +//! url = "udp://localhost:6969" //! //! [net] -//! port = 3001 +//! bind_address = "0.0.0.0:3001" //! //! [auth] -//! email_on_signup = "Optional" +//! user_claim_token_pepper = "MaxVerstappenWC2021" +//! +//! [auth.password_constraints] //! min_password_length = 6 //! max_password_length = 64 -//! secret_key = "MaxVerstappenWC2021" //! //! [database] //! connect_url = "sqlite://data.db?mode=rwc" //! //! [mail] -//! email_verification_enabled = false //! from = "example@email.com" //! reply_to = "noreply@email.com" -//! username = "" -//! password = "" -//! server = "" +//! +//! [mail.smtp] //! port = 25 +//! server = "" +//! +//! [mail.smtp.credentials] +//! password = "" +//! username = "" //! //! [image_cache] //! max_request_timeout_ms = 1000 @@ -200,14 +215,15 @@ //! //! [tracker_statistics_importer] //! torrent_info_update_interval = 3600 +//! port = 3002 //! ``` //! //! For more information about configuration you can visit the documentation for the [`config`]) module. //! -//! Alternatively to the `config.toml` file you can use one environment variable `TORRUST_IDX_BACK_CONFIG` to pass the configuration to the tracker: +//! Alternatively to the `config.toml` file you can use one environment variable `TORRUST_INDEX_CONFIG_TOML` to pass the configuration to the tracker: //! //! ```text -//! TORRUST_IDX_BACK_CONFIG=$(cat config.toml) +//! TORRUST_INDEX_CONFIG_TOML=$(cat config.toml) //! cargo run //! ``` //! @@ -215,9 +231,9 @@ //! //! The env var contains the same data as the `config.toml`. It's particularly useful in you are [running the index with docker](https://github.com/torrust/torrust-index/tree/develop/docker). //! -//! > **NOTICE**: The `TORRUST_IDX_BACK_CONFIG` env var has priority over the `config.toml` file. +//! > **NOTICE**: The `TORRUST_INDEX_CONFIG_TOML` env var has priority over the `config.toml` file. //! -//! > **NOTICE**: You can also change the location for the configuration file with the `TORRUST_IDX_BACK_CONFIG_PATH` env var. +//! > **NOTICE**: You can also change the location for the configuration file with the `TORRUST_INDEX_CONFIG_PATH` env var. //! //! # Usage //! @@ -230,7 +246,7 @@ //! This console command allows you to manually import the tracker statistics. //! //! For more information about this command you can visit the documentation for -//! the [`Import tracker statistics`](crate::console::commands::import_tracker_statistics) module. +//! the [`Import tracker statistics`](crate::console::commands::tracker_statistics_importer) module. //! //! ## Upgrader //! diff --git a/src/mailer.rs b/src/mailer.rs index 0c48acd6..5cfc06ac 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -13,7 +13,7 @@ use tera::{try_get_value, Context, Tera}; use crate::config::Configuration; use crate::errors::ServiceError; use crate::utils::clock; -use crate::web::api::v1::routes::API_VERSION_URL_PREFIX; +use crate::web::api::server::v1::routes::API_VERSION_URL_PREFIX; lazy_static! { pub static ref TEMPLATES: Tera = { @@ -70,19 +70,22 @@ impl Service { async fn get_mailer(cfg: &Configuration) -> Mailer { let settings = cfg.settings.read().await; - if !settings.mail.username.is_empty() && !settings.mail.password.is_empty() { + if !settings.mail.smtp.credentials.username.is_empty() && !settings.mail.smtp.credentials.password.is_empty() { // SMTP authentication - let creds = Credentials::new(settings.mail.username.clone(), settings.mail.password.clone()); + let creds = Credentials::new( + settings.mail.smtp.credentials.username.clone(), + settings.mail.smtp.credentials.password.clone(), + ); - AsyncSmtpTransport::::builder_dangerous(&settings.mail.server) - .port(settings.mail.port) + AsyncSmtpTransport::::builder_dangerous(&settings.mail.smtp.server) + .port(settings.mail.smtp.port) .credentials(creds) .authentication(vec![Mechanism::Login, Mechanism::Xoauth2, Mechanism::Plain]) .build() } else { // SMTP without authentication - AsyncSmtpTransport::::builder_dangerous(&settings.mail.server) - .port(settings.mail.port) + AsyncSmtpTransport::::builder_dangerous(&settings.mail.smtp.server) + .port(settings.mail.smtp.port) .build() } } @@ -121,8 +124,8 @@ impl Service { let settings = self.cfg.settings.read().await; Message::builder() - .from(settings.mail.from.parse().unwrap()) - .reply_to(settings.mail.reply_to.parse().unwrap()) + .from(settings.mail.from.clone()) + .reply_to(settings.mail.reply_to.clone()) .to(to.parse().unwrap()) } @@ -130,7 +133,7 @@ impl Service { let settings = self.cfg.settings.read().await; // create verification JWT - let key = settings.auth.secret_key.as_bytes(); + let key = settings.auth.user_claim_token_pepper.as_bytes(); // Create non expiring token that is only valid for email-verification let claims = VerifyClaims { @@ -141,10 +144,10 @@ impl Service { let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).unwrap(); - let mut base_url = &base_url.to_string(); - if let Some(cfg_base_url) = &settings.net.base_url { - base_url = cfg_base_url; - } + let base_url = match &settings.net.base_url { + Some(url) => url.to_string(), + None => base_url.to_string(), + }; format!("{base_url}/{API_VERSION_URL_PREFIX}/user/email/verify/{token}") } @@ -152,7 +155,7 @@ impl Service { fn build_letter(verification_url: &str, username: &str, builder: MessageBuilder) -> Result { let (plain_body, html_body) = build_content(verification_url, username).map_err(|e| { - log::error!("{e}"); + tracing::error!("{e}"); ServiceError::InternalServerError })?; diff --git a/src/main.rs b/src/main.rs index b09eedb6..387c8888 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,9 @@ async fn main() -> Result<(), std::io::Error> { let app = app::run(configuration, &api_version).await; + assert!(!app.api_server_halt_task.is_closed(), "Halt channel should be open"); + match api_version { - Version::V1 => app.api_server.unwrap().await.expect("the API server was dropped"), + Version::V1 => app.api_server.await.expect("the API server was dropped"), } } diff --git a/src/models/category.rs b/src/models/category.rs index 76b74f20..417ed57f 100644 --- a/src/models/category.rs +++ b/src/models/category.rs @@ -1,2 +1,26 @@ +use serde::{Deserialize, Serialize}; + +use crate::databases::database::Category as DatabaseCategory; + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct Category { + pub id: i64, + // Deprecated. Use `id`. + pub category_id: i64, + pub name: String, + pub num_torrents: i64, +} + #[allow(clippy::module_name_repetitions)] pub type CategoryId = i64; + +impl From for Category { + fn from(db_category: DatabaseCategory) -> Self { + Category { + id: db_category.category_id, + category_id: db_category.category_id, + name: db_category.name, + num_torrents: db_category.num_torrents, + } + } +} diff --git a/src/models/response.rs b/src/models/response.rs index 7d408b79..ea3ef7f3 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -1,10 +1,13 @@ use serde::{Deserialize, Serialize}; +use url::Url; +use super::category::Category; use super::torrent::TorrentId; -use crate::databases::database::Category; +use crate::databases::database::Category as DatabaseCategory; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::TorrentFile; use crate::models::torrent_tag::TorrentTag; +use crate::services::torrent::CanonicalInfoHashGroup; pub enum OkResponses { TokenResponse(TokenResponse), @@ -63,18 +66,26 @@ pub struct TorrentResponse { pub tags: Vec, pub name: String, pub comment: Option, + pub creation_date: Option, + pub created_by: Option, + pub encoding: Option, + pub canonical_info_hash_group: Vec, } impl TorrentResponse { #[must_use] - pub fn from_listing(torrent_listing: TorrentListing, category: Option) -> TorrentResponse { + pub fn from_listing( + torrent_listing: TorrentListing, + category: Option, + canonical_info_hash_group: &CanonicalInfoHashGroup, + ) -> TorrentResponse { TorrentResponse { torrent_id: torrent_listing.torrent_id, uploader: torrent_listing.uploader, info_hash: torrent_listing.info_hash, title: torrent_listing.title, description: torrent_listing.description, - category, + category: category.map(std::convert::Into::into), upload_date: torrent_listing.date_uploaded, file_size: torrent_listing.file_size, seeders: torrent_listing.seeders, @@ -85,8 +96,25 @@ impl TorrentResponse { tags: vec![], name: torrent_listing.name, comment: torrent_listing.comment, + creation_date: torrent_listing.creation_date, + created_by: torrent_listing.created_by, + encoding: torrent_listing.encoding, + canonical_info_hash_group: canonical_info_hash_group + .original_info_hashes + .iter() + .map(super::info_hash::InfoHash::to_hex_string) + .collect(), } } + + /// It adds the tracker URL in the first position of the tracker list. + pub fn include_url_as_main_tracker(&mut self, tracker_url: &Url) { + // Remove any existing instances of tracker_url + self.trackers.retain(|tracker| *tracker != tracker_url.to_string()); + + // Insert tracker_url at the first position + self.trackers.insert(0, tracker_url.to_owned().to_string()); + } } #[allow(clippy::module_name_repetitions)] diff --git a/src/models/torrent.rs b/src/models/torrent.rs index 1c2d10cc..40a3f48d 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -25,6 +25,9 @@ pub struct TorrentListing { pub leechers: i64, pub name: String, pub comment: Option, + pub creation_date: Option, + pub created_by: Option, + pub encoding: Option, } #[derive(Debug, Display, PartialEq, Eq, Error)] diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 98e57b92..663084f2 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; use serde_bencode::ser; use serde_bytes::ByteBuf; use sha1::{Digest, Sha1}; +use tracing::error; +use url::Url; use super::info_hash::InfoHash; use crate::utils::hex::{from_bytes, into_bytes}; @@ -12,7 +14,7 @@ pub struct Torrent { #[serde(default)] pub announce: Option, #[serde(default)] - pub nodes: Option>, + pub nodes: Option>, #[serde(default)] pub encoding: Option, #[serde(default)] @@ -30,9 +32,6 @@ pub struct Torrent { pub created_by: Option, } -#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] -pub struct TorrentNode(String, i64); - #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct TorrentInfoDictionary { pub name: String, @@ -75,34 +74,91 @@ impl Torrent { #[must_use] pub fn from_database( db_torrent: &DbTorrent, - torrent_files: &Vec, + torrent_files: &[TorrentFile], torrent_announce_urls: Vec>, + torrent_http_seed_urls: Vec, + torrent_nodes: Vec<(String, i64)>, ) -> Self { + let pieces_or_root_hash = if db_torrent.is_bep_30 == 0 { + if let Some(pieces) = &db_torrent.pieces { + pieces.clone() + } else { + error!("Invalid torrent #{}. Null `pieces` in database", db_torrent.torrent_id); + String::new() + } + } else { + // A BEP-30 torrent + if let Some(root_hash) = &db_torrent.root_hash { + root_hash.clone() + } else { + error!("Invalid torrent #{}. Null `root_hash` in database", db_torrent.torrent_id); + String::new() + } + }; + let info_dict = TorrentInfoDictionary::with( &db_torrent.name, db_torrent.piece_length, db_torrent.private, - db_torrent.root_hash, - &db_torrent.pieces, + db_torrent.is_bep_30, + &pieces_or_root_hash, torrent_files, ); Self { info: info_dict, announce: None, - nodes: None, - encoding: None, - httpseeds: None, + nodes: if torrent_nodes.is_empty() { None } else { Some(torrent_nodes) }, + encoding: db_torrent.encoding.clone(), + httpseeds: if torrent_http_seed_urls.is_empty() { + None + } else { + Some(torrent_http_seed_urls) + }, announce_list: Some(torrent_announce_urls), - creation_date: None, + creation_date: db_torrent.creation_date, comment: db_torrent.comment.clone(), - created_by: None, + created_by: db_torrent.created_by.clone(), } } + /// Includes the tracker URL a the main tracker in the torrent. + /// + /// It will be the URL in the `announce` field and also the first URL in the + /// `announce_list`. + pub fn include_url_as_main_tracker(&mut self, tracker_url: &Url) { + self.set_announce_to(tracker_url); + self.add_url_to_front_of_announce_list(tracker_url); + } + /// Sets the announce url to the tracker url. - pub fn set_announce_to(&mut self, tracker_url: &str) { - self.announce = Some(tracker_url.to_owned()); + pub fn set_announce_to(&mut self, tracker_url: &Url) { + self.announce = Some(tracker_url.to_owned().to_string()); + } + + /// Adds a new tracker URL to the front of the `announce_list`, removes duplicates, + /// and cleans up any empty inner lists. + /// + /// In practice, it's common for the `announce_list` to include the URL from + /// the `announce` field as one of its entries, often in the first tier, + /// to ensure that this primary tracker is always used. However, this is not + /// a strict requirement of the `BitTorrent` protocol; it's more of a + /// convention followed by some torrent creators for redundancy and to + /// ensure better availability of trackers. + pub fn add_url_to_front_of_announce_list(&mut self, tracker_url: &Url) { + if let Some(list) = &mut self.announce_list { + // Remove the tracker URL from existing lists + for inner_list in list.iter_mut() { + inner_list.retain(|url| *url != tracker_url.to_string()); + } + + // Prepend a new vector containing the tracker_url + let vec = vec![tracker_url.to_owned().to_string()]; + list.insert(0, vec); + + // Remove any empty inner lists + list.retain(|inner_list| !inner_list.is_empty()); + } } /// Removes all other trackers if the torrent is private. @@ -202,9 +258,9 @@ impl TorrentInfoDictionary { name: &str, piece_length: i64, private: Option, - root_hash: i64, - pieces: &str, - files: &Vec, + is_bep_30: i64, + pieces_or_root_hash: &str, + files: &[TorrentFile], ) -> Self { let mut info_dict = Self { name: name.to_string(), @@ -219,13 +275,13 @@ impl TorrentInfoDictionary { source: None, }; - // a torrent file has a root hash or a pieces key, but not both. - if root_hash > 0 { - // If `root_hash` is true the `pieces` field contains the `root hash` - info_dict.root_hash = Some(pieces.to_owned()); - } else { - let buffer = into_bytes(pieces).expect("variable `torrent_info.pieces` is not a valid hex string"); + // BEP 30: . + // Torrent file can only hold a `pieces` key or a `root hash` key + if is_bep_30 == 0 { + let buffer = into_bytes(pieces_or_root_hash).expect("variable `torrent_info.pieces` is not a valid hex string"); info_dict.pieces = Some(ByteBuf::from(buffer)); + } else { + info_dict.root_hash = Some(pieces_or_root_hash.to_owned()); } // either set the single file or the multiple files information @@ -234,7 +290,7 @@ impl TorrentInfoDictionary { .first() .expect("vector `torrent_files` should have at least one element"); - info_dict.md5sum = torrent_file.md5sum.clone(); + info_dict.md5sum.clone_from(&torrent_file.md5sum); // DevSkim: ignore DS126858 info_dict.length = Some(torrent_file.length); @@ -252,7 +308,7 @@ impl TorrentInfoDictionary { info_dict.path = path; } else { - info_dict.files = Some(files.clone()); + info_dict.files = Some(files.to_vec()); } info_dict @@ -268,22 +324,22 @@ impl TorrentInfoDictionary { } } - /// It returns the root hash as a `i64` value. - /// - /// # Panics - /// - /// This function will panic if the root hash cannot be converted into a - /// `i64` value. + /// torrent file can only hold a pieces key or a root hash key: + /// [BEP 39](http://www.bittorrent.org/beps/bep_0030.html) #[must_use] - pub fn get_root_hash_as_i64(&self) -> i64 { + pub fn get_root_hash_as_string(&self) -> String { match &self.root_hash { - None => 0i64, - Some(root_hash) => root_hash - .parse::() - .expect("variable `root_hash` cannot be converted into a `i64`"), + None => String::new(), + Some(root_hash) => root_hash.clone(), } } + /// It returns true if the torrent is a BEP-30 torrent. + #[must_use] + pub fn is_bep_30(&self) -> bool { + self.root_hash.is_some() + } + #[must_use] pub fn is_a_single_file_torrent(&self) -> bool { self.length.is_some() @@ -300,12 +356,16 @@ pub struct DbTorrent { pub torrent_id: i64, pub info_hash: String, pub name: String, - pub pieces: String, + pub pieces: Option, + pub root_hash: Option, pub piece_length: i64, #[serde(default)] pub private: Option, - pub root_hash: i64, + pub is_bep_30: i64, pub comment: Option, + pub creation_date: Option, + pub created_by: Option, + pub encoding: Option, } #[allow(clippy::module_name_repetitions)] @@ -322,6 +382,17 @@ pub struct DbTorrentAnnounceUrl { pub tracker_url: String, } +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DbTorrentHttpSeedUrl { + pub seed_url: String, +} + +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DbTorrentNode { + pub node_ip: String, + pub node_port: i64, +} + #[cfg(test)] mod tests { diff --git a/src/models/user.rs b/src/models/user.rs index b115e10c..8347357c 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,3 +1,7 @@ +use std::fmt; +use std::str::FromStr; + +use regex::Regex; use serde::{Deserialize, Serialize}; #[allow(clippy::module_name_repetitions)] @@ -57,3 +61,82 @@ pub struct UserClaims { pub user: UserCompact, pub exp: u64, // epoch in seconds } + +const MAX_USERNAME_LENGTH: usize = 20; +const USERNAME_VALIDATION_ERROR_MSG: &str = "Usernames must consist of 1-20 alphanumeric characters, dashes, or underscore"; + +#[derive(Debug, Clone)] +pub struct UsernameParseError { + message: String, +} + +// Implement std::fmt::Display for UsernameParseError +impl fmt::Display for UsernameParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "UsernameParseError: {}", self.message) + } +} + +// Implement std::error::Error for UsernameParseError +impl std::error::Error for UsernameParseError {} + +pub struct Username(String); + +impl fmt::Display for Username { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// Implement the parsing logic +impl FromStr for Username { + type Err = UsernameParseError; + + fn from_str(s: &str) -> Result { + if s.len() > MAX_USERNAME_LENGTH { + return Err(UsernameParseError { + message: format!("username '{s}' is too long. {USERNAME_VALIDATION_ERROR_MSG}."), + }); + } + + let pattern = format!(r"^[A-Za-z0-9-_]{{1,{MAX_USERNAME_LENGTH}}}$"); + let re = Regex::new(&pattern).expect("username regexp should be valid"); + + if re.is_match(s) { + Ok(Username(s.to_string())) + } else { + Err(UsernameParseError { + message: format!("'{s}' is not a valid username. {USERNAME_VALIDATION_ERROR_MSG}."), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn username_must_consist_of_1_to_20_alphanumeric_characters_or_dashes() { + let username_str = "validUsername123"; + assert!(username_str.parse::().is_ok()); + } + + #[test] + fn username_should_be_shorter_then_21_chars() { + let username_str = "a".repeat(MAX_USERNAME_LENGTH + 1); + assert!(username_str.parse::().is_err()); + } + + #[test] + fn username_should_not_allow_invalid_characters() { + let username_str = "invalid*Username"; + assert!(username_str.parse::().is_err()); + } + + #[test] + fn username_should_be_displayed() { + let username = Username("FirstLast-01".to_string()); + assert_eq!(username.to_string(), "FirstLast-01"); + } +} diff --git a/src/services/about.rs b/src/services/about.rs index 82175bf6..6daacf0b 100644 --- a/src/services/about.rs +++ b/src/services/about.rs @@ -1,15 +1,35 @@ //! Templates for "about" static pages. -use crate::web::api::v1::routes::API_VERSION_URL_PREFIX; -#[must_use] -pub fn index_page() -> String { - page() +use std::sync::Arc; + +use super::authorization::{self, ACTION}; +use crate::errors::ServiceError; +use crate::models::user::UserId; + +pub struct Service { + authorization_service: Arc, } -#[must_use] -pub fn page() -> String { - format!( - r#" +impl Service { + #[must_use] + pub fn new(authorization_service: Arc) -> Service { + Service { authorization_service } + } + + /// Returns the html with the about page + /// + /// # Errors + /// + /// It returns an error if: + /// + /// * The user does not have the required permissions. + /// * There is an error authorizing the action. + pub async fn get_about_page(&self, maybe_user_id: Option) -> Result { + self.authorization_service + .authorize(ACTION::GetAboutPage, maybe_user_id) + .await?; + + let html = r#" About @@ -22,42 +42,55 @@ pub fn page() -> String {

Hi! This is a running torrust-index.

-"# - ) -} - -#[must_use] -pub fn license_page() -> String { - format!( - r#" - - - Licensing - - -

Torrust Index

- -

Licensing

- -

Multiple Licenses

- -

This repository has multiple licenses depending on the content type, the date of contributions or stemming from external component licenses that were not developed by any of Torrust team members or Torrust repository contributors.

+"#; -

The two main applicable license to most of its content are:

+ Ok(html.to_string()) + } -

- For Code -- agpl-3.0

+ /// Returns the html with the license page + /// + /// # Errors + /// + /// It returns an error if: + /// + /// * The user does not have the required permissions. + /// * There is an error authorizing the action. + pub async fn get_license_page(&self, maybe_user_id: Option) -> Result { + self.authorization_service + .authorize(ACTION::GetLicensePage, maybe_user_id) + .await?; -

- For Media (Images, etc.) -- cc-by-sa

+ let html = r#" + + + Licensing + + +

Torrust Index

+ +

Licensing

+ +

Multiple Licenses

+ +

This repository has multiple licenses depending on the content type, the date of contributions or stemming from external component licenses that were not developed by any of Torrust team members or Torrust repository contributors.

+ +

The two main applicable license to most of its content are:

+ +

- For Code -- agpl-3.0

+ +

- For Media (Images, etc.) -- cc-by-sa

+ +

If you want to read more about all the licenses and how they apply please refer to the contributor agreement.

+ + + + "#; -

If you want to read more about all the licenses and how they apply please refer to the contributor agreement.

- - - -"# - ) + Ok(html.to_string()) + } } diff --git a/src/services/authentication.rs b/src/services/authentication.rs index e04342a4..58a9023b 100644 --- a/src/services/authentication.rs +++ b/src/services/authentication.rs @@ -5,17 +5,18 @@ use argon2::{Argon2, PasswordHash, PasswordVerifier}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use pbkdf2::Pbkdf2; -use super::user::{DbUserProfileRepository, DbUserRepository}; +use super::user::DbUserProfileRepository; use crate::config::Configuration; use crate::databases::database::{Database, Error}; use crate::errors::ServiceError; use crate::models::user::{UserAuthentication, UserClaims, UserCompact, UserId}; +use crate::services::user::Repository; use crate::utils::clock; pub struct Service { configuration: Arc, json_web_token: Arc, - user_repository: Arc, + user_repository: Arc>, user_profile_repository: Arc, user_authentication_repository: Arc, } @@ -24,7 +25,7 @@ impl Service { pub fn new( configuration: Arc, json_web_token: Arc, - user_repository: Arc, + user_repository: Arc>, user_profile_repository: Arc, user_authentication_repository: Arc, ) -> Self { @@ -64,13 +65,17 @@ impl Service { .await .map_err(|_| ServiceError::InternalServerError)?; - verify_password(password.as_bytes(), &user_authentication)?; + verify_password(password.as_bytes(), &user_authentication).map_err(|_| ServiceError::WrongPasswordOrUsername)?; let settings = self.configuration.settings.read().await; // Fail login if email verification is required and this email is not verified - if settings.mail.email_verification_enabled && !user_profile.email_verified { - return Err(ServiceError::EmailNotVerified); + if let Some(registration) = &settings.registration { + if let Some(email) = ®istration.email { + if email.verification_required && !user_profile.email_verified { + return Err(ServiceError::EmailNotVerified); + } + } } // Drop read lock on settings @@ -129,7 +134,7 @@ impl JsonWebToken { let settings = self.cfg.settings.read().await; // Create JWT that expires in two weeks - let key = settings.auth.secret_key.as_bytes(); + let key = settings.auth.user_claim_token_pepper.as_bytes(); // todo: create config option for setting the token validity in seconds. let exp_date = clock::now() + 1_209_600; // two weeks from now @@ -149,7 +154,7 @@ impl JsonWebToken { match decode::( token, - &DecodingKey::from_secret(settings.auth.secret_key.as_bytes()), + &DecodingKey::from_secret(settings.auth.user_claim_token_pepper.as_bytes()), &Validation::new(Algorithm::HS256), ) { Ok(token_data) => { @@ -182,6 +187,15 @@ impl DbUserAuthenticationRepository { pub async fn get_user_authentication_from_id(&self, user_id: &UserId) -> Result { self.database.get_user_authentication_from_id(*user_id).await } + + /// It changes the user's password. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn change_password(&self, user_id: UserId, password_hash: &str) -> Result<(), Error> { + self.database.change_user_password(user_id, password_hash).await + } } /// Verify if the user supplied and the database supplied passwords match @@ -189,27 +203,27 @@ impl DbUserAuthenticationRepository { /// # Errors /// /// This function will return an error if unable to parse password hash from the stored user authentication value. -/// This function will return a `ServiceError::WrongPasswordOrUsername` if unable to match the password with either `argon2id` or `pbkdf2-sha256`. -fn verify_password(password: &[u8], user_authentication: &UserAuthentication) -> Result<(), ServiceError> { +/// This function will return a `ServiceError::InvalidPassword` if unable to match the password with either `argon2id` or `pbkdf2-sha256`. +pub fn verify_password(password: &[u8], user_authentication: &UserAuthentication) -> Result<(), ServiceError> { // wrap string of the hashed password into a PasswordHash struct for verification let parsed_hash = PasswordHash::new(&user_authentication.password_hash)?; match parsed_hash.algorithm.as_str() { "argon2id" => { if Argon2::default().verify_password(password, &parsed_hash).is_err() { - return Err(ServiceError::WrongPasswordOrUsername); + return Err(ServiceError::InvalidPassword); } Ok(()) } "pbkdf2-sha256" => { if Pbkdf2.verify_password(password, &parsed_hash).is_err() { - return Err(ServiceError::WrongPasswordOrUsername); + return Err(ServiceError::InvalidPassword); } Ok(()) } - _ => Err(ServiceError::WrongPasswordOrUsername), + _ => Err(ServiceError::InvalidPassword), } } diff --git a/src/services/authorization.rs b/src/services/authorization.rs new file mode 100644 index 00000000..dcca38f8 --- /dev/null +++ b/src/services/authorization.rs @@ -0,0 +1,275 @@ +//! Authorization service. +use std::fmt; +use std::sync::Arc; + +use casbin::{CoreApi, DefaultModel, Enforcer, MgmtApi}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +use super::user::Repository; +use crate::errors::ServiceError; +use crate::models::user::{UserCompact, UserId}; + +#[derive(Debug, PartialEq, Serialize, Deserialize, Hash)] +#[serde(rename_all = "lowercase")] +enum UserRole { + Admin, + Registered, + Guest, +} + +impl fmt::Display for UserRole { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let role_str = match self { + UserRole::Admin => "admin", + UserRole::Registered => "registered", + UserRole::Guest => "guest", + }; + write!(f, "{role_str}") + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Hash)] +pub enum ACTION { + GetAboutPage, + GetLicensePage, + AddCategory, + DeleteCategory, + GetCategories, + GetImageByUrl, + GetSettings, + GetSettingsSecret, + GetPublicSettings, + AddTag, + DeleteTag, + GetTags, + AddTorrent, + GetTorrent, + DeleteTorrent, + GetTorrentInfo, + GenerateTorrentInfoListing, + GetCanonicalInfoHash, + ChangePassword, + BanUser, +} + +pub struct Service { + user_repository: Arc>, + casbin_enforcer: Arc, +} + +impl Service { + #[must_use] + pub fn new(user_repository: Arc>, casbin_enforcer: Arc) -> Self { + Self { + user_repository, + casbin_enforcer, + } + } + + ///Allows or denies an user to perform an action based on the user's privileges + /// + /// # Errors + /// + /// Will return an error if: + /// - The user is not authorized to perform the action. + + pub async fn authorize(&self, action: ACTION, maybe_user_id: Option) -> std::result::Result<(), ServiceError> { + let role = self.get_role(maybe_user_id).await; + + let enforcer = self.casbin_enforcer.enforcer.read().await; + + let authorize = enforcer + .enforce((&role, action)) + .map_err(|_| ServiceError::UnauthorizedAction)?; + + if authorize { + Ok(()) + } else if role == UserRole::Guest { + Err(ServiceError::UnauthorizedActionForGuests) + } else { + Err(ServiceError::UnauthorizedAction) + } + } + + /// It returns the compact user. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + async fn get_user(&self, user_id: UserId) -> std::result::Result { + self.user_repository.get_compact(&user_id).await + } + + /// It returns the role of the user. + /// If the user found in the request does not exist in the database or there is no user id, a guest role is returned + async fn get_role(&self, maybe_user_id: Option) -> UserRole { + match maybe_user_id { + Some(user_id) => { + // Checks if the user found in the request exists in the database + let user_guard = self.get_user(user_id).await; + + match user_guard { + Ok(user_data) => { + if user_data.administrator { + UserRole::Admin + } else { + UserRole::Registered + } + } + Err(_) => UserRole::Guest, + } + } + None => UserRole::Guest, + } + } +} + +pub struct CasbinEnforcer { + enforcer: Arc>, +} + +impl CasbinEnforcer { + /// # Panics + /// + /// Will panic if: + /// + /// - The enforcer can't be created. + /// - The policies can't be loaded. + pub async fn with_default_configuration() -> Self { + let casbin_configuration = CasbinConfiguration::default(); + + let mut enforcer = Enforcer::new(casbin_configuration.default_model().await, ()) + .await + .expect("Error creating the enforcer"); + + enforcer + .add_policies(casbin_configuration.policy_lines()) + .await + .expect("Error loading the policy"); + + let enforcer = Arc::new(RwLock::new(enforcer)); + + Self { enforcer } + } + + /// # Panics + /// + /// Will panic if: + /// + /// - The enforcer can't be created. + /// - The policies can't be loaded. + pub async fn with_configuration(casbin_configuration: CasbinConfiguration) -> Self { + let mut enforcer = Enforcer::new(casbin_configuration.default_model().await, ()) + .await + .expect("Error creating the enforcer"); + + enforcer + .add_policies(casbin_configuration.policy_lines()) + .await + .expect("Error loading the policy"); + + let enforcer = Arc::new(RwLock::new(enforcer)); + + Self { enforcer } + } +} + +#[allow(dead_code)] +pub struct CasbinConfiguration { + model: String, + policy: String, +} + +impl CasbinConfiguration { + #[must_use] + pub fn new(model: &str, policy: &str) -> Self { + Self { + model: model.to_owned(), + policy: policy.to_owned(), + } + } + + /// # Panics + /// + /// It panics if the model cannot be loaded. + async fn default_model(&self) -> DefaultModel { + DefaultModel::from_str(&self.model).await.expect("Error loading the model") + } + + /// Converts the policy from a string type to a vector. + fn policy_lines(&self) -> Vec> { + self.policy + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| line.split(',').map(|s| s.trim().to_owned()).collect::>()) + .collect() + } +} + +impl Default for CasbinConfiguration { + fn default() -> Self { + Self { + model: String::from( + " + [request_definition] + r = role, action + + [policy_definition] + p = role, action + + [policy_effect] + e = some(where (p.eft == allow)) + + [matchers] + m = r.role == p.role && r.action == p.action + ", + ), + policy: String::from( + " + admin, GetAboutPage + admin, GetLicensePage + admin, AddCategory + admin, DeleteCategory + admin, GetCategories + admin, GetImageByUrl + admin, GetSettings + admin, GetSettingsSecret + admin, GetPublicSettings + admin, AddTag + admin, DeleteTag + admin, GetTags + admin, AddTorrent + admin, GetTorrent + admin, DeleteTorrent + admin, GetTorrentInfo + admin, GenerateTorrentInfoListing + admin, GetCanonicalInfoHash + admin, ChangePassword + admin, BanUser + registered, GetAboutPage + registered, GetLicensePage + registered, GetCategories + registered, GetImageByUrl + registered, GetPublicSettings + registered, GetTags + registered, AddTorrent + registered, GetTorrent + registered, GetTorrentInfo + registered, GenerateTorrentInfoListing + registered, GetCanonicalInfoHash + registered, ChangePassword + guest, GetAboutPage + guest, GetLicensePage + guest, GetCategories + guest, GetPublicSettings + guest, GetTags + guest, GetTorrent + guest, GetTorrentInfo + guest, GenerateTorrentInfoListing + guest, GetCanonicalInfoHash + ", + ), + } + } +} diff --git a/src/services/category.rs b/src/services/category.rs index 5abe8aa6..6f40a024 100644 --- a/src/services/category.rs +++ b/src/services/category.rs @@ -1,7 +1,7 @@ //! Category service. use std::sync::Arc; -use super::user::DbUserRepository; +use super::authorization::{self, ACTION}; use crate::databases::database::{Category, Database, Error as DatabaseError}; use crate::errors::ServiceError; use crate::models::category::CategoryId; @@ -9,15 +9,15 @@ use crate::models::user::UserId; pub struct Service { category_repository: Arc, - user_repository: Arc, + authorization_service: Arc, } impl Service { #[must_use] - pub fn new(category_repository: Arc, user_repository: Arc) -> Service { + pub fn new(category_repository: Arc, authorization_service: Arc) -> Service { Service { category_repository, - user_repository, + authorization_service, } } @@ -28,15 +28,13 @@ impl Service { /// It returns an error if: /// /// * The user does not have the required permissions. + /// * The category name is empty. + /// * The category already exists. /// * There is a database error. - pub async fn add_category(&self, category_name: &str, user_id: &UserId) -> Result { - let user = self.user_repository.get_compact(user_id).await?; - - // Check if user is administrator - // todo: extract authorization service - if !user.administrator { - return Err(ServiceError::Unauthorized); - } + pub async fn add_category(&self, category_name: &str, maybe_user_id: Option) -> Result { + self.authorization_service + .authorize(ACTION::AddCategory, maybe_user_id) + .await?; let trimmed_name = category_name.trim(); @@ -44,10 +42,16 @@ impl Service { return Err(ServiceError::CategoryNameEmpty); } - match self.category_repository.add(trimmed_name).await { - Ok(id) => Ok(id), + // Try to get the category by name to check if it already exists + match self.category_repository.get_by_name(trimmed_name).await { + // Return ServiceError::CategoryAlreadyExists if the category exists + Ok(_) => Err(ServiceError::CategoryAlreadyExists), Err(e) => match e { - DatabaseError::CategoryAlreadyExists => Err(ServiceError::CategoryAlreadyExists), + // Otherwise try to create it + DatabaseError::CategoryNotFound => match self.category_repository.add(trimmed_name).await { + Ok(id) => Ok(id), + Err(_) => Err(ServiceError::DatabaseError), + }, _ => Err(ServiceError::DatabaseError), }, } @@ -61,14 +65,10 @@ impl Service { /// /// * The user does not have the required permissions. /// * There is a database error. - pub async fn delete_category(&self, category_name: &str, user_id: &UserId) -> Result<(), ServiceError> { - let user = self.user_repository.get_compact(user_id).await?; - - // Check if user is administrator - // todo: extract authorization service - if !user.administrator { - return Err(ServiceError::Unauthorized); - } + pub async fn delete_category(&self, category_name: &str, maybe_user_id: Option) -> Result<(), ServiceError> { + self.authorization_service + .authorize(ACTION::DeleteCategory, maybe_user_id) + .await?; match self.category_repository.delete(category_name).await { Ok(()) => Ok(()), @@ -78,6 +78,25 @@ impl Service { }, } } + + /// Returns all the categories from the database + /// + /// # Errors + /// + /// It returns an error if: + /// + /// * The user does not have the required permissions. + /// * There is a database error retrieving the categories. + pub async fn get_categories(&self, maybe_user_id: Option) -> Result, ServiceError> { + self.authorization_service + .authorize(ACTION::GetCategories, maybe_user_id) + .await?; + + self.category_repository + .get_all() + .await + .map_err(|_| ServiceError::DatabaseError) + } } pub struct DbCategoryRepository { diff --git a/src/services/mod.rs b/src/services/mod.rs index b2431aec..567c35a9 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,6 +1,7 @@ //! App services. pub mod about; pub mod authentication; +pub mod authorization; pub mod category; pub mod hasher; pub mod proxy; diff --git a/src/services/proxy.rs b/src/services/proxy.rs index 9ea5ef3d..37517c5c 100644 --- a/src/services/proxy.rs +++ b/src/services/proxy.rs @@ -10,21 +10,21 @@ use std::sync::Arc; use bytes::Bytes; -use super::user::DbUserRepository; +use super::authorization::{self, ACTION}; use crate::cache::image::manager::{Error, ImageCacheService}; use crate::models::user::UserId; pub struct Service { image_cache_service: Arc, - user_repository: Arc, + authorization_service: Arc, } impl Service { #[must_use] - pub fn new(image_cache_service: Arc, user_repository: Arc) -> Self { + pub fn new(image_cache_service: Arc, authorization_service: Arc) -> Self { Self { image_cache_service, - user_repository, + authorization_service, } } @@ -38,9 +38,14 @@ impl Service { /// * The image URL is not an image. /// * The image is too big. /// * The user quota is met. - pub async fn get_image_by_url(&self, url: &str, user_id: &UserId) -> Result { - let user = self.user_repository.get_compact(user_id).await.ok(); + #[allow(clippy::missing_panics_doc)] + pub async fn get_image_by_url(&self, url: &str, maybe_user_id: Option) -> Result { + self.authorization_service + .authorize(ACTION::GetImageByUrl, maybe_user_id) + .await + .map_err(|_| Error::Unauthenticated)?; - self.image_cache_service.get_image_by_url(url, user).await + // The unwrap should never panic as if the maybe_user_id is none, an authorization error will be returned and handled at the method above + self.image_cache_service.get_image_by_url(url, maybe_user_id.unwrap()).await } } diff --git a/src/services/settings.rs b/src/services/settings.rs index 5cfe9baf..7578775d 100644 --- a/src/services/settings.rs +++ b/src/services/settings.rs @@ -1,22 +1,27 @@ //! Settings service. +use std::fmt; +use std::str::FromStr; use std::sync::Arc; -use super::user::DbUserRepository; -use crate::config::{Configuration, ConfigurationPublic, TorrustIndex}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use super::authorization::{self, ACTION}; +use crate::config::{Configuration, Settings}; use crate::errors::ServiceError; use crate::models::user::UserId; pub struct Service { configuration: Arc, - user_repository: Arc, + authorization_service: Arc, } impl Service { #[must_use] - pub fn new(configuration: Arc, user_repository: Arc) -> Service { + pub fn new(configuration: Arc, authorization_service: Arc) -> Service { Service { configuration, - user_repository, + authorization_service, } } @@ -25,16 +30,31 @@ impl Service { /// # Errors /// /// It returns an error if the user does not have the required permissions. - pub async fn get_all(&self, user_id: &UserId) -> Result { - let user = self.user_repository.get_compact(user_id).await?; + pub async fn get_all(&self, maybe_user_id: Option) -> Result { + self.authorization_service + .authorize(ACTION::GetSettings, maybe_user_id) + .await?; - // Check if user is administrator - // todo: extract authorization service - if !user.administrator { - return Err(ServiceError::Unauthorized); - } + let torrust_index_configuration = self.configuration.get_all().await; - Ok(self.configuration.get_all().await) + Ok(torrust_index_configuration) + } + + /// It gets all the settings making the secrets with asterisks. + /// + /// # Errors + /// + /// It returns an error if the user does not have the required permissions. + pub async fn get_all_masking_secrets(&self, maybe_user_id: Option) -> Result { + self.authorization_service + .authorize(ACTION::GetSettingsSecret, maybe_user_id) + .await?; + + let mut torrust_index_configuration = self.configuration.get_all().await; + + torrust_index_configuration.remove_secrets(); + + Ok(torrust_index_configuration) } /// It gets only the public settings. @@ -42,8 +62,13 @@ impl Service { /// # Errors /// /// It returns an error if the user does not have the required permissions. - pub async fn get_public(&self) -> ConfigurationPublic { - self.configuration.get_public().await + pub async fn get_public(&self, maybe_user_id: Option) -> Result { + self.authorization_service + .authorize(ACTION::GetPublicSettings, maybe_user_id) + .await?; + + let settings_lock = self.configuration.get_all().await; + Ok(extract_public_settings(&settings_lock)) } /// It gets the site name from the settings. @@ -55,3 +80,119 @@ impl Service { self.configuration.get_site_name().await } } + +fn extract_public_settings(settings: &Settings) -> ConfigurationPublic { + let email_on_signup = match &settings.registration { + Some(registration) => match ®istration.email { + Some(email) => { + if email.required { + EmailOnSignup::Required + } else { + EmailOnSignup::Optional + } + } + None => EmailOnSignup::NotIncluded, + }, + None => EmailOnSignup::NotIncluded, + }; + + ConfigurationPublic { + website_name: settings.website.name.clone(), + tracker_url: settings.tracker.url.clone(), + tracker_listed: settings.tracker.listed, + tracker_private: settings.tracker.private, + email_on_signup, + } +} + +/// The public index configuration. +/// There is an endpoint to get this configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ConfigurationPublic { + website_name: String, + tracker_url: Url, + tracker_listed: bool, + tracker_private: bool, + email_on_signup: EmailOnSignup, +} + +/// Whether the email is required on signup or not. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum EmailOnSignup { + /// The email is required on signup. + Required, + /// The email is optional on signup. + Optional, + /// The email is not allowed on signup. It will only be ignored if provided. + NotIncluded, +} + +impl Default for EmailOnSignup { + fn default() -> Self { + Self::Optional + } +} + +impl fmt::Display for EmailOnSignup { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let display_str = match self { + EmailOnSignup::Required => "required", + EmailOnSignup::Optional => "optional", + EmailOnSignup::NotIncluded => "ignored", + }; + write!(f, "{display_str}") + } +} + +impl FromStr for EmailOnSignup { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "required" => Ok(EmailOnSignup::Required), + "optional" => Ok(EmailOnSignup::Optional), + "none" => Ok(EmailOnSignup::NotIncluded), + _ => Err(format!( + "Unknown config 'email_on_signup' option (required, optional, none): {s}" + )), + } + } +} + +#[cfg(test)] +mod tests { + use crate::config::Configuration; + use crate::services::settings::{extract_public_settings, ConfigurationPublic, EmailOnSignup}; + + #[tokio::test] + async fn configuration_should_return_only_public_settings() { + let configuration = Configuration::default(); + let all_settings = configuration.get_all().await; + + let email_on_signup = match &all_settings.registration { + Some(registration) => match ®istration.email { + Some(email) => { + if email.required { + EmailOnSignup::Required + } else { + EmailOnSignup::Optional + } + } + None => EmailOnSignup::NotIncluded, + }, + None => EmailOnSignup::NotIncluded, + }; + + assert_eq!( + extract_public_settings(&all_settings), + ConfigurationPublic { + website_name: all_settings.website.name, + tracker_url: all_settings.tracker.url, + tracker_listed: all_settings.tracker.listed, + tracker_private: all_settings.tracker.private, + email_on_signup, + } + ); + } +} diff --git a/src/services/tag.rs b/src/services/tag.rs index fcbf56c3..282fc5c2 100644 --- a/src/services/tag.rs +++ b/src/services/tag.rs @@ -1,7 +1,7 @@ //! Tag service. use std::sync::Arc; -use super::user::DbUserRepository; +use super::authorization::{self, ACTION}; use crate::databases::database::{Database, Error as DatabaseError, Error}; use crate::errors::ServiceError; use crate::models::torrent_tag::{TagId, TorrentTag}; @@ -9,15 +9,15 @@ use crate::models::user::UserId; pub struct Service { tag_repository: Arc, - user_repository: Arc, + authorization_service: Arc, } impl Service { #[must_use] - pub fn new(tag_repository: Arc, user_repository: Arc) -> Service { + pub fn new(tag_repository: Arc, authorization_service: Arc) -> Service { Service { tag_repository, - user_repository, + authorization_service, } } @@ -29,14 +29,8 @@ impl Service { /// /// * The user does not have the required permissions. /// * There is a database error. - pub async fn add_tag(&self, tag_name: &str, user_id: &UserId) -> Result { - let user = self.user_repository.get_compact(user_id).await?; - - // Check if user is administrator - // todo: extract authorization service - if !user.administrator { - return Err(ServiceError::Unauthorized); - } + pub async fn add_tag(&self, tag_name: &str, maybe_user_id: Option) -> Result { + self.authorization_service.authorize(ACTION::AddTag, maybe_user_id).await?; let trimmed_name = tag_name.trim(); @@ -61,14 +55,8 @@ impl Service { /// /// * The user does not have the required permissions. /// * There is a database error. - pub async fn delete_tag(&self, tag_id: &TagId, user_id: &UserId) -> Result<(), ServiceError> { - let user = self.user_repository.get_compact(user_id).await?; - - // Check if user is administrator - // todo: extract authorization service - if !user.administrator { - return Err(ServiceError::Unauthorized); - } + pub async fn delete_tag(&self, tag_id: &TagId, maybe_user_id: Option) -> Result<(), ServiceError> { + self.authorization_service.authorize(ACTION::DeleteTag, maybe_user_id).await?; match self.tag_repository.delete(tag_id).await { Ok(()) => Ok(()), @@ -78,6 +66,20 @@ impl Service { }, } } + + /// Returns all the tags from the database + /// + /// # Errors + /// + /// It returns an error if: + /// + /// * The user does not have the required permissions. + /// * There is a database error retrieving the tags. + pub async fn get_tags(&self, maybe_user_id: Option) -> Result, ServiceError> { + self.authorization_service.authorize(ACTION::GetTags, maybe_user_id).await?; + + self.tag_repository.get_all().await.map_err(|_| ServiceError::DatabaseError) + } } pub struct DbTagRepository { diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 0a1f6b94..00c38864 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -1,11 +1,12 @@ //! Torrent service. use std::sync::Arc; -use log::debug; use serde_derive::{Deserialize, Serialize}; +use tracing::debug; +use url::Url; +use super::authorization::{self, ACTION}; use super::category::DbCategoryRepository; -use super::user::DbUserRepository; use crate::config::Configuration; use crate::databases::database::{Database, Error, Sorting}; use crate::errors::ServiceError; @@ -16,6 +17,7 @@ use crate::models::torrent::{Metadata, TorrentId, TorrentListing}; use crate::models::torrent_file::{DbTorrent, Torrent, TorrentFile}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::user::UserId; +use crate::services::user::Repository; use crate::tracker::statistics_importer::StatisticsImporter; use crate::utils::parse_torrent::decode_and_validate_torrent_file; use crate::{tracker, AsCSV}; @@ -24,7 +26,7 @@ pub struct Index { configuration: Arc, tracker_statistics_importer: Arc, tracker_service: Arc, - user_repository: Arc, + user_repository: Arc>, category_repository: Arc, torrent_repository: Arc, torrent_info_hash_repository: Arc, @@ -33,6 +35,7 @@ pub struct Index { torrent_announce_url_repository: Arc, torrent_tag_repository: Arc, torrent_listing_generator: Arc, + authorization_service: Arc, } pub struct AddTorrentRequest { @@ -45,8 +48,8 @@ pub struct AddTorrentRequest { pub struct AddTorrentResponse { pub torrent_id: TorrentId, + pub canonical_info_hash: String, pub info_hash: String, - pub original_info_hash: String, } /// User request to generate a torrent listing. @@ -80,7 +83,7 @@ impl Index { configuration: Arc, tracker_statistics_importer: Arc, tracker_service: Arc, - user_repository: Arc, + user_repository: Arc>, category_repository: Arc, torrent_repository: Arc, torrent_info_hash_repository: Arc, @@ -89,6 +92,7 @@ impl Index { torrent_announce_url_repository: Arc, torrent_tag_repository: Arc, torrent_listing_repository: Arc, + authorization_service: Arc, ) -> Self { Self { configuration, @@ -103,6 +107,7 @@ impl Index { torrent_announce_url_repository, torrent_tag_repository, torrent_listing_generator: torrent_listing_repository, + authorization_service, } } @@ -127,10 +132,11 @@ impl Index { pub async fn add_torrent( &self, add_torrent_req: AddTorrentRequest, - user_id: UserId, + maybe_user_id: Option, ) -> Result { - // Guard that the users exists - let _user = self.user_repository.get_compact(&user_id).await?; + self.authorization_service + .authorize(ACTION::AddTorrent, maybe_user_id) + .await?; let metadata = self.validate_and_build_metadata(&add_torrent_req).await?; @@ -143,7 +149,7 @@ impl Index { let torrent_id = self .torrent_repository - .add(&original_info_hash, &torrent, &metadata, user_id) + .add(&original_info_hash, &torrent, &metadata, maybe_user_id.unwrap()) .await?; // Synchronous secondary tasks @@ -165,15 +171,15 @@ impl Index { { // If the torrent can't be whitelisted somehow, remove the torrent from database drop(self.torrent_repository.delete(&torrent_id).await); - return Err(e); + return Err(e.into()); } // Build response Ok(AddTorrentResponse { torrent_id, - info_hash: torrent.canonical_info_hash_hex(), - original_info_hash: original_info_hash.to_string(), + canonical_info_hash: torrent.canonical_info_hash_hex(), + info_hash: original_info_hash.to_string(), }) } @@ -209,6 +215,8 @@ impl Index { .await?; if !original_info_hashes.is_empty() { + // A previous torrent with the same canonical infohash has been uploaded before + // Torrent with the same canonical infohash was already uploaded debug!("Canonical infohash found: {:?}", canonical_info_hash.to_hex_string()); @@ -216,7 +224,7 @@ impl Index { // The exact original infohash was already uploaded debug!("Original infohash found: {:?}", original_info_hash.to_hex_string()); - return Err(ServiceError::InfoHashAlreadyExists); + return Err(ServiceError::OriginalInfoHashAlreadyExists); } // A new original infohash is being uploaded with a canonical infohash that already exists. @@ -229,6 +237,7 @@ impl Index { return Err(ServiceError::CanonicalInfoHashAlreadyExists); } + // No other torrent with the same canonical infohash has been uploaded before Ok(()) } @@ -253,28 +262,26 @@ impl Index { /// /// This function will return an error if unable to get the torrent from the /// database. - pub async fn get_torrent(&self, info_hash: &InfoHash, opt_user_id: Option) -> Result { + pub async fn get_torrent(&self, info_hash: &InfoHash, maybe_user_id: Option) -> Result { + self.authorization_service + .authorize(ACTION::GetTorrent, maybe_user_id) + .await?; + let mut torrent = self.torrent_repository.get_by_info_hash(info_hash).await?; let tracker_url = self.get_tracker_url().await; + let tracker_is_private = self.tracker_is_private().await; - // Add personal tracker url or default tracker url - match opt_user_id { - Some(user_id) => { - let personal_announce_url = self - .tracker_service - .get_personal_announce_url(user_id) - .await - .unwrap_or(tracker_url); - torrent.announce = Some(personal_announce_url.clone()); - if let Some(list) = &mut torrent.announce_list { - let vec = vec![personal_announce_url]; - list.insert(0, vec); - } - } - None => { - torrent.announce = Some(tracker_url); - } + // code-review: should we remove all tracker URLs in the `announce_list` + // when the tracker is private? + + if !tracker_is_private { + torrent.include_url_as_main_tracker(&tracker_url); + } else if let Some(authenticated_user_id) = maybe_user_id { + let personal_announce_url = self.tracker_service.get_personal_announce_url(authenticated_user_id).await?; + torrent.include_url_as_main_tracker(&personal_announce_url); + } else { + torrent.include_url_as_main_tracker(&tracker_url); } Ok(torrent) @@ -290,21 +297,22 @@ impl Index { /// * The user does not have permission to delete the torrent. /// * Unable to get the torrent listing from it's ID. /// * Unable to delete the torrent from the database. - pub async fn delete_torrent(&self, info_hash: &InfoHash, user_id: &UserId) -> Result { - let user = self.user_repository.get_compact(user_id).await?; - - // Only administrator can delete torrents. - // todo: move this to an authorization service. - if !user.administrator { - return Err(ServiceError::Unauthorized); - } + pub async fn delete_torrent( + &self, + info_hash: &InfoHash, + maybe_user_id: Option, + ) -> Result { + self.authorization_service + .authorize(ACTION::DeleteTorrent, maybe_user_id) + .await?; let torrent_listing = self.torrent_listing_generator.one_torrent_by_info_hash(info_hash).await?; self.torrent_repository.delete(&torrent_listing.torrent_id).await?; // Remove info-hash from tracker whitelist - let _ = self + // todo: handle the error when the tracker is offline or not well configured. + let _unused = self .tracker_service .remove_info_hash_from_whitelist(info_hash.to_string()) .await; @@ -329,82 +337,17 @@ impl Index { pub async fn get_torrent_info( &self, info_hash: &InfoHash, - opt_user_id: Option, + maybe_user_id: Option, ) -> Result { - let torrent_listing = self.torrent_listing_generator.one_torrent_by_info_hash(info_hash).await?; - - let torrent_id = torrent_listing.torrent_id; - - let category = match torrent_listing.category_id { - Some(category_id) => Some(self.category_repository.get_by_id(&category_id).await?), - None => None, - }; - - let mut torrent_response = TorrentResponse::from_listing(torrent_listing, category); - - // Add files - - torrent_response.files = self.torrent_file_repository.get_by_torrent_id(&torrent_id).await?; - - if torrent_response.files.len() == 1 { - let torrent_info = self.torrent_info_repository.get_by_info_hash(info_hash).await?; - - torrent_response - .files - .iter_mut() - .for_each(|v| v.path = vec![torrent_info.name.to_string()]); - } - - // Add trackers - - torrent_response.trackers = self.torrent_announce_url_repository.get_by_torrent_id(&torrent_id).await?; - - let tracker_url = self.get_tracker_url().await; - - // add tracker url - match opt_user_id { - Some(user_id) => { - // if no user owned tracker key can be found, use default tracker url - let personal_announce_url = self - .tracker_service - .get_personal_announce_url(user_id) - .await - .unwrap_or(tracker_url); - // add personal tracker url to front of vec - torrent_response.trackers.insert(0, personal_announce_url); - } - None => { - torrent_response.trackers.insert(0, tracker_url); - } - } - - // Add magnet link - - // todo: extract a struct or function to build the magnet links - let mut magnet = format!( - "magnet:?xt=urn:btih:{}&dn={}", - torrent_response.info_hash, - urlencoding::encode(&torrent_response.title) - ); - - // Add trackers from torrent file to magnet link - for tracker in &torrent_response.trackers { - magnet.push_str(&format!("&tr={}", urlencoding::encode(tracker))); - } - - torrent_response.magnet_link = magnet; + self.authorization_service + .authorize(ACTION::GetTorrentInfo, maybe_user_id) + .await?; - // Get realtime seeders and leechers - if let Ok(torrent_info) = self - .tracker_statistics_importer - .import_torrent_statistics(torrent_response.torrent_id, &torrent_response.info_hash) - .await - { - torrent_response.seeders = torrent_info.seeders; - torrent_response.leechers = torrent_info.leechers; - } + let torrent_listing = self.torrent_listing_generator.one_torrent_by_info_hash(info_hash).await?; - torrent_response.tags = self.torrent_tag_repository.get_tags_for_torrent(&torrent_id).await?; + let torrent_response = self + .build_full_torrent_response(torrent_listing, info_hash, maybe_user_id) + .await?; Ok(torrent_response) } @@ -414,7 +357,15 @@ impl Index { /// # Errors /// /// Returns a `ServiceError::DatabaseError` if the database query fails. - pub async fn generate_torrent_info_listing(&self, request: &ListingRequest) -> Result { + pub async fn generate_torrent_info_listing( + &self, + request: &ListingRequest, + maybe_user_id: Option, + ) -> Result { + self.authorization_service + .authorize(ACTION::GenerateTorrentInfoListing, maybe_user_id) + .await?; + let torrent_listing_specification = self.listing_specification_from_user_request(request).await; let torrents_response = self @@ -486,7 +437,7 @@ impl Index { // Check if user is owner or administrator // todo: move this to an authorization service. if !(torrent_listing.uploader == updater.username || updater.administrator) { - return Err(ServiceError::Unauthorized); + return Err(ServiceError::UnauthorizedAction); } self.torrent_info_repository @@ -498,19 +449,141 @@ impl Index { .one_torrent_by_torrent_id(&torrent_listing.torrent_id) .await?; + let torrent_response = self.build_short_torrent_response(torrent_listing, info_hash).await?; + + Ok(torrent_response) + } + + async fn get_tracker_url(&self) -> Url { + let settings = self.configuration.settings.read().await; + settings.tracker.url.clone() + } + + async fn tracker_is_private(&self) -> bool { + let settings = self.configuration.settings.read().await; + settings.tracker.private + } + + async fn build_short_torrent_response( + &self, + torrent_listing: TorrentListing, + info_hash: &InfoHash, + ) -> Result { let category = match torrent_listing.category_id { Some(category_id) => Some(self.category_repository.get_by_id(&category_id).await?), None => None, }; - let torrent_response = TorrentResponse::from_listing(torrent_listing, category); + let canonical_info_hash_group = self + .torrent_info_hash_repository + .get_canonical_info_hash_group(info_hash) + .await?; + + Ok(TorrentResponse::from_listing( + torrent_listing, + category, + &canonical_info_hash_group, + )) + } + + async fn build_full_torrent_response( + &self, + torrent_listing: TorrentListing, + info_hash: &InfoHash, + maybe_user_id: Option, + ) -> Result { + let torrent_id: i64 = torrent_listing.torrent_id; + + let mut torrent_response = self.build_short_torrent_response(torrent_listing, info_hash).await?; + + // Add files + + torrent_response.files = self.torrent_file_repository.get_by_torrent_id(&torrent_id).await?; + + if torrent_response.files.len() == 1 { + let torrent_info = self.torrent_info_repository.get_by_info_hash(info_hash).await?; + + torrent_response + .files + .iter_mut() + .for_each(|v| v.path = vec![torrent_info.name.to_string()]); + } + + // Add trackers + + // code-review: duplicate logic. We have to check the same in the + // download torrent file endpoint. Here he have only one list of tracker + // like the `announce_list` in the torrent file. + + torrent_response.trackers = self.torrent_announce_url_repository.get_by_torrent_id(&torrent_id).await?; + + let tracker_url = self.get_tracker_url().await; + + if self.tracker_is_private().await { + // Add main tracker URL + match maybe_user_id { + Some(user_id) => { + let personal_announce_url = self.tracker_service.get_personal_announce_url(user_id).await?; + + torrent_response.include_url_as_main_tracker(&personal_announce_url); + } + None => { + torrent_response.include_url_as_main_tracker(&tracker_url); + } + } + } else { + torrent_response.include_url_as_main_tracker(&tracker_url); + } + + // Add magnet link + + // todo: extract a struct or function to build the magnet links + let mut magnet = format!( + "magnet:?xt=urn:btih:{}&dn={}", + torrent_response.info_hash, + urlencoding::encode(&torrent_response.title) + ); + + // Add trackers from torrent file to magnet link + for tracker in &torrent_response.trackers { + magnet.push_str(&format!("&tr={}", urlencoding::encode(tracker))); + } + + torrent_response.magnet_link = magnet; + + // Get realtime seeders and leechers + if let Ok(torrent_info) = self + .tracker_statistics_importer + .import_torrent_statistics(torrent_response.torrent_id, &torrent_response.info_hash) + .await + { + torrent_response.seeders = torrent_info.seeders; + torrent_response.leechers = torrent_info.leechers; + } + + torrent_response.tags = self.torrent_tag_repository.get_tags_for_torrent(&torrent_id).await?; Ok(torrent_response) } - async fn get_tracker_url(&self) -> String { - let settings = self.configuration.settings.read().await; - settings.tracker.url.clone() + /// Returns the canonical info-hash. + /// + /// # Errors + /// + /// Returns an error if the user is not authorized or if there is a problem with the database. + pub async fn get_canonical_info_hash( + &self, + info_hash: &InfoHash, + maybe_user_id: Option, + ) -> Result, ServiceError> { + self.authorization_service + .authorize(ACTION::GetCanonicalInfoHash, maybe_user_id) + .await?; + + self.torrent_info_hash_repository + .find_canonical_info_hash_for(info_hash) + .await + .map_err(|_| ServiceError::DatabaseError) } } @@ -575,6 +648,7 @@ pub struct DbTorrentInfoHash { /// This function returns the original infohashes of a canonical infohash. /// /// The relationship is 1 canonical infohash -> N original infohashes. +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct CanonicalInfoHashGroup { pub canonical_info_hash: InfoHash, /// The list of original infohashes associated to the canonical one. diff --git a/src/services/torrent_file.rs b/src/services/torrent_file.rs index 338ba6e6..a60999dd 100644 --- a/src/services/torrent_file.rs +++ b/src/services/torrent_file.rs @@ -11,14 +11,18 @@ use crate::services::hasher::sha1; pub struct CreateTorrentRequest { // The `info` dictionary fields pub name: String, - pub pieces: String, + pub pieces_or_root_hash: String, pub piece_length: i64, pub private: Option, - pub root_hash: i64, // True (1) if it's a BEP 30 torrent. + /// True (1) if it's a BEP 30 torrent. + pub is_bep_30: i64, pub files: Vec, // Other fields of the root level metainfo dictionary pub announce_urls: Vec>, pub comment: Option, + pub creation_date: Option, + pub created_by: Option, + pub encoding: Option, } impl CreateTorrentRequest { @@ -35,12 +39,12 @@ impl CreateTorrentRequest { info: info_dict, announce: None, nodes: None, - encoding: None, + encoding: self.encoding.clone(), httpseeds: None, announce_list: Some(self.announce_urls.clone()), - creation_date: None, + creation_date: self.creation_date, comment: self.comment.clone(), - created_by: None, + created_by: self.created_by.clone(), } } @@ -55,8 +59,8 @@ impl CreateTorrentRequest { &self.name, self.piece_length, self.private, - self.root_hash, - &self.pieces, + self.is_bep_30, + &self.pieces_or_root_hash, &self.files, ) } @@ -86,13 +90,16 @@ pub fn generate_random_torrent(id: Uuid) -> Torrent { let create_torrent_req = CreateTorrentRequest { name: format!("file-{id}.txt"), - pieces: sha1(&file_contents), + pieces_or_root_hash: sha1(&file_contents), piece_length: 16384, private: None, - root_hash: 0, + is_bep_30: 0, files: torrent_files, announce_urls: torrent_announce_urls, comment: None, + creation_date: None, + created_by: None, + encoding: None, }; create_torrent_req.build_torrent() diff --git a/src/services/user.rs b/src/services/user.rs index 358e7431..ebe3e4a7 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -3,18 +3,24 @@ use std::sync::Arc; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHasher}; +use async_trait::async_trait; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; -use log::{debug, info}; +#[cfg(test)] +use mockall::automock; use pbkdf2::password_hash::rand_core::OsRng; +use tracing::{debug, info}; -use crate::config::{Configuration, EmailOnSignup}; +use super::authentication::DbUserAuthenticationRepository; +use super::authorization::{self, ACTION}; +use crate::config::{Configuration, PasswordConstraints}; use crate::databases::database::{Database, Error}; use crate::errors::ServiceError; use crate::mailer; use crate::mailer::VerifyClaims; -use crate::models::user::{UserCompact, UserId, UserProfile}; +use crate::models::user::{UserCompact, UserId, UserProfile, Username}; +use crate::services::authentication::verify_password; use crate::utils::validation::validate_email_address; -use crate::web::api::v1::contexts::user::forms::RegistrationForm; +use crate::web::api::server::v1::contexts::user::forms::{ChangePasswordForm, RegistrationForm}; /// Since user email could be optional, we need a way to represent "no email" /// in the database. This function returns the string that should be used for @@ -26,7 +32,7 @@ fn no_email() -> String { pub struct RegistrationService { configuration: Arc, mailer: Arc, - user_repository: Arc, + user_repository: Arc>, user_profile_repository: Arc, } @@ -35,7 +41,7 @@ impl RegistrationService { pub fn new( configuration: Arc, mailer: Arc, - user_repository: Arc, + user_repository: Arc>, user_profile_repository: Arc, ) -> Self { Self { @@ -70,80 +76,85 @@ impl RegistrationService { let settings = self.configuration.settings.read().await; - let opt_email = match settings.auth.email_on_signup { - EmailOnSignup::Required => { - if registration_form.email.is_none() { - return Err(ServiceError::EmailMissing); + match &settings.registration { + Some(registration) => { + let Ok(username) = registration_form.username.parse::() else { + return Err(ServiceError::UsernameInvalid); + }; + + let opt_email = match ®istration.email { + Some(email) => { + if email.required && registration_form.email.is_none() { + return Err(ServiceError::EmailMissing); + } + match ®istration_form.email { + Some(email) => { + if email.trim() == String::new() { + None + } else { + Some(email.clone()) + } + } + None => None, + } + } + None => None, + }; + + if let Some(email) = &opt_email { + if !validate_email_address(email) { + return Err(ServiceError::EmailInvalid); + } } - registration_form.email.clone() - } - EmailOnSignup::None => None, - EmailOnSignup::Optional => registration_form.email.clone(), - }; - - if let Some(email) = ®istration_form.email { - if !validate_email_address(email) { - return Err(ServiceError::EmailInvalid); - } - } - - if registration_form.password != registration_form.confirm_password { - return Err(ServiceError::PasswordsDontMatch); - } - - let password_length = registration_form.password.len(); - - if password_length <= settings.auth.min_password_length { - return Err(ServiceError::PasswordTooShort); - } - - if password_length >= settings.auth.max_password_length { - return Err(ServiceError::PasswordTooLong); - } - - let salt = SaltString::generate(&mut OsRng); - - // Argon2 with default params (Argon2id v19) - let argon2 = Argon2::default(); - - // Hash password to PHC string ($argon2id$v=19$...) - let password_hash = argon2 - .hash_password(registration_form.password.as_bytes(), &salt)? - .to_string(); - - if registration_form.username.contains('@') { - return Err(ServiceError::UsernameInvalid); - } - - let user_id = self - .user_repository - .add( - ®istration_form.username, - &opt_email.clone().unwrap_or(no_email()), - &password_hash, - ) - .await?; - - // If this is the first created account, give administrator rights - if user_id == 1 { - drop(self.user_repository.grant_admin_role(&user_id).await); - } - if settings.mail.email_verification_enabled { - if let Some(email) = opt_email { - let mail_res = self - .mailer - .send_verification_mail(&email, ®istration_form.username, user_id, api_base_url) - .await; + let password_constraints = PasswordConstraints { + min_password_length: settings.auth.password_constraints.min_password_length, + max_password_length: settings.auth.password_constraints.max_password_length, + }; + + validate_password_constraints( + ®istration_form.password, + ®istration_form.confirm_password, + &password_constraints, + )?; + + let password_hash = hash_password(®istration_form.password)?; + + let user_id = self + .user_repository + .add( + &username.to_string(), + &opt_email.clone().unwrap_or(no_email()), + &password_hash, + ) + .await?; + + // If this is the first created account, give administrator rights + if user_id == 1 { + drop(self.user_repository.grant_admin_role(&user_id).await); + } - if mail_res.is_err() { - drop(self.user_repository.delete(&user_id).await); - return Err(ServiceError::FailedToSendVerificationEmail); + if let Some(email) = ®istration.email { + if email.verification_required { + // Email verification is enabled + if let Some(email) = opt_email { + let mail_res = self + .mailer + .send_verification_mail(&email, ®istration_form.username, user_id, api_base_url) + .await; + + if mail_res.is_err() { + drop(self.user_repository.delete(&user_id).await); + return Err(ServiceError::FailedToSendVerificationEmail); + } + } + } } + + Ok(user_id) } + None => Err(ServiceError::ClosedForRegistration), } - - Ok(user_id) } /// It verifies the email address of a user via the token sent to the @@ -158,7 +169,7 @@ impl RegistrationService { let token_data = match decode::( token, - &DecodingKey::from_secret(settings.auth.secret_key.as_bytes()), + &DecodingKey::from_secret(settings.auth.user_claim_token_pepper.as_bytes()), &Validation::new(Algorithm::HS256), ) { Ok(token_data) => { @@ -183,23 +194,98 @@ impl RegistrationService { } } +pub struct ProfileService { + configuration: Arc, + user_authentication_repository: Arc, + authorization_service: Arc, +} + +impl ProfileService { + #[must_use] + pub fn new( + configuration: Arc, + user_repository: Arc, + authorization_service: Arc, + ) -> Self { + Self { + configuration, + user_authentication_repository: user_repository, + authorization_service, + } + } + + /// It registers a new user. + /// + /// # Errors + /// + /// This function will return a: + /// + /// * `ServiceError::InvalidPassword` if the current password supplied is invalid. + /// * `ServiceError::PasswordsDontMatch` if the supplied passwords do not match. + /// * `ServiceError::PasswordTooShort` if the supplied password is too short. + /// * `ServiceError::PasswordTooLong` if the supplied password is too long. + /// * An error if unable to successfully hash the password. + /// * An error if unable to change the password in the database. + /// * An error if it is not possible to authorize the action + #[allow(clippy::missing_panics_doc)] + pub async fn change_password( + &self, + maybe_user_id: Option, + change_password_form: &ChangePasswordForm, + ) -> Result<(), ServiceError> { + self.authorization_service + .authorize(ACTION::ChangePassword, maybe_user_id) + .await?; + + info!("changing user password for user ID: {}", maybe_user_id.unwrap()); + + let settings = self.configuration.settings.read().await; + + let user_authentication = self + .user_authentication_repository + .get_user_authentication_from_id(&maybe_user_id.unwrap()) + .await?; + + verify_password(change_password_form.current_password.as_bytes(), &user_authentication)?; + + let password_constraints = PasswordConstraints { + min_password_length: settings.auth.password_constraints.min_password_length, + max_password_length: settings.auth.password_constraints.max_password_length, + }; + + validate_password_constraints( + &change_password_form.password, + &change_password_form.confirm_password, + &password_constraints, + )?; + + let password_hash = hash_password(&change_password_form.password)?; + + self.user_authentication_repository + .change_password(maybe_user_id.unwrap(), &password_hash) + .await?; + + Ok(()) + } +} + pub struct BanService { - user_repository: Arc, user_profile_repository: Arc, banned_user_list: Arc, + authorization_service: Arc, } impl BanService { #[must_use] pub fn new( - user_repository: Arc, user_profile_repository: Arc, banned_user_list: Arc, + authorization_service: Arc, ) -> Self { Self { - user_repository, user_profile_repository, banned_user_list, + authorization_service, } } @@ -212,15 +298,14 @@ impl BanService { /// * `ServiceError::InternalServerError` if unable get user from the request. /// * An error if unable to get user profile from supplied username. /// * An error if unable to set the ban of the user in the database. - pub async fn ban_user(&self, username_to_be_banned: &str, user_id: &UserId) -> Result<(), ServiceError> { - debug!("user with ID {user_id} banning username: {username_to_be_banned}"); - - let user = self.user_repository.get_compact(user_id).await?; + #[allow(clippy::missing_panics_doc)] + pub async fn ban_user(&self, username_to_be_banned: &str, maybe_user_id: Option) -> Result<(), ServiceError> { + debug!( + "user with ID {} banning username: {username_to_be_banned}", + maybe_user_id.unwrap() + ); - // Check if user is administrator - if !user.administrator { - return Err(ServiceError::Unauthorized); - } + self.authorization_service.authorize(ACTION::BanUser, maybe_user_id).await?; let user_profile = self .user_profile_repository @@ -233,6 +318,15 @@ impl BanService { } } +#[cfg_attr(test, automock)] +#[async_trait] +pub trait Repository: Sync + Send { + async fn get_compact(&self, user_id: &UserId) -> Result; + async fn grant_admin_role(&self, user_id: &UserId) -> Result<(), Error>; + async fn delete(&self, user_id: &UserId) -> Result<(), Error>; + async fn add(&self, username: &str, email: &str, password_hash: &str) -> Result; +} + pub struct DbUserRepository { database: Arc>, } @@ -242,13 +336,16 @@ impl DbUserRepository { pub fn new(database: Arc>) -> Self { Self { database } } +} +#[async_trait] +impl Repository for DbUserRepository { /// It returns the compact user. /// /// # Errors /// /// It returns an error if there is a database error. - pub async fn get_compact(&self, user_id: &UserId) -> Result { + async fn get_compact(&self, user_id: &UserId) -> Result { // todo: persistence layer should have its own errors instead of // returning a `ServiceError`. self.database @@ -262,7 +359,7 @@ impl DbUserRepository { /// # Errors /// /// It returns an error if there is a database error. - pub async fn grant_admin_role(&self, user_id: &UserId) -> Result<(), Error> { + async fn grant_admin_role(&self, user_id: &UserId) -> Result<(), Error> { self.database.grant_admin_role(*user_id).await } @@ -271,7 +368,7 @@ impl DbUserRepository { /// # Errors /// /// It returns an error if there is a database error. - pub async fn delete(&self, user_id: &UserId) -> Result<(), Error> { + async fn delete(&self, user_id: &UserId) -> Result<(), Error> { self.database.delete_user(*user_id).await } @@ -280,7 +377,7 @@ impl DbUserRepository { /// # Errors /// /// It returns an error if there is a database error. - pub async fn add(&self, username: &str, email: &str, password_hash: &str) -> Result { + async fn add(&self, username: &str, email: &str, password_hash: &str) -> Result { self.database.insert_user_and_get_id(username, email, password_hash).await } } @@ -349,3 +446,37 @@ impl DbBannedUserList { self.database.ban_user(*user_id, &reason, date_expiry).await } } + +fn validate_password_constraints( + password: &str, + confirm_password: &str, + password_rules: &PasswordConstraints, +) -> Result<(), ServiceError> { + if password != confirm_password { + return Err(ServiceError::PasswordsDontMatch); + } + + let password_length = password.len(); + + if password_length <= password_rules.min_password_length { + return Err(ServiceError::PasswordTooShort); + } + + if password_length >= password_rules.max_password_length { + return Err(ServiceError::PasswordTooLong); + } + + Ok(()) +} + +fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + + // Argon2 with default params (Argon2id v19) + let argon2 = Argon2::default(); + + // Hash password to PHC string ($argon2id$v=19$...) + let password_hash = argon2.hash_password(password.as_bytes(), &salt)?.to_string(); + + Ok(password_hash) +} diff --git a/src/tracker/api.rs b/src/tracker/api.rs index d3fa3fcb..f2e83510 100644 --- a/src/tracker/api.rs +++ b/src/tracker/api.rs @@ -1,31 +1,53 @@ +use std::time::Duration; + use reqwest::{Error, Response}; +use url::Url; pub struct ConnectionInfo { - /// The URL of the tracker. Eg: or - pub url: String, + /// The URL of the tracker API. Eg: . + pub url: Url, /// The token used to authenticate with the tracker API. pub token: String, } impl ConnectionInfo { #[must_use] - pub fn new(url: String, token: String) -> Self { + pub fn new(url: Url, token: String) -> Self { Self { url, token } } } +const TOKEN_PARAM_NAME: &str = "token"; +const API_PATH: &str = "api/v1"; +const TOTAL_REQUEST_TIMEOUT_IN_SECS: u64 = 5; + pub struct Client { pub connection_info: ConnectionInfo, - base_url: String, + api_base_url: Url, + client: reqwest::Client, + token_param: [(String, String); 1], } impl Client { - #[must_use] - pub fn new(connection_info: ConnectionInfo) -> Self { - let base_url = format!("{}/api/v1", connection_info.url); - Self { + /// # Errors + /// + /// Will fails if it can't build a HTTP client with a timeout. + /// + /// # Panics + /// + /// Will panic if the API base URL is not valid. + pub fn new(connection_info: ConnectionInfo) -> Result { + let api_base_url = connection_info.url.join(API_PATH).expect("valid URL API path"); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(TOTAL_REQUEST_TIMEOUT_IN_SECS)) + .build()?; + let token_param = [(TOKEN_PARAM_NAME.to_string(), connection_info.token.to_string())]; + + Ok(Self { connection_info, - base_url, - } + api_base_url, + client, + token_param, + }) } /// Add a torrent to the tracker whitelist. @@ -34,14 +56,9 @@ impl Client { /// /// Will return an error if the HTTP request fails. pub async fn whitelist_torrent(&self, info_hash: &str) -> Result { - let request_url = format!( - "{}/whitelist/{}?token={}", - self.base_url, info_hash, self.connection_info.token - ); + let request_url = format!("{}/whitelist/{}", self.api_base_url, info_hash); - let client = reqwest::Client::new(); - - client.post(request_url).send().await + self.client.post(request_url).query(&self.token_param).send().await } /// Remove a torrent from the tracker whitelist. @@ -50,14 +67,9 @@ impl Client { /// /// Will return an error if the HTTP request fails. pub async fn remove_torrent_from_whitelist(&self, info_hash: &str) -> Result { - let request_url = format!( - "{}/whitelist/{}?token={}", - self.base_url, info_hash, self.connection_info.token - ); - - let client = reqwest::Client::new(); + let request_url = format!("{}/whitelist/{}", self.api_base_url, info_hash); - client.delete(request_url).send().await + self.client.delete(request_url).query(&self.token_param).send().await } /// Retrieve a new tracker key. @@ -66,26 +78,38 @@ impl Client { /// /// Will return an error if the HTTP request fails. pub async fn retrieve_new_tracker_key(&self, token_valid_seconds: u64) -> Result { - let request_url = format!( - "{}/key/{}?token={}", - self.base_url, token_valid_seconds, self.connection_info.token - ); + let request_url = format!("{}/key/{}", self.api_base_url, token_valid_seconds); - let client = reqwest::Client::new(); - - client.post(request_url).send().await + self.client.post(request_url).query(&self.token_param).send().await } - /// Retrieve the info for a torrent. + /// Retrieve the info for one torrent. /// /// # Errors /// /// Will return an error if the HTTP request fails. pub async fn get_torrent_info(&self, info_hash: &str) -> Result { - let request_url = format!("{}/torrent/{}?token={}", self.base_url, info_hash, self.connection_info.token); + let request_url = format!("{}/torrent/{}", self.api_base_url, info_hash); + + self.client.get(request_url).query(&self.token_param).send().await + } - let client = reqwest::Client::new(); + /// Retrieve the info for multiple torrents at the same time. + /// + /// # Errors + /// + /// Will return an error if the HTTP request fails. + pub async fn get_torrents_info(&self, info_hashes: &[String]) -> Result { + let request_url = format!("{}/torrents", self.api_base_url); + + let mut query_params: Vec<(String, String)> = Vec::with_capacity(info_hashes.len() + 1); + + query_params.push((TOKEN_PARAM_NAME.to_string(), self.connection_info.token.clone())); + + for info_hash in info_hashes { + query_params.push(("info_hash".to_string(), info_hash.clone())); + } - client.get(request_url).send().await + self.client.get(request_url).query(&query_params).send().await } } diff --git a/src/tracker/service.rs b/src/tracker/service.rs index e39cf0a6..900206db 100644 --- a/src/tracker/service.rs +++ b/src/tracker/service.rs @@ -1,17 +1,49 @@ use std::sync::Arc; +use derive_more::{Display, Error}; use hyper::StatusCode; -use log::error; use serde::{Deserialize, Serialize}; +use tracing::{debug, error}; +use url::Url; use super::api::{Client, ConnectionInfo}; use crate::config::Configuration; use crate::databases::database::Database; -use crate::errors::ServiceError; use crate::models::tracker_key::TrackerKey; use crate::models::user::UserId; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Display, PartialEq, Eq, Error)] +#[allow(dead_code)] +pub enum TrackerAPIError { + #[display(fmt = "Error with tracker request: {error}.")] + TrackerOffline { error: String }, + + #[display(fmt = "Invalid token for tracker API. Check the tracker token in settings.")] + InvalidToken, + + #[display(fmt = "Tracker returned an internal server error.")] + InternalServerError, + + #[display(fmt = "Tracker returned a not found error.")] + NotFound, + + #[display(fmt = "Tracker returned an unexpected response status.")] + UnexpectedResponseStatus, + + #[display(fmt = "Could not save the newly generated user key into the database.")] + CannotSaveUserKey, + + #[display(fmt = "Torrent not found.")] + TorrentNotFound, + + #[display(fmt = "Expected body in tracker response, received empty body.")] + MissingResponseBody, + + #[display(fmt = "Expected body in tracker response, received empty body.")] + FailedToParseTrackerResponse { body: String }, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] pub struct TorrentInfo { pub info_hash: String, pub seeders: i64, @@ -20,7 +52,15 @@ pub struct TorrentInfo { pub peers: Vec, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct TorrentBasicInfo { + pub info_hash: String, + pub seeders: i64, + pub completed: i64, + pub leechers: i64, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] pub struct Peer { pub peer_id: Option, pub peer_addr: Option, @@ -31,7 +71,7 @@ pub struct Peer { pub event: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, PartialEq)] pub struct PeerId { pub id: Option, pub client: Option, @@ -41,16 +81,20 @@ pub struct Service { database: Arc>, api_client: Client, token_valid_seconds: u64, - tracker_url: String, + tracker_url: Url, } impl Service { + /// # Panics + /// + /// Will panic if it can't build a Tracker API client. pub async fn new(cfg: Arc, database: Arc>) -> Service { let settings = cfg.settings.read().await; let api_client = Client::new(ConnectionInfo::new( settings.tracker.api_url.clone(), - settings.tracker.token.clone(), - )); + settings.tracker.token.clone().to_string(), + )) + .expect("a reqwest client should be provided"); let token_valid_seconds = settings.tracker.token_valid_seconds; let tracker_url = settings.tracker.url.clone(); drop(settings); @@ -68,18 +112,39 @@ impl Service { /// /// Will return an error if the HTTP request failed (for example if the /// tracker API is offline) or if the tracker API returned an error. - pub async fn whitelist_info_hash(&self, info_hash: String) -> Result<(), ServiceError> { - let response = self.api_client.whitelist_torrent(&info_hash).await; + pub async fn whitelist_info_hash(&self, info_hash: String) -> Result<(), TrackerAPIError> { + debug!(target: "tracker-service", "add to whitelist: {info_hash}"); + + let maybe_response = self.api_client.whitelist_torrent(&info_hash).await; - match response { + debug!(target: "tracker-service", "add to whitelist response result: {:?}", maybe_response); + + match maybe_response { Ok(response) => { - if response.status().is_success() { - Ok(()) - } else { - Err(ServiceError::WhitelistingError) + let status: StatusCode = map_status_code(response.status()); + + let body = response.text().await.map_err(|_| { + error!(target: "tracker-service", "response without body"); + TrackerAPIError::MissingResponseBody + })?; + + match status { + StatusCode::OK => Ok(()), + StatusCode::INTERNAL_SERVER_ERROR => { + if body == "Unhandled rejection: Err { reason: \"token not valid\" }" { + Err(TrackerAPIError::InvalidToken) + } else { + error!(target: "tracker-service", "add to whitelist 500 response: status {status}, body: {body}"); + Err(TrackerAPIError::InternalServerError) + } + } + _ => { + error!(target: "tracker-service", "add to whitelist unexpected response: status {status}, body: {body}"); + Err(TrackerAPIError::UnexpectedResponseStatus) + } } } - Err(_) => Err(ServiceError::TrackerOffline), + Err(err) => Err(TrackerAPIError::TrackerOffline { error: err.to_string() }), } } @@ -89,18 +154,39 @@ impl Service { /// /// Will return an error if the HTTP request failed (for example if the /// tracker API is offline) or if the tracker API returned an error. - pub async fn remove_info_hash_from_whitelist(&self, info_hash: String) -> Result<(), ServiceError> { - let response = self.api_client.remove_torrent_from_whitelist(&info_hash).await; + pub async fn remove_info_hash_from_whitelist(&self, info_hash: String) -> Result<(), TrackerAPIError> { + debug!(target: "tracker-service", "remove from whitelist: {info_hash}"); + + let maybe_response = self.api_client.remove_torrent_from_whitelist(&info_hash).await; - match response { + debug!(target: "tracker-service", "remove from whitelist response result: {:?}", maybe_response); + + match maybe_response { Ok(response) => { - if response.status().is_success() { - Ok(()) - } else { - Err(ServiceError::InternalServerError) + let status: StatusCode = map_status_code(response.status()); + + let body = response.text().await.map_err(|_| { + error!(target: "tracker-service", "response without body"); + TrackerAPIError::MissingResponseBody + })?; + + match status { + StatusCode::OK => Ok(()), + StatusCode::INTERNAL_SERVER_ERROR => { + if body == Self::invalid_token_body() { + Err(TrackerAPIError::InvalidToken) + } else { + error!(target: "tracker-service", "remove from whitelist 500 response: status {status}, body: {body}"); + Err(TrackerAPIError::InternalServerError) + } + } + _ => { + error!(target: "tracker-service", "remove from whitelist unexpected response: status {status}, body: {body}"); + Err(TrackerAPIError::UnexpectedResponseStatus) + } } } - Err(_) => Err(ServiceError::InternalServerError), + Err(err) => Err(TrackerAPIError::TrackerOffline { error: err.to_string() }), } } @@ -115,14 +201,16 @@ impl Service { /// /// Will return an error if the HTTP request to get generated a new /// user tracker key failed. - pub async fn get_personal_announce_url(&self, user_id: UserId) -> Result { + pub async fn get_personal_announce_url(&self, user_id: UserId) -> Result { + debug!(target: "tracker-service", "get personal announce url for user: {user_id}"); + let tracker_key = self.database.get_user_tracker_key(user_id).await; match tracker_key { - Some(v) => Ok(self.announce_url_with_key(&v)), + Some(tracker_key) => Ok(self.announce_url_with_key(&tracker_key)), None => match self.retrieve_new_tracker_key(user_id).await { - Ok(v) => Ok(self.announce_url_with_key(&v)), - Err(_) => Err(ServiceError::TrackerOffline), + Ok(new_tracker_key) => Ok(self.announce_url_with_key(&new_tracker_key)), + Err(err) => Err(TrackerAPIError::TrackerOffline { error: err.to_string() }), }, } } @@ -133,65 +221,177 @@ impl Service { /// /// Will return an error if the HTTP request to get torrent info fails or /// if the response cannot be parsed. - pub async fn get_torrent_info(&self, info_hash: &str) -> Result { - let response = self - .api_client - .get_torrent_info(info_hash) - .await - .map_err(|_| ServiceError::InternalServerError)?; - - if response.status() == StatusCode::NOT_FOUND { - return Err(ServiceError::TorrentNotFound); + pub async fn get_torrent_info(&self, info_hash: &str) -> Result { + debug!(target: "tracker-service", "get torrent info: {info_hash}"); + + let maybe_response = self.api_client.get_torrent_info(info_hash).await; + + debug!(target: "tracker-service", "get torrent info response result: {:?}", maybe_response); + + match maybe_response { + Ok(response) => { + let status: StatusCode = map_status_code(response.status()); + + let body = response.text().await.map_err(|_| { + error!(target: "tracker-service", "response without body"); + TrackerAPIError::MissingResponseBody + })?; + + match status { + StatusCode::NOT_FOUND => Err(TrackerAPIError::TorrentNotFound), + StatusCode::OK => { + if body == Self::torrent_not_known_body() { + // todo: temporary fix. the service should return a 404 (StatusCode::NOT_FOUND). + return Err(TrackerAPIError::TorrentNotFound); + } + + serde_json::from_str(&body).map_err(|e| { + error!( + target: "tracker-service", "Failed to parse torrent info from tracker response. Body: {}, Error: {}", + body, e + ); + TrackerAPIError::FailedToParseTrackerResponse { body } + }) + } + StatusCode::INTERNAL_SERVER_ERROR => { + if body == Self::invalid_token_body() { + Err(TrackerAPIError::InvalidToken) + } else { + error!(target: "tracker-service", "get torrent info 500 response: status {status}, body: {body}"); + Err(TrackerAPIError::InternalServerError) + } + } + _ => { + error!(target: "tracker-service", "get torrent info unhandled response: status {status}, body: {body}"); + Err(TrackerAPIError::UnexpectedResponseStatus) + } + } + } + Err(err) => Err(TrackerAPIError::TrackerOffline { error: err.to_string() }), } + } + + /// Get torrent info from tracker in batches. + /// + /// # Errors + /// + /// Will return an error if the HTTP request to get torrent info fails or + /// if the response cannot be parsed. + pub async fn get_torrents_info(&self, info_hashes: &[String]) -> Result, TrackerAPIError> { + debug!(target: "tracker-service", "get torrents info"); + + let maybe_response = self.api_client.get_torrents_info(info_hashes).await; + + debug!(target: "tracker-service", "get torrents info response result: {:?}", maybe_response); + + match maybe_response { + Ok(response) => { + let status: StatusCode = map_status_code(response.status()); + let url = response.url().clone(); - let body = response.text().await; + let body = response.text().await.map_err(|_| { + error!(target: "tracker-service", "response without body"); + TrackerAPIError::MissingResponseBody + })?; - if let Ok(body) = body { - if body == *"torrent not known" { - // todo: temporary fix. the service should return a 404 (StatusCode::NOT_FOUND). - return Err(ServiceError::TorrentNotFound); + match status { + StatusCode::OK => serde_json::from_str(&body).map_err(|e| { + error!( + target: "tracker-service", "Failed to parse torrents info from tracker response. Body: {}, Error: {}", + body, e + ); + TrackerAPIError::FailedToParseTrackerResponse { body } + }), + StatusCode::INTERNAL_SERVER_ERROR => { + if body == Self::invalid_token_body() { + Err(TrackerAPIError::InvalidToken) + } else { + error!(target: "tracker-service", "get torrents info 500 response: status {status}, body: {body}"); + Err(TrackerAPIError::InternalServerError) + } + } + StatusCode::NOT_FOUND => { + error!(target: "tracker-service", "get torrents info 404 response: url {url}"); + Err(TrackerAPIError::NotFound) + } + _ => { + error!(target: "tracker-service", "get torrents info unhandled response: status {status}, body: {body}"); + Err(TrackerAPIError::UnexpectedResponseStatus) + } + } } + Err(err) => Err(TrackerAPIError::TrackerOffline { error: err.to_string() }), + } + } + + /// Issue a new tracker key from tracker. + async fn retrieve_new_tracker_key(&self, user_id: i64) -> Result { + debug!(target: "tracker-service", "retrieve key: {user_id}"); + + let maybe_response = self.api_client.retrieve_new_tracker_key(self.token_valid_seconds).await; + + debug!(target: "tracker-service", "retrieve key response result: {:?}", maybe_response); + + match maybe_response { + Ok(response) => { + let status: StatusCode = map_status_code(response.status()); - let torrent_info = serde_json::from_str(&body); + let body = response.text().await.map_err(|_| { + error!(target: "tracker-service", "response without body"); + TrackerAPIError::MissingResponseBody + })?; - if let Ok(torrent_info) = torrent_info { - Ok(torrent_info) - } else { - error!("Failed to parse torrent info from tracker response. Body: {}", body); - Err(ServiceError::InternalServerError) + match status { + StatusCode::OK => { + // Parse tracker key from response + let tracker_key = + serde_json::from_str(&body).map_err(|_| TrackerAPIError::FailedToParseTrackerResponse { body })?; + + // Add tracker key to database (tied to a user) + self.database + .add_tracker_key(user_id, &tracker_key) + .await + .map_err(|_| TrackerAPIError::CannotSaveUserKey)?; + + Ok(tracker_key) + } + StatusCode::INTERNAL_SERVER_ERROR => { + if body == Self::invalid_token_body() { + Err(TrackerAPIError::InvalidToken) + } else { + error!(target: "tracker-service", "retrieve key 500 response: status {status}, body: {body}"); + Err(TrackerAPIError::InternalServerError) + } + } + _ => { + error!(target: "tracker-service", " retrieve key unexpected response: status {status}, body: {body}"); + Err(TrackerAPIError::UnexpectedResponseStatus) + } + } } - } else { - error!("Tracker API response without body"); - Err(ServiceError::InternalServerError) + Err(err) => Err(TrackerAPIError::TrackerOffline { error: err.to_string() }), } } /// It builds the announce url appending the user tracker key. /// Eg: - fn announce_url_with_key(&self, tracker_key: &TrackerKey) -> String { - format!("{}/{}", self.tracker_url, tracker_key.key) + fn announce_url_with_key(&self, tracker_key: &TrackerKey) -> Url { + self.tracker_url + .join(&tracker_key.key) + .expect("a tracker key should be added to the tracker base URL") } - /// Issue a new tracker key from tracker and save it in database, - /// tied to a user - async fn retrieve_new_tracker_key(&self, user_id: i64) -> Result { - // Request new tracker key from tracker - let response = self - .api_client - .retrieve_new_tracker_key(self.token_valid_seconds) - .await - .map_err(|_| ServiceError::InternalServerError)?; - - // Parse tracker key from response - let tracker_key = response - .json::() - .await - .map_err(|_| ServiceError::InternalServerError)?; - - // Add tracker key to database (tied to a user) - self.database.add_tracker_key(user_id, &tracker_key).await?; - - // return tracker key - Ok(tracker_key) + fn invalid_token_body() -> String { + "Unhandled rejection: Err { reason: \"token not valid\" }".to_string() } + + fn torrent_not_known_body() -> String { + "\"torrent not known\"".to_string() + } +} + +/// Temporary patch to map `StatusCode` from crate `http` 0.2.11 to `http` v1.0.0 +/// until `reqwest` upgrades to hyper 1.0. See +fn map_status_code(status: reqwest::StatusCode) -> hyper::StatusCode { + StatusCode::from_u16(status.as_u16()).unwrap() } diff --git a/src/tracker/statistics_importer.rs b/src/tracker/statistics_importer.rs index 128cce12..bc256a2c 100644 --- a/src/tracker/statistics_importer.rs +++ b/src/tracker/statistics_importer.rs @@ -1,16 +1,21 @@ use std::sync::Arc; +use std::time::Instant; -use log::{error, info}; +use chrono::{DateTime, Utc}; +use text_colorizer::Colorize; +use tracing::{debug, error, info}; +use url::Url; -use super::service::{Service, TorrentInfo}; +use super::service::{Service, TorrentInfo, TrackerAPIError}; use crate::config::Configuration; use crate::databases::database::{self, Database}; -use crate::errors::ServiceError; + +const LOG_TARGET: &str = "Tracker Stats Importer"; pub struct StatisticsImporter { database: Arc>, tracker_service: Arc, - tracker_url: String, + tracker_url: Url, } impl StatisticsImporter { @@ -31,30 +36,103 @@ impl StatisticsImporter { /// /// Will return an error if the database query failed. pub async fn import_all_torrents_statistics(&self) -> Result<(), database::Error> { - info!("Importing torrents statistics from tracker ..."); let torrents = self.database.get_all_torrents_compact().await?; + if torrents.is_empty() { + return Ok(()); + } + + info!(target: LOG_TARGET, "Importing {} torrents statistics from tracker {} ...", torrents.len().to_string().yellow(), self.tracker_url.to_string().yellow()); + + // Start the timer before the loop + let start_time = Instant::now(); + for torrent in torrents { - info!("Updating torrent {} ...", torrent.torrent_id); + info!(target: LOG_TARGET, "Importing torrent #{} statistics ...", torrent.torrent_id.to_string().yellow()); let ret = self.import_torrent_statistics(torrent.torrent_id, &torrent.info_hash).await; - // code-review: should we treat differently for each case?. The - // tracker API could be temporarily offline, or there could be a - // tracker misconfiguration. - // - // This is the log when the torrent is not found in the tracker: - // - // ``` - // 2023-05-09T13:31:24.497465723+00:00 [torrust_index::tracker::statistics_importer][ERROR] Error updating torrent tracker stats for torrent with id 140: TorrentNotFound - // ``` - if let Some(err) = ret.err() { - let message = format!( - "Error updating torrent tracker stats for torrent with id {}: {:?}", - torrent.torrent_id, err - ); - error!("{}", message); + if err != TrackerAPIError::TorrentNotFound { + let message = format!( + "Error updating torrent tracker stats for torrent. Torrent: id {}; infohash {}. Error: {:?}", + torrent.torrent_id, torrent.info_hash, err + ); + error!(target: "statistics_importer", "{}", message); + // todo: return a service error that can be a tracker API error or a database error. + } + } + } + + let elapsed_time = start_time.elapsed(); + + info!(target: LOG_TARGET, "Statistics import completed in {:.2?}", elapsed_time); + + Ok(()) + } + + /// Import torrents statistics not updated recently.. + /// + /// # Errors + /// + /// Will return an error if the database query failed. + pub async fn import_torrents_statistics_not_updated_since( + &self, + datetime: DateTime, + limit: i64, + ) -> Result<(), database::Error> { + debug!(target: LOG_TARGET, "Importing torrents statistics not updated since {} limited to a maximum of {} torrents ...", datetime.to_string().yellow(), limit.to_string().yellow()); + + let torrents = self + .database + .get_torrents_with_stats_not_updated_since(datetime, limit) + .await?; + + if torrents.is_empty() { + return Ok(()); + } + + info!(target: LOG_TARGET, "Importing {} torrents statistics from tracker {} ...", torrents.len().to_string().yellow(), self.tracker_url.to_string().yellow()); + + // Import stats for all torrents in one request + + let info_hashes: Vec = torrents.iter().map(|t| t.info_hash.clone()).collect(); + + let torrent_info_vec = match self.tracker_service.get_torrents_info(&info_hashes).await { + Ok(torrents_info) => torrents_info, + Err(err) => { + let message = format!("Error getting torrents tracker stats. Error: {err:?}"); + error!(target: LOG_TARGET, "{}", message); + // todo: return a service error that can be a tracker API error or a database error. + return Ok(()); + } + }; + + // Update stats for all torrents + + for torrent in torrents { + match torrent_info_vec.iter().find(|t| t.info_hash == torrent.info_hash) { + None => { + // No stats for this torrent in the tracker + drop( + self.database + .update_tracker_info(torrent.torrent_id, &self.tracker_url, 0, 0) + .await, + ); + } + Some(torrent_info) => { + // Update torrent stats for this tracker + drop( + self.database + .update_tracker_info( + torrent.torrent_id, + &self.tracker_url, + torrent_info.seeders, + torrent_info.leechers, + ) + .await, + ); + } } } @@ -67,17 +145,20 @@ impl StatisticsImporter { /// /// Will return an error if the HTTP request failed or the torrent is not /// found. - pub async fn import_torrent_statistics(&self, torrent_id: i64, info_hash: &str) -> Result { - if let Ok(torrent_info) = self.tracker_service.get_torrent_info(info_hash).await { - drop( - self.database - .update_tracker_info(torrent_id, &self.tracker_url, torrent_info.seeders, torrent_info.leechers) - .await, - ); - Ok(torrent_info) - } else { - drop(self.database.update_tracker_info(torrent_id, &self.tracker_url, 0, 0).await); - Err(ServiceError::TorrentNotFound) + pub async fn import_torrent_statistics(&self, torrent_id: i64, info_hash: &str) -> Result { + match self.tracker_service.get_torrent_info(info_hash).await { + Ok(torrent_info) => { + drop( + self.database + .update_tracker_info(torrent_id, &self.tracker_url, torrent_info.seeders, torrent_info.leechers) + .await, + ); + Ok(torrent_info) + } + Err(err) => { + drop(self.database.update_tracker_info(torrent_id, &self.tracker_url, 0, 0).await); + Err(err) + } } } } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 37a06d5e..13e7d023 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -1,6 +1,6 @@ #![allow(clippy::missing_errors_doc)] -use chrono::{DateTime, NaiveDateTime, Utc}; +use chrono::DateTime; use serde::{Deserialize, Serialize}; use sqlx::sqlite::{SqlitePoolOptions, SqliteQueryResult}; use sqlx::{query, query_as, SqlitePool}; @@ -23,10 +23,11 @@ pub struct TorrentRecordV2 { pub info_hash: String, pub size: i64, pub name: String, - pub pieces: String, + pub pieces: Option, + pub root_hash: Option, pub piece_length: i64, pub private: Option, - pub root_hash: i64, + pub is_bep_30: i64, pub date_uploaded: String, } @@ -40,10 +41,11 @@ impl TorrentRecordV2 { info_hash: torrent.info_hash.clone(), size: torrent.file_size, name: torrent_info.name.clone(), - pieces: torrent_info.get_pieces_as_string(), + pieces: Some(torrent_info.get_pieces_as_string()), + root_hash: Some(torrent_info.get_root_hash_as_string()), piece_length: torrent_info.piece_length, private: torrent_info.private, - root_hash: torrent_info.get_root_hash_as_i64(), + is_bep_30: i64::from(torrent_info.is_bep_30()), date_uploaded: convert_timestamp_to_datetime(torrent.upload_date), } } @@ -59,11 +61,10 @@ pub fn convert_timestamp_to_datetime(timestamp: i64) -> String { // The expected format in database is: 2022-11-04 09:53:57 // MySQL uses a DATETIME column and SQLite uses a TEXT column. - let naive_datetime = NaiveDateTime::from_timestamp_opt(timestamp, 0).expect("Overflow of i64 seconds, very future!"); - let datetime_again: DateTime = DateTime::from_naive_utc_and_offset(naive_datetime, Utc); + let datetime = DateTime::from_timestamp(timestamp, 0).expect("Overflow of i64 seconds, very future!"); // Format without timezone - datetime_again.format("%Y-%m-%d %H:%M:%S").to_string() + datetime.format("%Y-%m-%d %H:%M:%S").to_string() } pub struct SqliteDatabaseV2_0_0 { @@ -116,16 +117,7 @@ impl SqliteDatabaseV2_0_0 { .execute(&self.pool) .await .map(|v| v.last_insert_rowid()) - .map_err(|e| match e { - sqlx::Error::Database(err) => { - if err.message().contains("UNIQUE") && err.message().contains("name") { - database::Error::CategoryAlreadyExists - } else { - database::Error::Error - } - } - _ => database::Error::Error, - }) + .map_err(|_| database::Error::Error) } pub async fn insert_category(&self, category: &CategoryRecordV2) -> Result { @@ -205,7 +197,7 @@ impl SqliteDatabaseV2_0_0 { pieces, piece_length, private, - root_hash, + is_bep_30, date_uploaded ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) @@ -218,7 +210,7 @@ impl SqliteDatabaseV2_0_0 { .bind(torrent.pieces.clone()) .bind(torrent.piece_length) .bind(torrent.private.unwrap_or(0)) - .bind(torrent.root_hash) + .bind(torrent.is_bep_30) .bind(torrent.date_uploaded.clone()) .execute(&self.pool) .await diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 71e58413..51b58637 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -23,8 +23,8 @@ //! //! - The database schema was changed. //! - The torrents are now stored entirely in the database. The torrent files -//! are not stored in the filesystem anymore. This command reads the torrent -//! files from the filesystem and store them in the database. +//! are not stored in the filesystem anymore. This command reads the torrent +//! files from the filesystem and store them in the database. //! //! We recommend to download your production database and the torrent files dir. //! And run the command in a local environment with the version `v2.0.0.`. Then, @@ -36,13 +36,13 @@ //! NOTES for `torrust_users` table transfer: //! //! - In v2, the table `torrust_user` contains a field `date_registered` non -//! existing in v1. We changed that column to allow `NULL`. We also added the -//! new column `date_imported` with the datetime when the upgrader was executed. +//! existing in v1. We changed that column to allow `NULL`. We also added the +//! new column `date_imported` with the datetime when the upgrader was executed. //! //! NOTES for `torrust_user_profiles` table transfer: //! //! - In v2, the table `torrust_user_profiles` contains two new fields: `bio` -//! and `avatar`. Empty string is used as default value. +//! and `avatar`. Empty string is used as default value. //! //! //! If you want more information about this command you can read the [issue 56](https://github.com/torrust/torrust-index/issues/56). diff --git a/src/utils/clock.rs b/src/utils/clock.rs index b17ee48b..5ce8c40e 100644 --- a/src/utils/clock.rs +++ b/src/utils/clock.rs @@ -1,3 +1,7 @@ +use chrono::{DateTime, TimeDelta, Utc}; + +pub const DATETIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S"; + /// Returns the current timestamp in seconds. /// /// # Panics @@ -8,3 +12,23 @@ pub fn now() -> u64 { u64::try_from(chrono::prelude::Utc::now().timestamp()).expect("timestamp should be positive") } + +/// Returns the datetime some seconds ago. +/// +/// # Panics +/// +/// The function panics if the number of seconds passed as a parameter +/// are more than `i64::MAX` / `1_000` or less than `-i64::MAX` / `1_000`. +#[must_use] +pub fn seconds_ago_utc(seconds: i64) -> DateTime { + Utc::now() + - TimeDelta::try_seconds(seconds).expect("seconds should be more than i64::MAX / 1_000 or less than -i64::MAX / 1_000") +} + +/// Returns the current time in database format. +/// +/// For example: `2024-03-12 15:56:24`. +#[must_use] +pub fn datetime_now() -> String { + Utc::now().format(DATETIME_FORMAT).to_string() +} diff --git a/src/utils/parse_torrent.rs b/src/utils/parse_torrent.rs index 69e69011..0446f331 100644 --- a/src/utils/parse_torrent.rs +++ b/src/utils/parse_torrent.rs @@ -1,7 +1,7 @@ use std::error; use derive_more::{Display, Error}; -use serde::{self, Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; use serde_bencode::value::Value; use serde_bencode::{de, Error}; use sha1::{Digest, Sha1}; @@ -94,9 +94,9 @@ struct ParsedInfoDictFromMetainfoFile { /// This function will return an error if: /// /// - The torrent file is not a valid bencoded torrent file containing an `info` -/// dictionary key. +/// dictionary key. /// - The original torrent info-hash cannot be bencoded from the parsed `info` -/// dictionary is not a valid bencoded dictionary. +/// dictionary is not a valid bencoded dictionary. pub fn calculate_info_hash(bytes: &[u8]) -> Result { // Extract the info dictionary let metainfo: ParsedInfoDictFromMetainfoFile = @@ -115,40 +115,152 @@ pub fn calculate_info_hash(bytes: &[u8]) -> Result Vec { + /* code-review: + + This is the contents of the torrent file in fixtures dir. After adding + some tests using the following: + + `figment::Jail::expect_with(|jail| { ... });`` + + some tests using relative paths for fixtures failed. It seems + Figment::Jail changes the current dir. In the drop function it restores + it but that could cause a problem when test are running in parallel. + + For the time being, I have replaced loading the torrent file with + this function. This should make the test faster. + + I don't know why this happens only with these two tests. + + */ + + // cspell:disable-next-line + // "tests/fixtures/torrents/6c690018c5786dbbb00161f62b0712d69296df97_with_custom_info_dict_key.torrent" + + [ + 100, 56, 58, 97, 110, 110, 111, 117, 110, 99, 101, 52, 49, 58, 104, 116, 116, 112, 115, 58, 47, 47, 97, 99, 97, 100, + 101, 109, 105, 99, 116, 111, 114, 114, 101, 110, 116, 115, 46, 99, 111, 109, 47, 97, 110, 110, 111, 117, 110, 99, + 101, 46, 112, 104, 112, 49, 51, 58, 97, 110, 110, 111, 117, 110, 99, 101, 45, 108, 105, 115, 116, 108, 108, 52, 49, + 58, 104, 116, 116, 112, 115, 58, 47, 47, 97, 99, 97, 100, 101, 109, 105, 99, 116, 111, 114, 114, 101, 110, 116, 115, + 46, 99, 111, 109, 47, 97, 110, 110, 111, 117, 110, 99, 101, 46, 112, 104, 112, 101, 108, 52, 54, 58, 104, 116, 116, + 112, 115, 58, 47, 47, 105, 112, 118, 54, 46, 97, 99, 97, 100, 101, 109, 105, 99, 116, 111, 114, 114, 101, 110, 116, + 115, 46, 99, 111, 109, 47, 97, 110, 110, 111, 117, 110, 99, 101, 46, 112, 104, 112, 101, 108, 52, 50, 58, 117, 100, + 112, 58, 47, 47, 116, 114, 97, 99, 107, 101, 114, 46, 111, 112, 101, 110, 116, 114, 97, 99, 107, 114, 46, 111, 114, + 103, 58, 49, 51, 51, 55, 47, 97, 110, 110, 111, 117, 110, 99, 101, 101, 108, 52, 52, 58, 117, 100, 112, 58, 47, 47, + 116, 114, 97, 99, 107, 101, 114, 46, 111, 112, 101, 110, 98, 105, 116, 116, 111, 114, 114, 101, 110, 116, 46, 99, + 111, 109, 58, 56, 48, 47, 97, 110, 110, 111, 117, 110, 99, 101, 101, 108, 51, 54, 58, 104, 116, 116, 112, 58, 47, 47, + 98, 116, 49, 46, 97, 114, 99, 104, 105, 118, 101, 46, 111, 114, 103, 58, 54, 57, 54, 57, 47, 97, 110, 110, 111, 117, + 110, 99, 101, 101, 108, 51, 54, 58, 104, 116, 116, 112, 58, 47, 47, 98, 116, 50, 46, 97, 114, 99, 104, 105, 118, 101, + 46, 111, 114, 103, 58, 54, 57, 54, 57, 47, 97, 110, 110, 111, 117, 110, 99, 101, 101, 101, 55, 58, 99, 111, 109, 109, + 101, 110, 116, 54, 51, 52, 58, 84, 104, 105, 115, 32, 99, 111, 110, 116, 101, 110, 116, 32, 104, 111, 115, 116, 101, + 100, 32, 97, 116, 32, 116, 104, 101, 32, 73, 110, 116, 101, 114, 110, 101, 116, 32, 65, 114, 99, 104, 105, 118, 101, + 32, 97, 116, 32, 104, 116, 116, 112, 115, 58, 47, 47, 97, 114, 99, 104, 105, 118, 101, 46, 111, 114, 103, 47, 100, + 101, 116, 97, 105, 108, 115, 47, 114, 97, 112, 112, 112, 105, 100, 45, 119, 101, 105, 103, 104, 116, 115, 46, 116, + 97, 114, 10, 70, 105, 108, 101, 115, 32, 109, 97, 121, 32, 104, 97, 118, 101, 32, 99, 104, 97, 110, 103, 101, 100, + 44, 32, 119, 104, 105, 99, 104, 32, 112, 114, 101, 118, 101, 110, 116, 115, 32, 116, 111, 114, 114, 101, 110, 116, + 115, 32, 102, 114, 111, 109, 32, 100, 111, 119, 110, 108, 111, 97, 100, 105, 110, 103, 32, 99, 111, 114, 114, 101, + 99, 116, 108, 121, 32, 111, 114, 32, 99, 111, 109, 112, 108, 101, 116, 101, 108, 121, 59, 32, 112, 108, 101, 97, 115, + 101, 32, 99, 104, 101, 99, 107, 32, 102, 111, 114, 32, 97, 110, 32, 117, 112, 100, 97, 116, 101, 100, 32, 116, 111, + 114, 114, 101, 110, 116, 32, 97, 116, 32, 104, 116, 116, 112, 115, 58, 47, 47, 97, 114, 99, 104, 105, 118, 101, 46, + 111, 114, 103, 47, 100, 111, 119, 110, 108, 111, 97, 100, 47, 114, 97, 112, 112, 112, 105, 100, 45, 119, 101, 105, + 103, 104, 116, 115, 46, 116, 97, 114, 47, 114, 97, 112, 112, 112, 105, 100, 45, 119, 101, 105, 103, 104, 116, 115, + 46, 116, 97, 114, 95, 97, 114, 99, 104, 105, 118, 101, 46, 116, 111, 114, 114, 101, 110, 116, 10, 78, 111, 116, 101, + 58, 32, 114, 101, 116, 114, 105, 101, 118, 97, 108, 32, 117, 115, 117, 97, 108, 108, 121, 32, 114, 101, 113, 117, + 105, 114, 101, 115, 32, 97, 32, 99, 108, 105, 101, 110, 116, 32, 116, 104, 97, 116, 32, 115, 117, 112, 112, 111, 114, + 116, 115, 32, 119, 101, 98, 115, 101, 101, 100, 105, 110, 103, 32, 40, 71, 101, 116, 82, 105, 103, 104, 116, 32, 115, + 116, 121, 108, 101, 41, 46, 10, 78, 111, 116, 101, 58, 32, 109, 97, 110, 121, 32, 73, 110, 116, 101, 114, 110, 101, + 116, 32, 65, 114, 99, 104, 105, 118, 101, 32, 116, 111, 114, 114, 101, 110, 116, 115, 32, 99, 111, 110, 116, 97, 105, + 110, 32, 97, 32, 39, 112, 97, 100, 32, 102, 105, 108, 101, 39, 32, 100, 105, 114, 101, 99, 116, 111, 114, 121, 46, + 32, 84, 104, 105, 115, 32, 100, 105, 114, 101, 99, 116, 111, 114, 121, 32, 97, 110, 100, 32, 116, 104, 101, 32, 102, + 105, 108, 101, 115, 32, 119, 105, 116, 104, 105, 110, 32, 105, 116, 32, 109, 97, 121, 32, 98, 101, 32, 101, 114, 97, + 115, 101, 100, 32, 111, 110, 99, 101, 32, 114, 101, 116, 114, 105, 101, 118, 97, 108, 32, 99, 111, 109, 112, 108, + 101, 116, 101, 115, 46, 10, 78, 111, 116, 101, 58, 32, 116, 104, 101, 32, 102, 105, 108, 101, 32, 114, 97, 112, 112, + 112, 105, 100, 45, 119, 101, 105, 103, 104, 116, 115, 46, 116, 97, 114, 95, 109, 101, 116, 97, 46, 120, 109, 108, 32, + 99, 111, 110, 116, 97, 105, 110, 115, 32, 109, 101, 116, 97, 100, 97, 116, 97, 32, 97, 98, 111, 117, 116, 32, 116, + 104, 105, 115, 32, 116, 111, 114, 114, 101, 110, 116, 39, 115, 32, 99, 111, 110, 116, 101, 110, 116, 115, 46, 49, 48, + 58, 99, 114, 101, 97, 116, 101, 100, 32, 98, 121, 49, 53, 58, 105, 97, 95, 109, 97, 107, 101, 95, 116, 111, 114, 114, + 101, 110, 116, 49, 51, 58, 99, 114, 101, 97, 116, 105, 111, 110, 32, 100, 97, 116, 101, 105, 49, 54, 56, 57, 50, 55, + 51, 55, 56, 55, 101, 52, 58, 105, 110, 102, 111, 100, 49, 49, 58, 99, 111, 108, 108, 101, 99, 116, 105, 111, 110, + 115, 108, 51, 49, 58, 111, 114, 103, 46, 97, 114, 99, 104, 105, 118, 101, 46, 114, 97, 112, 112, 112, 105, 100, 45, + 119, 101, 105, 103, 104, 116, 115, 46, 116, 97, 114, 101, 53, 58, 102, 105, 108, 101, 115, 108, 100, 53, 58, 99, 114, + 99, 51, 50, 56, 58, 53, 55, 100, 51, 51, 102, 99, 99, 54, 58, 108, 101, 110, 103, 116, 104, 105, 49, 49, 53, 50, 56, + 51, 50, 52, 101, 51, 58, 109, 100, 53, 51, 50, 58, 101, 57, 49, 98, 98, 52, 98, 97, 56, 50, 54, 57, 53, 49, 54, 49, + 98, 101, 54, 56, 102, 56, 98, 51, 51, 97, 101, 55, 54, 49, 52, 50, 53, 58, 109, 116, 105, 109, 101, 49, 48, 58, 49, + 54, 56, 57, 50, 55, 51, 55, 51, 48, 52, 58, 112, 97, 116, 104, 108, 50, 50, 58, 82, 65, 80, 80, 80, 73, 68, 32, 87, + 101, 105, 103, 104, 116, 115, 46, 116, 97, 114, 46, 103, 122, 101, 52, 58, 115, 104, 97, 49, 52, 48, 58, 52, 53, 57, + 55, 48, 101, 102, 51, 51, 99, 98, 51, 48, 52, 57, 97, 55, 97, 56, 54, 50, 57, 101, 52, 48, 99, 56, 102, 53, 101, 53, + 50, 54, 56, 100, 49, 100, 99, 53, 51, 101, 100, 53, 58, 99, 114, 99, 51, 50, 56, 58, 99, 54, 53, 56, 102, 100, 52, + 102, 54, 58, 108, 101, 110, 103, 116, 104, 105, 50, 48, 52, 56, 48, 101, 51, 58, 109, 100, 53, 51, 50, 58, 97, 55, + 56, 50, 98, 50, 97, 53, 51, 98, 97, 52, 57, 102, 48, 100, 52, 53, 102, 51, 100, 100, 54, 101, 51, 53, 101, 48, 100, + 53, 57, 51, 53, 58, 109, 116, 105, 109, 101, 49, 48, 58, 49, 54, 56, 57, 50, 55, 51, 55, 56, 51, 52, 58, 112, 97, + 116, 104, 108, 51, 49, 58, 114, 97, 112, 112, 112, 105, 100, 45, 119, 101, 105, 103, 104, 116, 115, 46, 116, 97, 114, + 95, 109, 101, 116, 97, 46, 115, 113, 108, 105, 116, 101, 101, 52, 58, 115, 104, 97, 49, 52, 48, 58, 98, 99, 98, 48, + 54, 98, 51, 49, 54, 52, 102, 49, 100, 50, 97, 98, 97, 50, 50, 101, 102, 54, 48, 52, 54, 101, 98, 56, 48, 102, 54, 53, + 50, 54, 52, 101, 57, 102, 98, 97, 101, 100, 53, 58, 99, 114, 99, 51, 50, 56, 58, 56, 49, 52, 48, 97, 53, 99, 55, 54, + 58, 108, 101, 110, 103, 116, 104, 105, 49, 48, 52, 52, 101, 51, 58, 109, 100, 53, 51, 50, 58, 49, 98, 97, 98, 50, 49, + 101, 53, 48, 101, 48, 54, 97, 98, 52, 50, 100, 51, 97, 55, 55, 100, 56, 55, 50, 98, 102, 50, 53, 50, 101, 53, 53, 58, + 109, 116, 105, 109, 101, 49, 48, 58, 49, 54, 56, 57, 50, 55, 51, 55, 54, 51, 52, 58, 112, 97, 116, 104, 108, 50, 56, + 58, 114, 97, 112, 112, 112, 105, 100, 45, 119, 101, 105, 103, 104, 116, 115, 46, 116, 97, 114, 95, 109, 101, 116, 97, + 46, 120, 109, 108, 101, 52, 58, 115, 104, 97, 49, 52, 48, 58, 98, 50, 102, 48, 102, 50, 98, 98, 101, 99, 51, 52, 97, + 97, 57, 49, 52, 48, 102, 98, 57, 97, 99, 51, 102, 99, 98, 49, 57, 48, 53, 56, 56, 97, 52, 57, 54, 97, 97, 51, 101, + 101, 52, 58, 110, 97, 109, 101, 49, 57, 58, 114, 97, 112, 112, 112, 105, 100, 45, 119, 101, 105, 103, 104, 116, 115, + 46, 116, 97, 114, 49, 50, 58, 112, 105, 101, 99, 101, 32, 108, 101, 110, 103, 116, 104, 105, 53, 50, 52, 50, 56, 56, + 101, 54, 58, 112, 105, 101, 99, 101, 115, 52, 54, 48, 58, 171, 236, 85, 110, 15, 123, 231, 211, 48, 12, 246, 104, + 140, 144, 109, 153, 12, 62, 50, 181, 44, 242, 182, 124, 12, 50, 82, 188, 114, 111, 7, 30, 115, 171, 118, 241, 188, + 50, 43, 252, 33, 212, 127, 26, 233, 114, 53, 64, 126, 195, 180, 137, 9, 43, 237, 75, 216, 176, 108, 101, 140, 39, 88, + 174, 251, 114, 117, 115, 68, 55, 136, 40, 32, 210, 148, 189, 164, 106, 248, 210, 166, 253, 2, 101, 28, 28, 223, 184, + 86, 109, 58, 210, 126, 167, 61, 202, 226, 73, 247, 54, 141, 23, 119, 110, 50, 173, 239, 165, 68, 194, 143, 182, 156, + 36, 86, 173, 232, 251, 123, 166, 113, 192, 129, 229, 67, 3, 145, 212, 79, 176, 166, 100, 202, 41, 27, 13, 29, 64, + 125, 57, 78, 118, 150, 235, 1, 24, 243, 245, 80, 142, 47, 250, 84, 252, 73, 102, 133, 216, 56, 135, 120, 155, 10, + 143, 122, 163, 44, 143, 114, 54, 173, 109, 116, 11, 252, 197, 87, 113, 134, 251, 243, 207, 202, 201, 218, 236, 97, + 98, 162, 42, 27, 167, 133, 137, 145, 143, 170, 192, 192, 203, 13, 87, 216, 183, 231, 100, 77, 242, 132, 115, 118, + 152, 251, 58, 23, 72, 215, 156, 1, 254, 202, 109, 31, 197, 151, 52, 5, 84, 57, 218, 194, 110, 23, 65, 17, 105, 243, + 70, 209, 125, 22, 211, 192, 135, 59, 195, 178, 12, 29, 224, 226, 73, 195, 5, 210, 76, 0, 90, 91, 120, 1, 18, 62, 191, + 82, 67, 73, 109, 26, 238, 35, 121, 210, 14, 40, 182, 132, 126, 197, 237, 121, 222, 100, 2, 237, 71, 113, 61, 147, 22, + 196, 162, 118, 24, 119, 84, 197, 49, 72, 150, 58, 81, 193, 74, 146, 144, 145, 243, 207, 72, 91, 36, 134, 85, 168, + 235, 12, 198, 45, 134, 226, 41, 86, 9, 44, 56, 11, 205, 193, 202, 69, 230, 100, 106, 71, 254, 187, 46, 71, 154, 119, + 69, 41, 233, 114, 25, 32, 111, 66, 121, 43, 55, 185, 83, 37, 237, 15, 41, 4, 213, 226, 150, 241, 222, 207, 153, 190, + 50, 170, 184, 10, 29, 11, 159, 185, 214, 171, 92, 80, 67, 120, 133, 65, 9, 1, 36, 207, 224, 137, 118, 91, 77, 169, + 202, 114, 192, 223, 146, 71, 15, 13, 206, 202, 150, 198, 126, 165, 65, 95, 43, 167, 187, 4, 204, 247, 68, 127, 148, + 30, 36, 210, 27, 23, 202, 24, 121, 144, 163, 214, 32, 117, 162, 150, 104, 6, 88, 90, 222, 245, 44, 26, 144, 34, 114, + 51, 142, 213, 178, 168, 250, 229, 233, 231, 105, 98, 2, 124, 9, 179, 76, 101, 54, 58, 108, 111, 99, 97, 108, 101, 50, + 58, 101, 110, 53, 58, 116, 105, 116, 108, 101, 49, 57, 58, 114, 97, 112, 112, 112, 105, 100, 45, 119, 101, 105, 103, + 104, 116, 115, 46, 116, 97, 114, 56, 58, 117, 114, 108, 45, 108, 105, 115, 116, 108, 50, 57, 58, 104, 116, 116, 112, + 115, 58, 47, 47, 97, 114, 99, 104, 105, 118, 101, 46, 111, 114, 103, 47, 100, 111, 119, 110, 108, 111, 97, 100, 47, + 52, 48, 58, 104, 116, 116, 112, 58, 47, 47, 105, 97, 57, 48, 50, 55, 48, 50, 46, 117, 115, 46, 97, 114, 99, 104, 105, + 118, 101, 46, 111, 114, 103, 47, 50, 50, 47, 105, 116, 101, 109, 115, 47, 52, 48, 58, 104, 116, 116, 112, 58, 47, 47, + 105, 97, 56, 48, 50, 55, 48, 50, 46, 117, 115, 46, 97, 114, 99, 104, 105, 118, 101, 46, 111, 114, 103, 47, 50, 50, + 47, 105, 116, 101, 109, 115, 47, 101, 101, + ] + .to_vec() + } + #[test] fn it_should_calculate_the_original_info_hash_using_all_fields_in_the_info_key_dictionary() { - let torrent_path = Path::new( - // cspell:disable-next-line - "tests/fixtures/torrents/6c690018c5786dbbb00161f62b0712d69296df97_with_custom_info_dict_key.torrent", - ); - - let original_info_hash = super::calculate_info_hash(&std::fs::read(torrent_path).unwrap()).unwrap(); + let original_info_hash = super::calculate_info_hash(&torrent_with_custom_info_dict_key()).unwrap(); assert_eq!( original_info_hash, - InfoHash::from_str("6c690018c5786dbbb00161f62b0712d69296df97").unwrap() + InfoHash::from_str("6c690018c5786dbbb00161f62b0712d69296df97").unwrap() // DevSkim: ignore DS173237 ); } #[test] fn it_should_calculate_the_new_info_hash_ignoring_non_standard_fields_in_the_info_key_dictionary() { - let torrent_path = Path::new( - // cspell:disable-next-line - "tests/fixtures/torrents/6c690018c5786dbbb00161f62b0712d69296df97_with_custom_info_dict_key.torrent", - ); - - let torrent = super::decode_torrent(&std::fs::read(torrent_path).unwrap()).unwrap(); + let torrent = super::decode_torrent(&torrent_with_custom_info_dict_key()).unwrap(); // The infohash is not the original infohash of the torrent file, // but the infohash of the info dictionary without the custom keys. assert_eq!( torrent.canonical_info_hash_hex(), - "8aa01a4c816332045ffec83247ccbc654547fedf".to_string() + "8aa01a4c816332045ffec83247ccbc654547fedf".to_string() // DevSkim: ignore DS173237 ); } } diff --git a/src/web/api/client/mod.rs b/src/web/api/client/mod.rs new file mode 100644 index 00000000..a3a6d96c --- /dev/null +++ b/src/web/api/client/mod.rs @@ -0,0 +1 @@ +pub mod v1; diff --git a/src/web/api/client/v1/client.rs b/src/web/api/client/v1/client.rs new file mode 100644 index 00000000..99069a19 --- /dev/null +++ b/src/web/api/client/v1/client.rs @@ -0,0 +1,282 @@ +use reqwest::{multipart, Url}; + +use super::connection_info::ConnectionInfo; +use super::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; +use super::contexts::tag::forms::{AddTagForm, DeleteTagForm}; +use super::contexts::torrent::forms::UpdateTorrentForm; +use super::contexts::torrent::requests::InfoHash; +use super::contexts::user::forms::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; +use super::http::{Http, Query}; +use super::responses::{self, TextResponse}; + +#[derive(Debug)] +pub enum Error { + HttpError(reqwest::Error), +} + +impl From for Error { + fn from(err: reqwest::Error) -> Self { + Error::HttpError(err) + } +} + +/// API Client +pub struct Client { + http_client: Http, +} + +impl Client { + fn base_path() -> String { + "/v1".to_string() + } + + #[must_use] + pub fn unauthenticated(base_url: &Url) -> Self { + Self::new(ConnectionInfo::anonymous(base_url, &Self::base_path())) + } + + #[must_use] + pub fn authenticated(base_url: &Url, token: &str) -> Self { + Self::new(ConnectionInfo::new(base_url, &Self::base_path(), token)) + } + + #[must_use] + pub fn new(connection_info: ConnectionInfo) -> Self { + Self { + http_client: Http::new(connection_info), + } + } + + /// It checks if the server is running. + pub async fn server_is_running(&self) -> bool { + let response = self.http_client.inner_get("").await; + response.is_ok() + } + + // Context: about + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn about(&self) -> Result { + self.http_client.get("/about", Query::empty()).await.map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn license(&self) -> Result { + self.http_client + .get("/about/license", Query::empty()) + .await + .map_err(Error::from) + } + + // Context: category + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_categories(&self) -> Result { + self.http_client.get("/category", Query::empty()).await.map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn add_category(&self, add_category_form: AddCategoryForm) -> Result { + self.http_client + .post("/category", &add_category_form) + .await + .map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn delete_category(&self, delete_category_form: DeleteCategoryForm) -> Result { + self.http_client + .delete_with_body("/category", &delete_category_form) + .await + .map_err(Error::from) + } + + // Context: tag + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_tags(&self) -> Result { + self.http_client.get("/tags", Query::empty()).await.map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn add_tag(&self, add_tag_form: AddTagForm) -> Result { + self.http_client.post("/tag", &add_tag_form).await.map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn delete_tag(&self, delete_tag_form: DeleteTagForm) -> Result { + self.http_client + .delete_with_body("/tag", &delete_tag_form) + .await + .map_err(Error::from) + } + + // Context: root + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn root(&self) -> Result { + self.http_client.get("", Query::empty()).await.map_err(Error::from) + } + + // Context: settings + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_public_settings(&self) -> Result { + self.http_client + .get("/settings/public", Query::empty()) + .await + .map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_site_name(&self) -> Result { + self.http_client + .get("/settings/name", Query::empty()) + .await + .map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_settings(&self) -> Result { + self.http_client.get("/settings", Query::empty()).await.map_err(Error::from) + } + + // Context: torrent + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_torrents(&self, params: Query) -> Result { + self.http_client.get("/torrents", params).await.map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_torrent(&self, info_hash: &InfoHash) -> Result { + self.http_client + .get(&format!("/torrent/{info_hash}"), Query::empty()) + .await + .map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn delete_torrent(&self, info_hash: &InfoHash) -> Result { + self.http_client + .delete(&format!("/torrent/{info_hash}")) + .await + .map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn update_torrent( + &self, + info_hash: &InfoHash, + update_torrent_form: UpdateTorrentForm, + ) -> Result { + self.http_client + .put(&format!("/torrent/{info_hash}"), &update_torrent_form) + .await + .map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn upload_torrent(&self, form: multipart::Form) -> Result { + self.http_client + .post_multipart("/torrent/upload", form) + .await + .map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn download_torrent(&self, info_hash: &InfoHash) -> Result { + self.http_client + .get_binary(&format!("/torrent/download/{info_hash}"), Query::empty()) + .await + .map_err(Error::from) + } + + // Context: user + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn register_user(&self, registration_form: RegistrationForm) -> Result { + self.http_client + .post("/user/register", ®istration_form) + .await + .map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn login_user(&self, registration_form: LoginForm) -> Result { + self.http_client + .post("/user/login", ®istration_form) + .await + .map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> Result { + self.http_client + .post("/user/token/verify", &token_verification_form) + .await + .map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn renew_token(&self, token_verification_form: TokenRenewalForm) -> Result { + self.http_client + .post("/user/token/renew", &token_verification_form) + .await + .map_err(Error::from) + } + + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn ban_user(&self, username: Username) -> Result { + self.http_client + .delete(&format!("/user/ban/{}", &username.value)) + .await + .map_err(Error::from) + } +} diff --git a/src/web/api/client/v1/connection_info.rs b/src/web/api/client/v1/connection_info.rs new file mode 100644 index 00000000..8c186732 --- /dev/null +++ b/src/web/api/client/v1/connection_info.rs @@ -0,0 +1,73 @@ +use std::fmt; +use std::str::FromStr; + +use reqwest::Url; + +#[derive(Clone)] +pub enum Scheme { + Http, + Https, +} + +impl fmt::Display for Scheme { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Scheme::Http => write!(f, "http"), + Scheme::Https => write!(f, "https"), + } + } +} + +impl FromStr for Scheme { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "http" => Ok(Scheme::Http), + "https" => Ok(Scheme::Https), + _ => Err(()), + } + } +} + +#[derive(Clone)] +pub struct ConnectionInfo { + pub scheme: Scheme, + pub bind_address: String, + pub base_path: String, + pub token: Option, +} + +impl ConnectionInfo { + /// # Panics + /// + /// Will panic if the the base URL does not have a valid scheme: `http` or `https`. + #[must_use] + pub fn new(base_url: &Url, base_path: &str, token: &str) -> Self { + Self { + scheme: base_url + .scheme() + .parse() + .expect("base API URL scheme should be 'http' or 'https"), + bind_address: base_url.authority().to_string(), + base_path: base_path.to_string(), + token: Some(token.to_string()), + } + } + + /// # Panics + /// + /// Will panic if the the base URL does not have a valid scheme: `http` or `https`. + #[must_use] + pub fn anonymous(base_url: &Url, base_path: &str) -> Self { + Self { + scheme: base_url + .scheme() + .parse() + .expect("base API URL scheme should be 'http' or 'https"), + bind_address: base_url.authority().to_string(), + base_path: base_path.to_string(), + token: None, + } + } +} diff --git a/src/web/api/client/v1/contexts/category/forms.rs b/src/web/api/client/v1/contexts/category/forms.rs new file mode 100644 index 00000000..ea9cf429 --- /dev/null +++ b/src/web/api/client/v1/contexts/category/forms.rs @@ -0,0 +1,9 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct AddCategoryForm { + pub name: String, + pub icon: Option, +} + +pub type DeleteCategoryForm = AddCategoryForm; diff --git a/src/web/api/client/v1/contexts/category/mod.rs b/src/web/api/client/v1/contexts/category/mod.rs new file mode 100644 index 00000000..ea737db8 --- /dev/null +++ b/src/web/api/client/v1/contexts/category/mod.rs @@ -0,0 +1,2 @@ +pub mod forms; +pub mod responses; diff --git a/src/web/api/client/v1/contexts/category/responses.rs b/src/web/api/client/v1/contexts/category/responses.rs new file mode 100644 index 00000000..cbadb631 --- /dev/null +++ b/src/web/api/client/v1/contexts/category/responses.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct AddedCategoryResponse { + pub data: String, +} + +#[derive(Deserialize)] +pub struct DeletedCategoryResponse { + pub data: String, +} + +#[derive(Deserialize, Debug)] +pub struct ListResponse { + pub data: Vec, +} + +#[derive(Deserialize, Debug, PartialEq)] +pub struct ListItem { + pub category_id: i64, + pub name: String, + pub num_torrents: i64, +} diff --git a/src/web/api/client/v1/contexts/mod.rs b/src/web/api/client/v1/contexts/mod.rs new file mode 100644 index 00000000..44f74414 --- /dev/null +++ b/src/web/api/client/v1/contexts/mod.rs @@ -0,0 +1,5 @@ +pub mod category; +pub mod settings; +pub mod tag; +pub mod torrent; +pub mod user; diff --git a/src/web/api/client/v1/contexts/settings/mod.rs b/src/web/api/client/v1/contexts/settings/mod.rs new file mode 100644 index 00000000..55260a7c --- /dev/null +++ b/src/web/api/client/v1/contexts/settings/mod.rs @@ -0,0 +1,235 @@ +pub mod responses; + +use std::net::SocketAddr; + +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::config::v2::tracker::ApiToken; +use crate::config::{ + Api as DomainApi, Auth as DomainAuth, Credentials as DomainCredentials, Database as DomainDatabase, + ImageCache as DomainImageCache, Mail as DomainMail, Network as DomainNetwork, + PasswordConstraints as DomainPasswordConstraints, Settings as DomainSettings, Smtp as DomainSmtp, Tracker as DomainTracker, + TrackerStatisticsImporter as DomainTrackerStatisticsImporter, Website as DomainWebsite, +}; + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Settings { + pub website: Website, + pub tracker: Tracker, + pub net: Network, + pub auth: Auth, + pub database: Database, + pub mail: Mail, + pub image_cache: ImageCache, + pub api: Api, + pub tracker_statistics_importer: TrackerStatisticsImporter, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Website { + pub name: String, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Tracker { + pub url: Url, + pub listed: bool, + pub private: bool, + pub api_url: Url, + pub token: ApiToken, + pub token_valid_seconds: u64, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Network { + pub base_url: Option, + pub bind_address: SocketAddr, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Auth { + pub user_claim_token_pepper: String, + pub password_constraints: PasswordConstraints, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct PasswordConstraints { + pub min_password_length: usize, + pub max_password_length: usize, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Database { + pub connect_url: String, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Mail { + pub from: String, + pub reply_to: String, + pub smtp: Smtp, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Smtp { + pub server: String, + pub port: u16, + pub credentials: Credentials, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Credentials { + pub username: String, + pub password: String, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct ImageCache { + pub max_request_timeout_ms: u64, + pub capacity: usize, + pub entry_size_limit: usize, + pub user_quota_period_seconds: u64, + pub user_quota_bytes: usize, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Api { + pub default_torrent_page_size: u8, + pub max_torrent_page_size: u8, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct TrackerStatisticsImporter { + pub torrent_info_update_interval: u64, + port: u16, +} + +impl From for Settings { + fn from(settings: DomainSettings) -> Self { + Settings { + website: Website::from(settings.website), + tracker: Tracker::from(settings.tracker), + net: Network::from(settings.net), + auth: Auth::from(settings.auth), + database: Database::from(settings.database), + mail: Mail::from(settings.mail), + image_cache: ImageCache::from(settings.image_cache), + api: Api::from(settings.api), + tracker_statistics_importer: TrackerStatisticsImporter::from(settings.tracker_statistics_importer), + } + } +} + +impl From for Website { + fn from(website: DomainWebsite) -> Self { + Self { name: website.name } + } +} + +impl From for Tracker { + fn from(tracker: DomainTracker) -> Self { + Self { + url: tracker.url, + listed: tracker.listed, + private: tracker.private, + api_url: tracker.api_url, + token: tracker.token, + token_valid_seconds: tracker.token_valid_seconds, + } + } +} + +impl From for Network { + fn from(net: DomainNetwork) -> Self { + Self { + base_url: net.base_url.map(|url_without_port| url_without_port.to_string()), + bind_address: net.bind_address, + } + } +} + +impl From for Auth { + fn from(auth: DomainAuth) -> Self { + Self { + user_claim_token_pepper: auth.user_claim_token_pepper.to_string(), + password_constraints: auth.password_constraints.into(), + } + } +} + +impl From for PasswordConstraints { + fn from(password_constraints: DomainPasswordConstraints) -> Self { + Self { + min_password_length: password_constraints.min_password_length, + max_password_length: password_constraints.max_password_length, + } + } +} + +impl From for Database { + fn from(database: DomainDatabase) -> Self { + Self { + connect_url: database.connect_url.to_string(), + } + } +} + +impl From for Mail { + fn from(mail: DomainMail) -> Self { + Self { + from: mail.from.to_string(), + reply_to: mail.reply_to.to_string(), + smtp: Smtp::from(mail.smtp), + } + } +} + +impl From for Smtp { + fn from(smtp: DomainSmtp) -> Self { + Self { + server: smtp.server, + port: smtp.port, + credentials: Credentials::from(smtp.credentials), + } + } +} + +impl From for Credentials { + fn from(credentials: DomainCredentials) -> Self { + Self { + username: credentials.username, + password: credentials.password, + } + } +} + +impl From for ImageCache { + fn from(image_cache: DomainImageCache) -> Self { + Self { + max_request_timeout_ms: image_cache.max_request_timeout_ms, + capacity: image_cache.capacity, + entry_size_limit: image_cache.entry_size_limit, + user_quota_period_seconds: image_cache.user_quota_period_seconds, + user_quota_bytes: image_cache.user_quota_bytes, + } + } +} + +impl From for Api { + fn from(api: DomainApi) -> Self { + Self { + default_torrent_page_size: api.default_torrent_page_size, + max_torrent_page_size: api.max_torrent_page_size, + } + } +} + +impl From for TrackerStatisticsImporter { + fn from(tracker_statistics_importer: DomainTrackerStatisticsImporter) -> Self { + Self { + torrent_info_update_interval: tracker_statistics_importer.torrent_info_update_interval, + port: tracker_statistics_importer.port, + } + } +} diff --git a/src/web/api/client/v1/contexts/settings/responses.rs b/src/web/api/client/v1/contexts/settings/responses.rs new file mode 100644 index 00000000..096ef1f4 --- /dev/null +++ b/src/web/api/client/v1/contexts/settings/responses.rs @@ -0,0 +1,26 @@ +use serde::Deserialize; + +use super::Settings; + +#[derive(Deserialize)] +pub struct AllSettingsResponse { + pub data: Settings, +} + +#[derive(Deserialize)] +pub struct PublicSettingsResponse { + pub data: Public, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct Public { + pub website_name: String, + pub tracker_url: String, + pub tracker_mode: String, + pub email_on_signup: String, +} + +#[derive(Deserialize)] +pub struct SiteNameResponse { + pub data: String, +} diff --git a/src/web/api/client/v1/contexts/tag/forms.rs b/src/web/api/client/v1/contexts/tag/forms.rs new file mode 100644 index 00000000..26d1395d --- /dev/null +++ b/src/web/api/client/v1/contexts/tag/forms.rs @@ -0,0 +1,11 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct AddTagForm { + pub name: String, +} + +#[derive(Serialize)] +pub struct DeleteTagForm { + pub tag_id: i64, +} diff --git a/src/web/api/client/v1/contexts/tag/mod.rs b/src/web/api/client/v1/contexts/tag/mod.rs new file mode 100644 index 00000000..ea737db8 --- /dev/null +++ b/src/web/api/client/v1/contexts/tag/mod.rs @@ -0,0 +1,2 @@ +pub mod forms; +pub mod responses; diff --git a/src/web/api/client/v1/contexts/tag/responses.rs b/src/web/api/client/v1/contexts/tag/responses.rs new file mode 100644 index 00000000..a08cdf55 --- /dev/null +++ b/src/web/api/client/v1/contexts/tag/responses.rs @@ -0,0 +1,46 @@ +use serde::Deserialize; + +// code-review: we should always include a API resource in the `data`attribute. +// +// ``` +// pub struct DeletedTagResponse { +// pub data: DeletedTag, +// } +// +// pub struct DeletedTag { +// pub tag_id: i64, +// } +// ``` +// +// This way the API client knows what's the meaning of the `data` attribute. + +#[derive(Deserialize)] +pub struct AddedTagResponse { + pub data: String, +} + +#[derive(Deserialize)] +pub struct DeletedTagResponse { + pub data: i64, +} + +#[derive(Deserialize, Debug)] +pub struct ListResponse { + pub data: Vec, +} + +impl ListResponse { + /// # Panics + /// + /// Will panic if it can't fin the tag in the response. + #[must_use] + pub fn find_tag_id(&self, tag_name: &str) -> i64 { + self.data.iter().find(|tag| tag.name == tag_name).unwrap().tag_id + } +} + +#[derive(Deserialize, Debug, PartialEq)] +pub struct ListItem { + pub tag_id: i64, + pub name: String, +} diff --git a/src/web/api/client/v1/contexts/torrent/forms.rs b/src/web/api/client/v1/contexts/torrent/forms.rs new file mode 100644 index 00000000..64a0360d --- /dev/null +++ b/src/web/api/client/v1/contexts/torrent/forms.rs @@ -0,0 +1,66 @@ +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +pub struct UpdateTorrentForm { + pub title: Option, + pub description: Option, + pub category: Option, + pub tags: Option>, +} + +use reqwest::multipart::Form; + +pub struct UploadTorrentMultipartForm { + pub title: String, + pub description: String, + pub category: String, + pub torrent_file: BinaryFile, +} + +#[derive(Clone)] +pub struct BinaryFile { + pub name: String, + pub contents: Vec, +} + +impl BinaryFile { + /// # Panics + /// + /// Will panic if: + /// + /// - The path is not a file. + /// - The path can't be converted into string. + /// - The file can't be read. + #[must_use] + pub fn from_file_at_path(path: &Path) -> Self { + BinaryFile { + name: path.file_name().unwrap().to_owned().into_string().unwrap(), + contents: fs::read(path).unwrap(), + } + } + + /// Build the binary file directly from the binary data provided. + #[must_use] + pub fn from_bytes(name: String, contents: Vec) -> Self { + BinaryFile { name, contents } + } +} + +impl From for Form { + fn from(form: UploadTorrentMultipartForm) -> Self { + Form::new() + .text("title", form.title) + .text("description", form.description) + .text("category", form.category) + .part( + "torrent", + reqwest::multipart::Part::bytes(form.torrent_file.contents) + .file_name(form.torrent_file.name) + .mime_str("application/x-bittorrent") + .unwrap(), + ) + } +} diff --git a/src/web/api/client/v1/contexts/torrent/mod.rs b/src/web/api/client/v1/contexts/torrent/mod.rs new file mode 100644 index 00000000..a3bb0936 --- /dev/null +++ b/src/web/api/client/v1/contexts/torrent/mod.rs @@ -0,0 +1,3 @@ +pub mod forms; +pub mod requests; +pub mod responses; diff --git a/src/web/api/client/v1/contexts/torrent/requests.rs b/src/web/api/client/v1/contexts/torrent/requests.rs new file mode 100644 index 00000000..1d4ac583 --- /dev/null +++ b/src/web/api/client/v1/contexts/torrent/requests.rs @@ -0,0 +1 @@ +pub type InfoHash = String; diff --git a/src/web/api/client/v1/contexts/torrent/responses.rs b/src/web/api/client/v1/contexts/torrent/responses.rs new file mode 100644 index 00000000..43704149 --- /dev/null +++ b/src/web/api/client/v1/contexts/torrent/responses.rs @@ -0,0 +1,124 @@ +use serde::{Deserialize, Serialize}; + +pub type Id = i64; +pub type CategoryId = i64; +pub type TagId = i64; +pub type UtcDateTime = String; // %Y-%m-%d %H:%M:%S + +#[derive(Deserialize, PartialEq, Debug)] +pub struct ErrorResponse { + pub error: String, +} + +#[derive(Deserialize)] +pub struct TorrentListResponse { + pub data: TorrentList, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct TorrentList { + pub total: u32, + pub results: Vec, +} + +impl TorrentList { + #[must_use] + pub fn _contains(&self, torrent_id: Id) -> bool { + self.results.iter().any(|item| item.torrent_id == torrent_id) + } +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct ListItem { + pub torrent_id: i64, + pub uploader: String, + pub info_hash: String, + pub title: String, + pub description: Option, + pub category_id: i64, + pub date_uploaded: String, + pub file_size: i64, + pub seeders: i64, + pub leechers: i64, + pub name: String, + pub comment: Option, + pub creation_date: Option, + pub created_by: Option, + pub encoding: Option, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct TorrentDetailsResponse { + pub data: TorrentDetails, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct TorrentDetails { + pub torrent_id: Id, + pub uploader: String, + pub info_hash: String, + pub title: String, + pub description: String, + pub category: Category, + pub upload_date: UtcDateTime, + pub file_size: u64, + pub seeders: u64, + pub leechers: u64, + pub files: Vec, + pub trackers: Vec, + pub magnet_link: String, + pub tags: Vec, + pub name: String, + pub comment: Option, + pub creation_date: Option, + pub created_by: Option, + pub encoding: Option, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct Category { + pub id: CategoryId, + pub name: String, + pub num_torrents: u64, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct Tag { + pub tag_id: TagId, + pub name: String, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct File { + pub path: Vec, + pub length: u64, + pub md5sum: Option, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct UploadedTorrentResponse { + pub data: UploadedTorrent, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct UploadedTorrent { + pub torrent_id: Id, + pub info_hash: String, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct DeletedTorrentResponse { + pub data: DeletedTorrent, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct DeletedTorrent { + pub torrent_id: Id, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct UpdatedTorrentResponse { + pub data: UpdatedTorrent, +} + +pub type UpdatedTorrent = TorrentDetails; diff --git a/src/web/api/client/v1/contexts/user/forms.rs b/src/web/api/client/v1/contexts/user/forms.rs new file mode 100644 index 00000000..18d08f9e --- /dev/null +++ b/src/web/api/client/v1/contexts/user/forms.rs @@ -0,0 +1,38 @@ +use serde::Serialize; + +#[derive(Clone, Serialize)] +pub struct RegistrationForm { + pub username: String, + pub email: Option, + pub password: String, + pub confirm_password: String, +} + +pub type RegisteredUser = RegistrationForm; + +#[derive(Serialize)] +pub struct LoginForm { + pub login: String, + pub password: String, +} + +#[derive(Serialize)] +pub struct TokenVerificationForm { + pub token: String, +} + +#[derive(Serialize)] +pub struct TokenRenewalForm { + pub token: String, +} + +pub struct Username { + pub value: String, +} + +impl Username { + #[must_use] + pub fn new(value: String) -> Self { + Self { value } + } +} diff --git a/src/web/api/client/v1/contexts/user/mod.rs b/src/web/api/client/v1/contexts/user/mod.rs new file mode 100644 index 00000000..ea737db8 --- /dev/null +++ b/src/web/api/client/v1/contexts/user/mod.rs @@ -0,0 +1,2 @@ +pub mod forms; +pub mod responses; diff --git a/src/web/api/client/v1/contexts/user/responses.rs b/src/web/api/client/v1/contexts/user/responses.rs new file mode 100644 index 00000000..1a9a3837 --- /dev/null +++ b/src/web/api/client/v1/contexts/user/responses.rs @@ -0,0 +1,45 @@ +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +pub struct AddedUserResponse { + pub data: NewUserData, +} + +#[derive(Deserialize, Debug)] +pub struct NewUserData { + pub user_id: i64, +} + +#[derive(Deserialize, Debug)] +pub struct SuccessfulLoginResponse { + pub data: LoggedInUserData, +} + +#[derive(Deserialize, Debug)] +pub struct LoggedInUserData { + pub token: String, + pub username: String, + pub admin: bool, +} + +#[derive(Deserialize)] +pub struct TokenVerifiedResponse { + pub data: String, +} + +#[derive(Deserialize)] +pub struct BannedUserResponse { + pub data: String, +} + +#[derive(Deserialize)] +pub struct TokenRenewalResponse { + pub data: TokenRenewalData, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct TokenRenewalData { + pub token: String, + pub username: String, + pub admin: bool, +} diff --git a/src/web/api/client/v1/http.rs b/src/web/api/client/v1/http.rs new file mode 100644 index 00000000..ca42d462 --- /dev/null +++ b/src/web/api/client/v1/http.rs @@ -0,0 +1,294 @@ +use std::time::Duration; + +use reqwest::{multipart, Error}; +use serde::Serialize; + +use super::connection_info::ConnectionInfo; +use super::responses::{BinaryResponse, TextResponse}; + +pub type ReqwestQuery = Vec; +pub type ReqwestQueryParam = (String, String); + +/// URL Query component +#[derive(Default, Debug)] +pub struct Query { + params: Vec, +} + +impl Query { + #[must_use] + pub fn empty() -> Self { + Self { params: vec![] } + } + + #[must_use] + pub fn with_params(params: Vec) -> Self { + Self { params } + } + + pub fn add_param(&mut self, param: QueryParam) { + self.params.push(param); + } +} + +impl From for ReqwestQuery { + fn from(url_search_params: Query) -> Self { + url_search_params + .params + .iter() + .map(|param| ReqwestQueryParam::from((*param).clone())) + .collect() + } +} + +/// URL query param +#[derive(Clone, Debug)] +pub struct QueryParam { + name: String, + value: String, +} + +impl QueryParam { + #[must_use] + pub fn new(name: &str, value: &str) -> Self { + Self { + name: name.to_string(), + value: value.to_string(), + } + } +} + +impl From for ReqwestQueryParam { + fn from(param: QueryParam) -> Self { + (param.name, param.value) + } +} + +/// Generic HTTP Client +pub struct Http { + connection_info: ConnectionInfo, + /// The timeout is applied from when the request starts connecting until the + /// response body has finished. + timeout: Duration, +} + +impl Http { + #[must_use] + pub fn new(connection_info: ConnectionInfo) -> Self { + Self { + connection_info, + timeout: Duration::from_secs(5), + } + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn get(&self, path: &str, params: Query) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .bearer_auth(token) + .send() + .await? + } + None => { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .send() + .await? + } + }; + + Ok(TextResponse::from(response).await) + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn get_binary(&self, path: &str, params: Query) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .bearer_auth(token) + .send() + .await? + } + None => { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .send() + .await? + } + }; + + // todo: If the response is a JSON, it returns the JSON body in a byte + // array. This is not the expected behavior. + // - Rename BinaryResponse to BinaryTorrentResponse + // - Return an error if the response is not a bittorrent file + Ok(BinaryResponse::from(response).await) + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn inner_get(&self, path: &str) -> Result { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .get(self.base_url(path).clone()) + .send() + .await + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn post(&self, path: &str, form: &T) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::new() + .post(self.base_url(path).clone()) + .bearer_auth(token) + .json(&form) + .send() + .await? + } + None => { + reqwest::Client::new() + .post(self.base_url(path).clone()) + .json(&form) + .send() + .await? + } + }; + + Ok(TextResponse::from(response).await) + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn post_multipart(&self, path: &str, form: multipart::Form) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .post(self.base_url(path).clone()) + .multipart(form) + .bearer_auth(token) + .send() + .await? + } + None => { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .post(self.base_url(path).clone()) + .multipart(form) + .send() + .await? + } + }; + + Ok(TextResponse::from(response).await) + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn put(&self, path: &str, form: &T) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::new() + .put(self.base_url(path).clone()) + .bearer_auth(token) + .json(&form) + .send() + .await? + } + None => { + reqwest::Client::new() + .put(self.base_url(path).clone()) + .json(&form) + .send() + .await? + } + }; + + Ok(TextResponse::from(response).await) + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn delete(&self, path: &str) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::new() + .delete(self.base_url(path).clone()) + .bearer_auth(token) + .send() + .await? + } + None => reqwest::Client::new().delete(self.base_url(path).clone()).send().await?, + }; + + Ok(TextResponse::from(response).await) + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn delete_with_body(&self, path: &str, form: &T) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::new() + .delete(self.base_url(path).clone()) + .bearer_auth(token) + .json(&form) + .send() + .await? + } + None => { + reqwest::Client::new() + .delete(self.base_url(path).clone()) + .json(&form) + .send() + .await? + } + }; + + Ok(TextResponse::from(response).await) + } + + fn base_url(&self, path: &str) -> String { + format!( + "{}://{}{}{path}", + &self.connection_info.scheme, &self.connection_info.bind_address, &self.connection_info.base_path + ) + } +} diff --git a/src/web/api/client/v1/mod.rs b/src/web/api/client/v1/mod.rs new file mode 100644 index 00000000..5d0fbf2f --- /dev/null +++ b/src/web/api/client/v1/mod.rs @@ -0,0 +1,6 @@ +pub mod client; +pub mod connection_info; +pub mod contexts; +pub mod http; +pub mod random; +pub mod responses; diff --git a/src/web/api/client/v1/random.rs b/src/web/api/client/v1/random.rs new file mode 100644 index 00000000..2133dcd2 --- /dev/null +++ b/src/web/api/client/v1/random.rs @@ -0,0 +1,10 @@ +//! Random data generators for testing. +use rand::distributions::Alphanumeric; +use rand::{thread_rng, Rng}; + +/// Returns a random alphanumeric string of a certain size. +/// +/// It is useful for generating random names, IDs, etc for testing. +pub fn string(size: usize) -> String { + thread_rng().sample_iter(&Alphanumeric).take(size).map(char::from).collect() +} diff --git a/src/web/api/client/v1/responses.rs b/src/web/api/client/v1/responses.rs new file mode 100644 index 00000000..bacdcba6 --- /dev/null +++ b/src/web/api/client/v1/responses.rs @@ -0,0 +1,97 @@ +use reqwest::Response as ReqwestResponse; + +#[derive(Debug)] +pub struct TextResponse { + pub status: u16, + pub content_type: Option, + pub body: String, +} + +impl TextResponse { + /// # Panics + /// + /// Will panic if: + /// + /// - It can't map the content type in the response header to string. + /// - It can't get the response bytes. + pub async fn from(response: ReqwestResponse) -> Self { + Self { + status: response.status().as_u16(), + content_type: response + .headers() + .get("content-type") + .map(|content_type| content_type.to_str().unwrap().to_owned()), + body: response.text().await.unwrap(), + } + } + + #[must_use] + pub fn is_json_and_ok(&self) -> bool { + self.is_ok() && self.is_json() + } + + #[must_use] + pub fn is_json(&self) -> bool { + if let Some(content_type) = &self.content_type { + return content_type == "application/json"; + } + false + } + + #[must_use] + pub fn is_ok(&self) -> bool { + self.status == 200 + } +} + +#[derive(Debug)] +pub struct BinaryResponse { + pub status: u16, + pub content_type: Option, + pub bytes: Vec, +} + +impl BinaryResponse { + /// # Panics + /// + /// Will panic if: + /// + /// - It can't map the content type in the response header to string. + /// - It can't get the response bytes. + pub async fn from(response: ReqwestResponse) -> Self { + Self { + status: response.status().as_u16(), + content_type: response + .headers() + .get("content-type") + .map(|content_type| content_type.to_str().unwrap().to_owned()), + bytes: response.bytes().await.unwrap().to_vec(), + } + } + + #[must_use] + pub fn is_a_bit_torrent_file(&self) -> bool { + self.is_ok() && (self.is_bittorrent_content_type() || self.is_octet_stream_content_type()) + } + + #[must_use] + pub fn is_bittorrent_content_type(&self) -> bool { + if let Some(content_type) = &self.content_type { + return content_type == "application/x-bittorrent"; + } + false + } + + #[must_use] + pub fn is_octet_stream_content_type(&self) -> bool { + if let Some(content_type) = &self.content_type { + return content_type == "application/octet-stream"; + } + false + } + + #[must_use] + pub fn is_ok(&self) -> bool { + self.status == 200 + } +} diff --git a/src/web/api/mod.rs b/src/web/api/mod.rs index 749008f1..6615a5d5 100644 --- a/src/web/api/mod.rs +++ b/src/web/api/mod.rs @@ -2,16 +2,21 @@ //! //! Currently, the API has only one version: `v1`. //! -//! Refer to the [`v1`]) module for more information. +//! Refer to: +//! +//! - [`client::v1`]) module for more information about the API client. +//! - [`server::v1`]) module for more information about the API server. +pub mod client; pub mod server; -pub mod v1; use std::net::SocketAddr; use std::sync::Arc; use tokio::task::JoinHandle; +use self::server::signals::Halted; use crate::common::AppData; +use crate::config::Tsl; use crate::web::api; /// API versions. @@ -23,20 +28,21 @@ pub enum Version { pub struct Running { /// The socket address the API server is listening on. pub socket_addr: SocketAddr, + /// The channel sender to send halt signal to the server. + pub halt_task: tokio::sync::oneshot::Sender, /// The handle for the running API server. - pub api_server: Option>>, -} - -#[must_use] -#[derive(Debug)] -pub struct ServerStartedMessage { - pub socket_addr: SocketAddr, + pub task: JoinHandle>, } /// Starts the API server. #[must_use] -pub async fn start(app_data: Arc, net_ip: &str, net_port: u16, implementation: &Version) -> api::Running { +pub async fn start( + app_data: Arc, + config_bind_address: SocketAddr, + opt_tsl: Option, + implementation: &Version, +) -> api::Running { match implementation { - Version::V1 => server::start(app_data, net_ip, net_port).await, + Version::V1 => server::start(app_data, config_bind_address, opt_tsl).await, } } diff --git a/src/web/api/server.rs b/src/web/api/server.rs deleted file mode 100644 index 8fa1e704..00000000 --- a/src/web/api/server.rs +++ /dev/null @@ -1,75 +0,0 @@ -use std::net::SocketAddr; -use std::sync::Arc; - -use futures::Future; -use log::info; -use tokio::sync::oneshot::{self, Sender}; - -use super::v1::routes::router; -use super::{Running, ServerStartedMessage}; -use crate::common::AppData; - -/// Starts the API server. -/// -/// # Panics -/// -/// Panics if the API server can't be started. -pub async fn start(app_data: Arc, net_ip: &str, net_port: u16) -> Running { - let config_socket_addr: SocketAddr = format!("{net_ip}:{net_port}") - .parse() - .expect("API server socket address to be valid."); - - let (tx, rx) = oneshot::channel::(); - - // Run the API server - let join_handle = tokio::spawn(async move { - info!("Starting API server with net config: {} ...", config_socket_addr); - - let handle = start_server(config_socket_addr, app_data.clone(), tx); - - if let Ok(()) = handle.await { - info!("API server stopped"); - } - - Ok(()) - }); - - // Wait until the API server is running - let bound_addr = match rx.await { - Ok(msg) => msg.socket_addr, - Err(e) => panic!("API server start. The API server was dropped: {e}"), - }; - - Running { - socket_addr: bound_addr, - api_server: Some(join_handle), - } -} - -fn start_server( - config_socket_addr: SocketAddr, - app_data: Arc, - tx: Sender, -) -> impl Future> { - let tcp_listener = std::net::TcpListener::bind(config_socket_addr).expect("tcp listener to bind to a socket address"); - - let bound_addr = tcp_listener - .local_addr() - .expect("tcp listener to be bound to a socket address."); - - info!("API server listening on http://{}", bound_addr); - - let app = router(app_data); - - let server = axum::Server::from_tcp(tcp_listener) - .expect("a new server from the previously created tcp listener.") - .serve(app.into_make_service_with_connect_info::()); - - tx.send(ServerStartedMessage { socket_addr: bound_addr }) - .expect("the API server should not be dropped"); - - server.with_graceful_shutdown(async move { - tokio::signal::ctrl_c().await.expect("Failed to listen to shutdown signal."); - info!("Stopping API server on http://{} ...", bound_addr); - }) -} diff --git a/src/web/api/server/custom_axum.rs b/src/web/api/server/custom_axum.rs new file mode 100644 index 00000000..5705ef24 --- /dev/null +++ b/src/web/api/server/custom_axum.rs @@ -0,0 +1,275 @@ +//! Wrapper for Axum server to add timeouts. +//! +//! Copyright (c) Eray Karatay ([@programatik29](https://github.com/programatik29)). +//! +//! See: . +//! +//! If a client opens a HTTP connection and it does not send any requests, the +//! connection is closed after a timeout. You can test it with: +//! +//! ```text +//! telnet 127.0.0.1 1212 +//! Trying 127.0.0.1... +//! Connected to 127.0.0.1. +//! Escape character is '^]'. +//! Connection closed by foreign host. +//! ``` +//! +//! If you want to know more about Axum and timeouts see . +use std::future::Ready; +use std::io::ErrorKind; +use std::net::TcpListener; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; + +use axum_server::accept::Accept; +use axum_server::tls_rustls::{RustlsAcceptor, RustlsConfig}; +use axum_server::Server; +use futures_util::{ready, Future}; +use http_body::{Body, Frame}; +use hyper::Response; +use hyper_util::rt::TokioTimer; +use pin_project_lite::pin_project; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use tokio::time::{Instant, Sleep}; +use tower::Service; + +const HTTP1_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(5); +const HTTP2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(5); +const HTTP2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(5); + +#[must_use] +pub fn from_tcp_with_timeouts(socket: TcpListener) -> Server { + add_timeouts(axum_server::from_tcp(socket)) +} + +#[must_use] +pub fn from_tcp_rustls_with_timeouts(socket: TcpListener, tls: RustlsConfig) -> Server { + add_timeouts(axum_server::from_tcp_rustls(socket, tls)) +} + +fn add_timeouts(mut server: Server) -> Server { + server.http_builder().http1().timer(TokioTimer::new()); + server.http_builder().http2().timer(TokioTimer::new()); + + server.http_builder().http1().header_read_timeout(HTTP1_HEADER_READ_TIMEOUT); + server + .http_builder() + .http2() + .keep_alive_timeout(HTTP2_KEEP_ALIVE_TIMEOUT) + .keep_alive_interval(HTTP2_KEEP_ALIVE_INTERVAL); + + server +} + +#[derive(Clone)] +pub struct TimeoutAcceptor; + +impl Accept for TimeoutAcceptor { + type Stream = TimeoutStream; + type Service = TimeoutService; + type Future = Ready>; + + fn accept(&self, stream: I, service: S) -> Self::Future { + let (tx, rx) = mpsc::unbounded_channel(); + + let stream = TimeoutStream::new(stream, HTTP1_HEADER_READ_TIMEOUT, rx); + let service = TimeoutService::new(service, tx); + + std::future::ready(Ok((stream, service))) + } +} + +#[derive(Clone)] +pub struct TimeoutService { + inner: S, + sender: UnboundedSender, +} + +impl TimeoutService { + fn new(inner: S, sender: UnboundedSender) -> Self { + Self { inner, sender } + } +} + +impl Service for TimeoutService +where + S: Service>, +{ + type Response = Response>; + type Error = S::Error; + type Future = TimeoutServiceFuture; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + // send timer wait signal + let _ = self.sender.send(TimerSignal::Wait); + + TimeoutServiceFuture::new(self.inner.call(req), self.sender.clone()) + } +} + +pin_project! { + pub struct TimeoutServiceFuture { + #[pin] + inner: F, + sender: Option>, + } +} + +impl TimeoutServiceFuture { + fn new(inner: F, sender: UnboundedSender) -> Self { + Self { + inner, + sender: Some(sender), + } + } +} + +impl Future for TimeoutServiceFuture +where + F: Future, E>>, +{ + type Output = Result>, E>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.project(); + this.inner.poll(cx).map(|result| { + result.map(|response| { + response.map(|body| TimeoutBody::new(body, this.sender.take().expect("future polled after ready"))) + }) + }) + } +} + +enum TimerSignal { + Wait, + Reset, +} + +pin_project! { + pub struct TimeoutBody { + #[pin] + inner: B, + sender: UnboundedSender, + } +} + +impl TimeoutBody { + fn new(inner: B, sender: UnboundedSender) -> Self { + Self { inner, sender } + } +} + +impl Body for TimeoutBody { + type Data = B::Data; + type Error = B::Error; + + fn poll_frame(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll, Self::Error>>> { + let this = self.project(); + let option = ready!(this.inner.poll_frame(cx)); + + if option.is_none() { + let _ = this.sender.send(TimerSignal::Reset); + } + + Poll::Ready(option) + } + + fn is_end_stream(&self) -> bool { + let is_end_stream = self.inner.is_end_stream(); + + if is_end_stream { + let _ = self.sender.send(TimerSignal::Reset); + } + + is_end_stream + } + + fn size_hint(&self) -> http_body::SizeHint { + self.inner.size_hint() + } +} + +pub struct TimeoutStream { + inner: IO, + // hyper requires unpin + sleep: Pin>, + duration: Duration, + waiting: bool, + receiver: UnboundedReceiver, + finished: bool, +} + +impl TimeoutStream { + fn new(inner: IO, duration: Duration, receiver: UnboundedReceiver) -> Self { + Self { + inner, + sleep: Box::pin(tokio::time::sleep(duration)), + duration, + waiting: false, + receiver, + finished: false, + } + } +} + +impl AsyncRead for TimeoutStream { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + if !self.finished { + match Pin::new(&mut self.receiver).poll_recv(cx) { + // reset the timer + Poll::Ready(Some(TimerSignal::Reset)) => { + self.waiting = false; + + let deadline = Instant::now() + self.duration; + self.sleep.as_mut().reset(deadline); + } + // enter waiting mode (for response body last chunk) + Poll::Ready(Some(TimerSignal::Wait)) => self.waiting = true, + Poll::Ready(None) => self.finished = true, + Poll::Pending => (), + } + } + + if !self.waiting { + // return error if timer is elapsed + if let Poll::Ready(()) = self.sleep.as_mut().poll(cx) { + return Poll::Ready(Err(std::io::Error::new(ErrorKind::TimedOut, "request header read timed out"))); + } + } + + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for TimeoutStream { + fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } + + fn poll_write_vectored( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[std::io::IoSlice<'_>], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write_vectored(cx, bufs) + } + + fn is_write_vectored(&self) -> bool { + self.inner.is_write_vectored() + } +} diff --git a/src/web/api/server/mod.rs b/src/web/api/server/mod.rs new file mode 100644 index 00000000..edaa6a86 --- /dev/null +++ b/src/web/api/server/mod.rs @@ -0,0 +1,155 @@ +pub mod custom_axum; +pub mod signals; +pub mod v1; + +use std::net::SocketAddr; +use std::panic::Location; +use std::sync::Arc; + +use axum_server::tls_rustls::RustlsConfig; +use axum_server::Handle; +use thiserror::Error; +use tokio::sync::oneshot::{Receiver, Sender}; +use torrust_index_located_error::LocatedError; +use tracing::{error, info}; +use v1::routes::router; + +use self::signals::{Halted, Started}; +use super::Running; +use crate::common::AppData; +use crate::config::Tsl; +use crate::web::api::server::custom_axum::TimeoutAcceptor; +use crate::web::api::server::signals::graceful_shutdown; + +pub type DynError = Arc; + +/// Starts the API server. +/// +/// # Panics +/// +/// Panics if the API server can't be started. +pub async fn start(app_data: Arc, config_bind_address: SocketAddr, opt_tsl: Option) -> Running { + let opt_rust_tls_config = make_rust_tls(&opt_tsl) + .await + .map(|tls| tls.expect("it should have a valid net tls configuration")); + + let (tx_start, rx) = tokio::sync::oneshot::channel::(); + let (tx_halt, rx_halt) = tokio::sync::oneshot::channel::(); + + // Run the API server + let join_handle = tokio::spawn(async move { + info!("Starting API server with net config: {} ...", config_bind_address); + + start_server(config_bind_address, app_data.clone(), tx_start, rx_halt, opt_rust_tls_config).await; + + info!("API server stopped"); + + Ok(()) + }); + + // Wait until the API server is running + let bound_addr = match rx.await { + Ok(started) => started.socket_addr, + Err(err) => { + let msg = format!("Unable to start API server: {err}"); + error!("{}", msg); + panic!("{}", msg); + } + }; + + Running { + socket_addr: bound_addr, + halt_task: tx_halt, + task: join_handle, + } +} + +async fn start_server( + config_socket_addr: SocketAddr, + app_data: Arc, + tx_start: Sender, + rx_halt: Receiver, + rust_tls_config: Option, +) { + let router = router(app_data); + let socket = std::net::TcpListener::bind(config_socket_addr).expect("Could not bind tcp_listener to address."); + let address = socket.local_addr().expect("Could not get local_addr from tcp_listener."); + + let handle = Handle::new(); + + tokio::task::spawn(graceful_shutdown( + handle.clone(), + rx_halt, + format!("Shutting down API server on socket address: {address}"), + )); + + let tls = rust_tls_config.clone(); + let protocol = if tls.is_some() { "https" } else { "http" }; + + info!("API server listening on {}://{}", protocol, address); // # DevSkim: ignore DS137138 + + tx_start + .send(Started { socket_addr: address }) + .expect("the API server should not be dropped"); + + match tls { + Some(tls) => custom_axum::from_tcp_rustls_with_timeouts(socket, tls) + .handle(handle) + // The TimeoutAcceptor is commented because TSL does not work with it. + // See: https://github.com/torrust/torrust-index/issues/204 + //.acceptor(TimeoutAcceptor) + .serve(router.into_make_service_with_connect_info::()) + .await + .expect("API server should be running"), + None => custom_axum::from_tcp_with_timeouts(socket) + .handle(handle) + .acceptor(TimeoutAcceptor) + .serve(router.into_make_service_with_connect_info::()) + .await + .expect("API server should be running"), + }; +} + +#[derive(Error, Debug)] +pub enum Error { + /// Enabled tls but missing config. + #[error("tls config missing")] + MissingTlsConfig { location: &'static Location<'static> }, + + /// Unable to parse tls Config. + #[error("bad tls config: {source}")] + BadTlsConfig { + source: LocatedError<'static, dyn std::error::Error + Send + Sync>, + ssl_cert_path: String, + ssl_key_path: String, + }, +} + +pub async fn make_rust_tls(tsl_config: &Option) -> Option> { + if let Some(tsl) = tsl_config { + if let (Some(cert), Some(key)) = (tsl.ssl_cert_path.clone(), tsl.ssl_key_path.clone()) { + info!("Using https. Cert path: {cert}."); + info!("Using https. Key path: {key}."); + + let ssl_cert_path = cert.clone().to_string(); + let ssl_key_path = key.clone().to_string(); + + Some( + RustlsConfig::from_pem_file(cert, key) + .await + .map_err(|err| Error::BadTlsConfig { + source: (Arc::new(err) as DynError).into(), + ssl_cert_path, + ssl_key_path, + }), + ) + } else { + Some(Err(Error::MissingTlsConfig { + location: Location::caller(), + })) + } + } else { + info!("TLS not enabled"); + None + } +} diff --git a/src/web/api/server/signals.rs b/src/web/api/server/signals.rs new file mode 100644 index 00000000..2eead7da --- /dev/null +++ b/src/web/api/server/signals.rs @@ -0,0 +1,88 @@ +use std::net::SocketAddr; +use std::time::Duration; + +use derive_more::Display; +use tokio::time::sleep; +use tracing::info; + +/// This is the message that the "launcher" spawned task sends to the main +/// application process to notify the service was successfully started. +#[derive(Copy, Clone, Debug, Display)] +pub struct Started { + pub socket_addr: SocketAddr, +} + +/// This is the message that the "launcher" spawned task receives from the main +/// application process to notify the service to shutdown. +#[derive(Copy, Clone, Debug, Display)] +pub enum Halted { + Normal, +} + +pub async fn graceful_shutdown(handle: axum_server::Handle, rx_halt: tokio::sync::oneshot::Receiver, message: String) { + shutdown_signal_with_message(rx_halt, message).await; + + info!("Sending graceful shutdown signal"); + handle.graceful_shutdown(Some(Duration::from_secs(90))); + + println!("!! shuting down in 90 seconds !!"); + + loop { + sleep(Duration::from_secs(1)).await; + + info!("remaining alive connections: {}", handle.connection_count()); + } +} + +/// Same as `shutdown_signal()`, but shows a message when it resolves. +pub async fn shutdown_signal_with_message(rx_halt: tokio::sync::oneshot::Receiver, message: String) { + shutdown_signal(rx_halt).await; + + info!("{message}"); +} + +/// Resolves when the `stop_receiver` or the `global_shutdown_signal()` resolves. +/// +/// # Panics +/// +/// Will panic if the `stop_receiver` resolves with an error. +pub async fn shutdown_signal(rx_halt: tokio::sync::oneshot::Receiver) { + let halt = async { + match rx_halt.await { + Ok(signal) => signal, + Err(err) => panic!("Failed to install stop signal: {err}"), + } + }; + + tokio::select! { + signal = halt => { info!("Halt signal processed: {}", signal) }, + () = global_shutdown_signal() => { info!("Global shutdown signal processed") } + } +} + +/// Resolves on `ctrl_c` or the `terminate` signal. +/// +/// # Panics +/// +/// Will panic if the `ctrl_c` or `terminate` signal resolves with an error. +pub async fn global_shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c().await.expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + () = ctrl_c => {}, + () = terminate => {} + } +} diff --git a/src/web/api/v1/auth.rs b/src/web/api/server/v1/auth.rs similarity index 98% rename from src/web/api/v1/auth.rs rename to src/web/api/server/v1/auth.rs index e52542cc..3e355b30 100644 --- a/src/web/api/v1/auth.rs +++ b/src/web/api/server/v1/auth.rs @@ -86,7 +86,7 @@ use crate::common::AppData; use crate::errors::ServiceError; use crate::models::user::{UserClaims, UserCompact, UserId}; use crate::services::authentication::JsonWebToken; -use crate::web::api::v1::extractors::bearer_token::BearerToken; +use crate::web::api::server::v1::extractors::bearer_token::BearerToken; pub struct Authentication { json_web_token: Arc, diff --git a/src/web/api/server/v1/contexts/about/handlers.rs b/src/web/api/server/v1/contexts/about/handlers.rs new file mode 100644 index 00000000..530eae89 --- /dev/null +++ b/src/web/api/server/v1/contexts/about/handlers.rs @@ -0,0 +1,34 @@ +//! API handlers for the the [`about`](crate::web::api::server::v1::contexts::about) API +//! context. +use std::sync::Arc; + +use axum::extract::State; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; + +use crate::common::AppData; +use crate::web::api::server::v1::extractors::optional_user_id::ExtractOptionalLoggedInUser; + +#[allow(clippy::unused_async)] +pub async fn about_page_handler( + State(app_data): State>, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, +) -> Response { + match app_data.about_service.get_about_page(maybe_user_id).await { + Ok(html) => (StatusCode::OK, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response(), + Err(error) => error.into_response(), + } +} + +#[allow(clippy::unused_async)] +pub async fn license_page_handler( + State(app_data): State>, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, +) -> Response { + match app_data.about_service.get_license_page(maybe_user_id).await { + Ok(html) => (StatusCode::OK, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], html) + .into_response() + .into_response(), + Err(error) => error.into_response(), + } +} diff --git a/src/web/api/v1/contexts/about/mod.rs b/src/web/api/server/v1/contexts/about/mod.rs similarity index 97% rename from src/web/api/v1/contexts/about/mod.rs rename to src/web/api/server/v1/contexts/about/mod.rs index ef4668d1..02423b4c 100644 --- a/src/web/api/v1/contexts/about/mod.rs +++ b/src/web/api/server/v1/contexts/about/mod.rs @@ -35,7 +35,7 @@ //! //! //! //! //! ``` diff --git a/src/web/api/v1/contexts/about/routes.rs b/src/web/api/server/v1/contexts/about/routes.rs similarity index 58% rename from src/web/api/v1/contexts/about/routes.rs rename to src/web/api/server/v1/contexts/about/routes.rs index d3877a3b..8a9ccef7 100644 --- a/src/web/api/v1/contexts/about/routes.rs +++ b/src/web/api/server/v1/contexts/about/routes.rs @@ -1,6 +1,6 @@ -//! API routes for the [`about`](crate::web::api::v1::contexts::about) API context. +//! API routes for the [`about`](crate::web::api::server::v1::contexts::about) API context. //! -//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::about). +//! Refer to the [API endpoint documentation](crate::web::api::server::v1::contexts::about). use std::sync::Arc; use axum::routing::get; @@ -9,7 +9,7 @@ use axum::Router; use super::handlers::{about_page_handler, license_page_handler}; use crate::common::AppData; -/// Routes for the [`about`](crate::web::api::v1::contexts::about) API context. +/// Routes for the [`about`](crate::web::api::server::v1::contexts::about) API context. pub fn router(app_data: Arc) -> Router { Router::new() .route("/", get(about_page_handler).with_state(app_data.clone())) diff --git a/src/web/api/v1/contexts/category/forms.rs b/src/web/api/server/v1/contexts/category/forms.rs similarity index 100% rename from src/web/api/v1/contexts/category/forms.rs rename to src/web/api/server/v1/contexts/category/forms.rs diff --git a/src/web/api/v1/contexts/category/handlers.rs b/src/web/api/server/v1/contexts/category/handlers.rs similarity index 59% rename from src/web/api/v1/contexts/category/handlers.rs rename to src/web/api/server/v1/contexts/category/handlers.rs index da0c1209..2bfe7437 100644 --- a/src/web/api/v1/contexts/category/handlers.rs +++ b/src/web/api/server/v1/contexts/category/handlers.rs @@ -1,4 +1,4 @@ -//! API handlers for the the [`category`](crate::web::api::v1::contexts::category) API +//! API handlers for the the [`category`](crate::web::api::server::v1::contexts::category) API //! context. use std::sync::Arc; @@ -6,10 +6,10 @@ use axum::extract::{self, State}; use axum::response::{IntoResponse, Json, Response}; use super::forms::{AddCategoryForm, DeleteCategoryForm}; -use super::responses::{added_category, deleted_category}; +use super::responses::{added_category, deleted_category, Category}; use crate::common::AppData; -use crate::web::api::v1::extractors::bearer_token::Extract; -use crate::web::api::v1::responses::{self}; +use crate::web::api::server::v1::extractors::optional_user_id::ExtractOptionalLoggedInUser; +use crate::web::api::server::v1::responses::{self}; /// It handles the request to get all the categories. /// @@ -18,16 +18,22 @@ use crate::web::api::v1::responses::{self}; /// - `200` response with a json containing the category list [`Vec`](crate::databases::database::Category). /// - Other error status codes if there is a database error. /// -/// Refer to the [API endpoint documentation](crate::web::api::v1::contexts::category) +/// Refer to the [API endpoint documentation](crate::web::api::server::v1::contexts::category) /// for more information about this endpoint. /// /// # Errors /// /// It returns an error if there is a database error. #[allow(clippy::unused_async)] -pub async fn get_all_handler(State(app_data): State>) -> Response { - match app_data.category_repository.get_all().await { - Ok(categories) => Json(responses::OkResponseData { data: categories }).into_response(), +pub async fn get_all_handler( + State(app_data): State>, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, +) -> Response { + match app_data.category_service.get_categories(maybe_user_id).await { + Ok(categories) => { + let categories: Vec = categories.into_iter().map(Category::from).collect(); + Json(responses::OkResponseData { data: categories }).into_response() + } Err(error) => error.into_response(), } } @@ -43,15 +49,14 @@ pub async fn get_all_handler(State(app_data): State>) -> Response { #[allow(clippy::unused_async)] pub async fn add_handler( State(app_data): State>, - Extract(maybe_bearer_token): Extract, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, extract::Json(category_form): extract::Json, ) -> Response { - let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { - Ok(user_id) => user_id, - Err(error) => return error.into_response(), - }; - - match app_data.category_service.add_category(&category_form.name, &user_id).await { + match app_data + .category_service + .add_category(&category_form.name, maybe_user_id) + .await + { Ok(_) => added_category(&category_form.name).into_response(), Err(error) => error.into_response(), } @@ -68,19 +73,18 @@ pub async fn add_handler( #[allow(clippy::unused_async)] pub async fn delete_handler( State(app_data): State>, - Extract(maybe_bearer_token): Extract, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, extract::Json(category_form): extract::Json, ) -> Response { // code-review: why do we need to send the whole category object to delete it? // And we should use the ID instead of the name, because the name could change // or we could add support for multiple languages. - let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { - Ok(user_id) => user_id, - Err(error) => return error.into_response(), - }; - - match app_data.category_service.delete_category(&category_form.name, &user_id).await { + match app_data + .category_service + .delete_category(&category_form.name, maybe_user_id) + .await + { Ok(()) => deleted_category(&category_form.name).into_response(), Err(error) => error.into_response(), } diff --git a/src/web/api/v1/contexts/category/mod.rs b/src/web/api/server/v1/contexts/category/mod.rs similarity index 100% rename from src/web/api/v1/contexts/category/mod.rs rename to src/web/api/server/v1/contexts/category/mod.rs diff --git a/src/web/api/server/v1/contexts/category/responses.rs b/src/web/api/server/v1/contexts/category/responses.rs new file mode 100644 index 00000000..577b0882 --- /dev/null +++ b/src/web/api/server/v1/contexts/category/responses.rs @@ -0,0 +1,41 @@ +//! API responses for the the [`category`](crate::web::api::server::v1::contexts::category) API +//! context. +use axum::Json; +use serde::{Deserialize, Serialize}; + +use crate::databases::database::Category as DatabaseCategory; +use crate::web::api::server::v1::responses::OkResponseData; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Category { + pub id: i64, + /// Deprecated. Use `id`. + pub category_id: i64, // todo: remove when the Index GUI uses the new `id` field. + pub name: String, + pub num_torrents: i64, +} + +/// Response after successfully creating a new category. +pub fn added_category(category_name: &str) -> Json> { + Json(OkResponseData { + data: category_name.to_string(), + }) +} + +/// Response after successfully deleting a new category. +pub fn deleted_category(category_name: &str) -> Json> { + Json(OkResponseData { + data: category_name.to_string(), + }) +} + +impl From for Category { + fn from(db_category: DatabaseCategory) -> Self { + Category { + id: db_category.category_id, + category_id: db_category.category_id, + name: db_category.name, + num_torrents: db_category.num_torrents, + } + } +} diff --git a/src/web/api/v1/contexts/category/routes.rs b/src/web/api/server/v1/contexts/category/routes.rs similarity index 61% rename from src/web/api/v1/contexts/category/routes.rs rename to src/web/api/server/v1/contexts/category/routes.rs index 2d762c47..df8e728d 100644 --- a/src/web/api/v1/contexts/category/routes.rs +++ b/src/web/api/server/v1/contexts/category/routes.rs @@ -1,6 +1,6 @@ -//! API routes for the [`category`](crate::web::api::v1::contexts::category) API context. +//! API routes for the [`category`](crate::web::api::server::v1::contexts::category) API context. //! -//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::category). +//! Refer to the [API endpoint documentation](crate::web::api::server::v1::contexts::category). use std::sync::Arc; use axum::routing::{delete, get, post}; @@ -9,7 +9,7 @@ use axum::Router; use super::handlers::{add_handler, delete_handler, get_all_handler}; use crate::common::AppData; -/// Routes for the [`category`](crate::web::api::v1::contexts::category) API context. +/// Routes for the [`category`](crate::web::api::server::v1::contexts::category) API context. pub fn router(app_data: Arc) -> Router { Router::new() .route("/", get(get_all_handler).with_state(app_data.clone())) diff --git a/src/web/api/server/v1/contexts/mod.rs b/src/web/api/server/v1/contexts/mod.rs new file mode 100644 index 00000000..a39cbf4e --- /dev/null +++ b/src/web/api/server/v1/contexts/mod.rs @@ -0,0 +1,19 @@ +//! The API is organized in the following contexts: +//! +//! Context | Description | Version +//! ---|---|--- +//! `About` | Metadata about the API | [`v1`](crate::web::api::server::v1::contexts::about) +//! `Category` | Torrent categories | [`v1`](crate::web::api::server::v1::contexts::category) +//! `Proxy` | Image proxy cache | [`v1`](crate::web::api::server::v1::contexts::proxy) +//! `Settings` | Index settings | [`v1`](crate::web::api::server::v1::contexts::settings) +//! `Tag` | Torrent tags | [`v1`](crate::web::api::server::v1::contexts::tag) +//! `Torrent` | Indexed torrents | [`v1`](crate::web::api::server::v1::contexts::torrent) +//! `User` | Users | [`v1`](crate::web::api::server::v1::contexts::user) +//! +pub mod about; +pub mod category; +pub mod proxy; +pub mod settings; +pub mod tag; +pub mod torrent; +pub mod user; diff --git a/src/web/api/v1/contexts/proxy/handlers.rs b/src/web/api/server/v1/contexts/proxy/handlers.rs similarity index 65% rename from src/web/api/v1/contexts/proxy/handlers.rs rename to src/web/api/server/v1/contexts/proxy/handlers.rs index 1e5105ee..241fe3aa 100644 --- a/src/web/api/v1/contexts/proxy/handlers.rs +++ b/src/web/api/server/v1/contexts/proxy/handlers.rs @@ -1,4 +1,4 @@ -//! API handlers for the the [`proxy`](crate::web::api::v1::contexts::proxy) API +//! API handlers for the the [`proxy`](crate::web::api::server::v1::contexts::proxy) API //! context. use std::sync::Arc; @@ -6,26 +6,17 @@ use axum::extract::{Path, State}; use axum::response::Response; use super::responses::png_image; -use crate::cache::image::manager::Error; use crate::common::AppData; use crate::ui::proxy::map_error_to_image; -use crate::web::api::v1::extractors::bearer_token::Extract; +use crate::web::api::server::v1::extractors::optional_user_id::ExtractOptionalLoggedInUser; /// Get the remote image. It uses the cached image if available. #[allow(clippy::unused_async)] pub async fn get_proxy_image_handler( State(app_data): State>, - Extract(maybe_bearer_token): Extract, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, Path(url): Path, ) -> Response { - if maybe_bearer_token.is_none() { - return png_image(map_error_to_image(&Error::Unauthenticated)); - } - - let Ok(user_id) = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await else { - return png_image(map_error_to_image(&Error::Unauthenticated)); - }; - // code-review: Handling status codes in the index-gui other tan OK is quite a pain. // Return OK for now. @@ -36,7 +27,7 @@ pub async fn get_proxy_image_handler( // Get image URL from URL path parameter. let image_url = urlencoding::decode(&url).unwrap_or_default().into_owned(); - match app_data.proxy_service.get_image_by_url(&image_url, &user_id).await { + match app_data.proxy_service.get_image_by_url(&image_url, maybe_user_id).await { Ok(image_bytes) => { // Returns the cached image. png_image(image_bytes) diff --git a/src/web/api/v1/contexts/proxy/mod.rs b/src/web/api/server/v1/contexts/proxy/mod.rs similarity index 100% rename from src/web/api/v1/contexts/proxy/mod.rs rename to src/web/api/server/v1/contexts/proxy/mod.rs diff --git a/src/web/api/v1/contexts/proxy/responses.rs b/src/web/api/server/v1/contexts/proxy/responses.rs similarity index 100% rename from src/web/api/v1/contexts/proxy/responses.rs rename to src/web/api/server/v1/contexts/proxy/responses.rs diff --git a/src/web/api/v1/contexts/proxy/routes.rs b/src/web/api/server/v1/contexts/proxy/routes.rs similarity index 51% rename from src/web/api/v1/contexts/proxy/routes.rs rename to src/web/api/server/v1/contexts/proxy/routes.rs index e6bd7bef..b12ff0ee 100644 --- a/src/web/api/v1/contexts/proxy/routes.rs +++ b/src/web/api/server/v1/contexts/proxy/routes.rs @@ -1,6 +1,6 @@ -//! API routes for the [`proxy`](crate::web::api::v1::contexts::proxy) API context. +//! API routes for the [`proxy`](crate::web::api::server::v1::contexts::proxy) API context. //! -//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::proxy). +//! Refer to the [API endpoint documentation](crate::web::api::server::v1::contexts::proxy). use std::sync::Arc; use axum::routing::get; @@ -9,7 +9,7 @@ use axum::Router; use super::handlers::get_proxy_image_handler; use crate::common::AppData; -/// Routes for the [`about`](crate::web::api::v1::contexts::about) API context. +/// Routes for the [`about`](crate::web::api::server::v1::contexts::about) API context. pub fn router(app_data: Arc) -> Router { Router::new().route("/image/:url", get(get_proxy_image_handler).with_state(app_data)) } diff --git a/src/web/api/server/v1/contexts/settings/handlers.rs b/src/web/api/server/v1/contexts/settings/handlers.rs new file mode 100644 index 00000000..45ff79f8 --- /dev/null +++ b/src/web/api/server/v1/contexts/settings/handlers.rs @@ -0,0 +1,49 @@ +//! API handlers for the the [`category`](crate::web::api::server::v1::contexts::category) API +//! context. +use std::sync::Arc; + +use axum::extract::State; +use axum::response::{IntoResponse, Json, Response}; + +use crate::common::AppData; +use crate::web::api::server::v1::extractors::optional_user_id::ExtractOptionalLoggedInUser; +use crate::web::api::server::v1::responses; + +/// Get all settings. +/// +/// # Errors +/// +/// This function will return an error if the user does not have permission to +/// view all the settings. +#[allow(clippy::unused_async)] +pub async fn get_all_handler( + State(app_data): State>, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, +) -> Response { + let all_settings = match app_data.settings_service.get_all_masking_secrets(maybe_user_id).await { + Ok(all_settings) => all_settings, + Err(error) => return error.into_response(), + }; + + Json(responses::OkResponseData { data: all_settings }).into_response() +} + +/// Get public Settings. +#[allow(clippy::unused_async)] +pub async fn get_public_handler( + State(app_data): State>, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, +) -> Response { + match app_data.settings_service.get_public(maybe_user_id).await { + Ok(public_settings) => Json(responses::OkResponseData { data: public_settings }).into_response(), + Err(error) => error.into_response(), + } +} + +/// Get website name. +#[allow(clippy::unused_async)] +pub async fn get_site_name_handler(State(app_data): State>) -> Response { + let site_name = app_data.settings_service.get_site_name().await; + + Json(responses::OkResponseData { data: site_name }).into_response() +} diff --git a/src/web/api/server/v1/contexts/settings/mod.rs b/src/web/api/server/v1/contexts/settings/mod.rs new file mode 100644 index 00000000..95ee15c8 --- /dev/null +++ b/src/web/api/server/v1/contexts/settings/mod.rs @@ -0,0 +1,164 @@ +//! API context: `settings`. +//! +//! This API context is responsible for handling the application settings. +//! +//! # Endpoints +//! +//! - [Get all settings](#get-all-settings) +//! - [Get site name](#get-site-name) +//! - [Get public settings](#get-public-settings) +//! +//! # Get all settings +//! +//! `GET /v1/settings` +//! +//! Returns all settings. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request GET \ +//! "http://127.0.0.1:3001/v1/settings" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": { +//! "metadata": { +//! "app": "torrust-index", +//! "purpose": "configuration", +//! "schema_version": "2.0.0" +//! }, +//! "logging": { +//! "threshold": "info" +//! }, +//! "website": { +//! "name": "Torrust" +//! }, +//! "tracker": { +//! "api_url": "http://localhost:1212/", +//! "listed": false, +//! "private": false, +//! "token": "***", +//! "token_valid_seconds": 7257600, +//! "url": "udp://localhost:6969" +//! }, +//! "net": { +//! "base_url": null, +//! "bind_address": "0.0.0.0:3001", +//! "tsl": null +//! }, +//! "auth": { +//! "user_claim_token_pepper": "***", +//! "password_constraints": { +//! "max_password_length": 64, +//! "min_password_length": 6 +//! } +//! }, +//! "database": { +//! "connect_url": "sqlite://data.db?mode=rwc" +//! }, +//! "mail": { +//! "email_verification_enabled": false, +//! "from": "example@email.com", +//! "reply_to": "noreply@email.com", +//! "smtp": { +//! "port": 25, +//! "server": "", +//! "credentials": { +//! "password": "***", +//! "username": "" +//! } +//! } +//! }, +//! "image_cache": { +//! "capacity": 128000000, +//! "entry_size_limit": 4000000, +//! "max_request_timeout_ms": 1000, +//! "user_quota_bytes": 64000000, +//! "user_quota_period_seconds": 3600 +//! }, +//! "api": { +//! "default_torrent_page_size": 10, +//! "max_torrent_page_size": 30 +//! }, +//! "registration": { +//! "email": { +//! "required": false, +//! "verified": false +//! } +//! }, +//! "tracker_statistics_importer": { +//! "port": 3002, +//! "torrent_info_update_interval": 3600 +//! } +//! } +//! } +//! ``` +//! **Resource** +//! +//! Refer to the [`TorrustIndex`](crate::config::Settings) +//! struct for more information about the response attributes. +//! +//! # Get site name +//! +//! `GET /v1/settings/name` +//! +//! It returns the name of the site. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --request GET \ +//! "http://127.0.0.1:3001/v1/settings/name" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data":"Torrust" +//! } +//! ``` +//! +//! # Get public settings +//! +//! `GET /v1/settings/public` +//! +//! It returns all the public settings. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --request GET \ +//! "http://127.0.0.1:3001/v1/settings/public" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": { +//! "website_name": "Torrust", +//! "tracker_url": "udp://localhost:6969", +//! "tracker_mode": "public", +//! "email_on_signup": "optional" +//! } +//! } +//! ``` +//! +//! **Resource** +//! +//! Refer to the [`ConfigurationPublic`](crate::services::settings::ConfigurationPublic) +//! struct for more information about the response attributes. +pub mod handlers; +pub mod routes; diff --git a/src/web/api/v1/contexts/settings/routes.rs b/src/web/api/server/v1/contexts/settings/routes.rs similarity index 62% rename from src/web/api/v1/contexts/settings/routes.rs rename to src/web/api/server/v1/contexts/settings/routes.rs index e0990f52..ebca3bb6 100644 --- a/src/web/api/v1/contexts/settings/routes.rs +++ b/src/web/api/server/v1/contexts/settings/routes.rs @@ -1,6 +1,6 @@ -//! API routes for the [`settings`](crate::web::api::v1::contexts::settings) API context. +//! API routes for the [`settings`](crate::web::api::server::v1::contexts::settings) API context. //! -//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::settings). +//! Refer to the [API endpoint documentation](crate::web::api::server::v1::contexts::settings). use std::sync::Arc; use axum::routing::get; @@ -9,7 +9,7 @@ use axum::Router; use super::handlers::{get_all_handler, get_public_handler, get_site_name_handler}; use crate::common::AppData; -/// Routes for the [`category`](crate::web::api::v1::contexts::category) API context. +/// Routes for the [`category`](crate::web::api::server::v1::contexts::category) API context. pub fn router(app_data: Arc) -> Router { Router::new() .route("/", get(get_all_handler).with_state(app_data.clone())) diff --git a/src/web/api/v1/contexts/tag/forms.rs b/src/web/api/server/v1/contexts/tag/forms.rs similarity index 76% rename from src/web/api/v1/contexts/tag/forms.rs rename to src/web/api/server/v1/contexts/tag/forms.rs index 12c751ad..d254b324 100644 --- a/src/web/api/v1/contexts/tag/forms.rs +++ b/src/web/api/server/v1/contexts/tag/forms.rs @@ -1,4 +1,4 @@ -//! API forms for the the [`tag`](crate::web::api::v1::contexts::tag) API +//! API forms for the the [`tag`](crate::web::api::server::v1::contexts::tag) API //! context. use serde::{Deserialize, Serialize}; diff --git a/src/web/api/v1/contexts/tag/handlers.rs b/src/web/api/server/v1/contexts/tag/handlers.rs similarity index 63% rename from src/web/api/v1/contexts/tag/handlers.rs rename to src/web/api/server/v1/contexts/tag/handlers.rs index f750c385..73410acc 100644 --- a/src/web/api/v1/contexts/tag/handlers.rs +++ b/src/web/api/server/v1/contexts/tag/handlers.rs @@ -1,4 +1,4 @@ -//! API handlers for the [`tag`](crate::web::api::v1::contexts::tag) API +//! API handlers for the [`tag`](crate::web::api::server::v1::contexts::tag) API //! context. use std::sync::Arc; @@ -8,8 +8,8 @@ use axum::response::{IntoResponse, Json, Response}; use super::forms::{AddTagForm, DeleteTagForm}; use super::responses::{added_tag, deleted_tag}; use crate::common::AppData; -use crate::web::api::v1::extractors::bearer_token::Extract; -use crate::web::api::v1::responses::{self}; +use crate::web::api::server::v1::extractors::optional_user_id::ExtractOptionalLoggedInUser; +use crate::web::api::server::v1::responses::{self}; /// It handles the request to get all the tags. /// @@ -18,15 +18,21 @@ use crate::web::api::v1::responses::{self}; /// - `200` response with a json containing the tag list [`Vec`](crate::models::torrent_tag::TorrentTag). /// - Other error status codes if there is a database error. /// -/// Refer to the [API endpoint documentation](crate::web::api::v1::contexts::tag) +/// Refer to the [API endpoint documentation](crate::web::api::server::v1::contexts::tag) /// for more information about this endpoint. /// /// # Errors /// -/// It returns an error if there is a database error. +/// It returns an error if: +/// There is a database error +/// There is a problem authorizing the action. +/// The user is not authorized to perform the action #[allow(clippy::unused_async)] -pub async fn get_all_handler(State(app_data): State>) -> Response { - match app_data.tag_repository.get_all().await { +pub async fn get_all_handler( + State(app_data): State>, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, +) -> Response { + match app_data.tag_service.get_tags(maybe_user_id).await { Ok(tags) => Json(responses::OkResponseData { data: tags }).into_response(), Err(error) => error.into_response(), } @@ -43,15 +49,10 @@ pub async fn get_all_handler(State(app_data): State>) -> Response { #[allow(clippy::unused_async)] pub async fn add_handler( State(app_data): State>, - Extract(maybe_bearer_token): Extract, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, extract::Json(add_tag_form): extract::Json, ) -> Response { - let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { - Ok(user_id) => user_id, - Err(error) => return error.into_response(), - }; - - match app_data.tag_service.add_tag(&add_tag_form.name, &user_id).await { + match app_data.tag_service.add_tag(&add_tag_form.name, maybe_user_id).await { Ok(_) => added_tag(&add_tag_form.name).into_response(), Err(error) => error.into_response(), } @@ -68,15 +69,10 @@ pub async fn add_handler( #[allow(clippy::unused_async)] pub async fn delete_handler( State(app_data): State>, - Extract(maybe_bearer_token): Extract, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, extract::Json(delete_tag_form): extract::Json, ) -> Response { - let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { - Ok(user_id) => user_id, - Err(error) => return error.into_response(), - }; - - match app_data.tag_service.delete_tag(&delete_tag_form.tag_id, &user_id).await { + match app_data.tag_service.delete_tag(&delete_tag_form.tag_id, maybe_user_id).await { Ok(()) => deleted_tag(delete_tag_form.tag_id).into_response(), Err(error) => error.into_response(), } diff --git a/src/web/api/v1/contexts/tag/mod.rs b/src/web/api/server/v1/contexts/tag/mod.rs similarity index 100% rename from src/web/api/v1/contexts/tag/mod.rs rename to src/web/api/server/v1/contexts/tag/mod.rs diff --git a/src/web/api/v1/contexts/tag/responses.rs b/src/web/api/server/v1/contexts/tag/responses.rs similarity index 74% rename from src/web/api/v1/contexts/tag/responses.rs rename to src/web/api/server/v1/contexts/tag/responses.rs index a1645994..ac1e2c75 100644 --- a/src/web/api/v1/contexts/tag/responses.rs +++ b/src/web/api/server/v1/contexts/tag/responses.rs @@ -1,9 +1,9 @@ -//! API responses for the [`tag`](crate::web::api::v1::contexts::tag) API +//! API responses for the [`tag`](crate::web::api::server::v1::contexts::tag) API //! context. use axum::Json; use crate::models::torrent_tag::TagId; -use crate::web::api::v1::responses::OkResponseData; +use crate::web::api::server::v1::responses::OkResponseData; /// Response after successfully creating a new tag. pub fn added_tag(tag_name: &str) -> Json> { diff --git a/src/web/api/v1/contexts/tag/routes.rs b/src/web/api/server/v1/contexts/tag/routes.rs similarity index 64% rename from src/web/api/v1/contexts/tag/routes.rs rename to src/web/api/server/v1/contexts/tag/routes.rs index 4d72970a..0ec554e4 100644 --- a/src/web/api/v1/contexts/tag/routes.rs +++ b/src/web/api/server/v1/contexts/tag/routes.rs @@ -1,6 +1,6 @@ -//! API routes for the [`tag`](crate::web::api::v1::contexts::tag) API context. +//! API routes for the [`tag`](crate::web::api::server::v1::contexts::tag) API context. //! -//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::tag). +//! Refer to the [API endpoint documentation](crate::web::api::server::v1::contexts::tag). use std::sync::Arc; use axum::routing::{delete, get, post}; @@ -11,14 +11,14 @@ use crate::common::AppData; // code-review: should we use `tags` also for single resources? -/// Routes for the [`tag`](crate::web::api::v1::contexts::tag) API context. +/// Routes for the [`tag`](crate::web::api::server::v1::contexts::tag) API context. pub fn router_for_single_resources(app_data: Arc) -> Router { Router::new() .route("/", post(add_handler).with_state(app_data.clone())) .route("/", delete(delete_handler).with_state(app_data)) } -/// Routes for the [`tag`](crate::web::api::v1::contexts::tag) API context. +/// Routes for the [`tag`](crate::web::api::server::v1::contexts::tag) API context. pub fn router_for_multiple_resources(app_data: Arc) -> Router { Router::new().route("/", get(get_all_handler).with_state(app_data)) } diff --git a/src/web/api/v1/contexts/torrent/errors.rs b/src/web/api/server/v1/contexts/torrent/errors.rs similarity index 89% rename from src/web/api/v1/contexts/torrent/errors.rs rename to src/web/api/server/v1/contexts/torrent/errors.rs index 9bf24d48..2c18bbb2 100644 --- a/src/web/api/v1/contexts/torrent/errors.rs +++ b/src/web/api/server/v1/contexts/torrent/errors.rs @@ -2,7 +2,7 @@ use axum::response::{IntoResponse, Response}; use derive_more::{Display, Error}; use hyper::StatusCode; -use crate::web::api::v1::responses::{json_error_response, ErrorResponseData}; +use crate::web::api::server::v1::responses::{json_error_response, ErrorResponseData}; #[derive(Debug, Display, PartialEq, Eq, Error)] pub enum Request { @@ -21,7 +21,9 @@ pub enum Request { #[display(fmt = "torrent tags string is not a valid JSON.")] TagsArrayIsNotValidJson, - #[display(fmt = "upload torrent request header `content-type` should be `application/x-bittorrent`.")] + #[display( + fmt = "upload torrent request header `content-type` should be preferably `application/x-bittorrent` or `application/octet-stream`." + )] InvalidFileType, #[display(fmt = "cannot write uploaded torrent bytes (binary file) into memory.")] diff --git a/src/web/api/v1/contexts/torrent/forms.rs b/src/web/api/server/v1/contexts/torrent/forms.rs similarity index 100% rename from src/web/api/v1/contexts/torrent/forms.rs rename to src/web/api/server/v1/contexts/torrent/forms.rs diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/server/v1/contexts/torrent/handlers.rs similarity index 82% rename from src/web/api/v1/contexts/torrent/handlers.rs rename to src/web/api/server/v1/contexts/torrent/handlers.rs index bab51443..4e66dd8a 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/server/v1/contexts/torrent/handlers.rs @@ -1,4 +1,4 @@ -//! API handlers for the [`torrent`](crate::web::api::v1::contexts::torrent) API +//! API handlers for the [`torrent`](crate::web::api::server::v1::contexts::torrent) API //! context. use std::io::{Cursor, Write}; use std::str::FromStr; @@ -7,8 +7,8 @@ use std::sync::Arc; use axum::extract::{self, Multipart, Path, Query, State}; use axum::response::{IntoResponse, Redirect, Response}; use axum::Json; -use log::debug; use serde::Deserialize; +use tracing::debug; use uuid::Uuid; use super::errors; @@ -21,10 +21,10 @@ use crate::models::torrent_tag::TagId; use crate::services::torrent::{AddTorrentRequest, ListingRequest}; use crate::services::torrent_file::generate_random_torrent; use crate::utils::parse_torrent; -use crate::web::api::v1::auth::get_optional_logged_in_user; -use crate::web::api::v1::extractors::bearer_token::Extract; -use crate::web::api::v1::responses::OkResponseData; -use crate::web::api::v1::routes::API_VERSION_URL_PREFIX; +use crate::web::api::server::v1::extractors::optional_user_id::ExtractOptionalLoggedInUser; +use crate::web::api::server::v1::extractors::user_id::ExtractLoggedInUser; +use crate::web::api::server::v1::responses::OkResponseData; +use crate::web::api::server::v1::routes::API_VERSION_URL_PREFIX; /// Upload a new torrent file to the Index /// @@ -37,20 +37,15 @@ use crate::web::api::v1::routes::API_VERSION_URL_PREFIX; #[allow(clippy::unused_async)] pub async fn upload_torrent_handler( State(app_data): State>, - Extract(maybe_bearer_token): Extract, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, multipart: Multipart, ) -> Response { - let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { - Ok(user_id) => user_id, - Err(error) => return error.into_response(), - }; - let add_torrent_form = match build_add_torrent_request_from_payload(multipart).await { Ok(torrent_request) => torrent_request, Err(error) => return error.into_response(), }; - match app_data.torrent_service.add_torrent(add_torrent_form, user_id).await { + match app_data.torrent_service.add_torrent(add_torrent_form, maybe_user_id).await { Ok(response) => new_torrent_response(&response).into_response(), Err(error) => error.into_response(), } @@ -73,7 +68,7 @@ impl InfoHashParam { #[allow(clippy::unused_async)] pub async fn download_torrent_handler( State(app_data): State>, - Extract(maybe_bearer_token): Extract, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, Path(info_hash): Path, ) -> Response { let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { @@ -82,16 +77,13 @@ pub async fn download_torrent_handler( debug!("Downloading torrent: {:?}", info_hash.to_hex_string()); - if let Some(redirect_response) = redirect_to_download_url_using_canonical_info_hash_if_needed(&app_data, &info_hash).await { + if let Some(redirect_response) = + redirect_to_download_url_using_canonical_info_hash_if_needed(&app_data, &info_hash, maybe_user_id).await + { debug!("Redirecting to URL with canonical info-hash"); redirect_response } else { - let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { - Ok(opt_user_id) => opt_user_id, - Err(error) => return error.into_response(), - }; - - let torrent = match app_data.torrent_service.get_torrent(&info_hash, opt_user_id).await { + let torrent = match app_data.torrent_service.get_torrent(&info_hash, maybe_user_id).await { Ok(torrent) => torrent, Err(error) => return error.into_response(), }; @@ -111,10 +103,11 @@ pub async fn download_torrent_handler( async fn redirect_to_download_url_using_canonical_info_hash_if_needed( app_data: &Arc, info_hash: &InfoHash, + maybe_user_id: Option, ) -> Option { match app_data - .torrent_info_hash_repository - .find_canonical_info_hash_for(info_hash) + .torrent_service + .get_canonical_info_hash(info_hash, maybe_user_id) .await { Ok(Some(canonical_info_hash)) => { @@ -142,8 +135,16 @@ async fn redirect_to_download_url_using_canonical_info_hash_if_needed( /// /// It returns an error if the database query fails. #[allow(clippy::unused_async)] -pub async fn get_torrents_handler(State(app_data): State>, Query(criteria): Query) -> Response { - match app_data.torrent_service.generate_torrent_info_listing(&criteria).await { +pub async fn get_torrents_handler( + State(app_data): State>, + Query(criteria): Query, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, +) -> Response { + match app_data + .torrent_service + .generate_torrent_info_listing(&criteria, maybe_user_id) + .await + { Ok(torrents_response) => Json(OkResponseData { data: torrents_response }).into_response(), Err(error) => error.into_response(), } @@ -160,22 +161,19 @@ pub async fn get_torrents_handler(State(app_data): State>, Query(cr #[allow(clippy::unused_async)] pub async fn get_torrent_info_handler( State(app_data): State>, - Extract(maybe_bearer_token): Extract, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, Path(info_hash): Path, ) -> Response { let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { return errors::Request::InvalidInfoHashParam.into_response(); }; - if let Some(redirect_response) = redirect_to_details_url_using_canonical_info_hash_if_needed(&app_data, &info_hash).await { + if let Some(redirect_response) = + redirect_to_details_url_using_canonical_info_hash_if_needed(&app_data, &info_hash, maybe_user_id).await + { redirect_response } else { - let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { - Ok(opt_user_id) => opt_user_id, - Err(error) => return error.into_response(), - }; - - match app_data.torrent_service.get_torrent_info(&info_hash, opt_user_id).await { + match app_data.torrent_service.get_torrent_info(&info_hash, maybe_user_id).await { Ok(torrent_response) => Json(OkResponseData { data: torrent_response }).into_response(), Err(error) => error.into_response(), } @@ -185,10 +183,11 @@ pub async fn get_torrent_info_handler( async fn redirect_to_details_url_using_canonical_info_hash_if_needed( app_data: &Arc, info_hash: &InfoHash, + maybe_user_id: Option, ) -> Option { match app_data - .torrent_info_hash_repository - .find_canonical_info_hash_for(info_hash) + .torrent_service + .get_canonical_info_hash(info_hash, maybe_user_id) .await { Ok(Some(canonical_info_hash)) => { @@ -220,7 +219,7 @@ async fn redirect_to_details_url_using_canonical_info_hash_if_needed( #[allow(clippy::unused_async)] pub async fn update_torrent_info_handler( State(app_data): State>, - Extract(maybe_bearer_token): Extract, + ExtractLoggedInUser(user_id): ExtractLoggedInUser, Path(info_hash): Path, extract::Json(update_torrent_info_form): extract::Json, ) -> Response { @@ -228,11 +227,6 @@ pub async fn update_torrent_info_handler( return errors::Request::InvalidInfoHashParam.into_response(); }; - let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { - Ok(user_id) => user_id, - Err(error) => return error.into_response(), - }; - match app_data .torrent_service .update_torrent_info( @@ -262,19 +256,14 @@ pub async fn update_torrent_info_handler( #[allow(clippy::unused_async)] pub async fn delete_torrent_handler( State(app_data): State>, - Extract(maybe_bearer_token): Extract, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, Path(info_hash): Path, ) -> Response { let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { return errors::Request::InvalidInfoHashParam.into_response(); }; - let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { - Ok(user_id) => user_id, - Err(error) => return error.into_response(), - }; - - match app_data.torrent_service.delete_torrent(&info_hash, &user_id).await { + match app_data.torrent_service.delete_torrent(&info_hash, maybe_user_id).await { Ok(deleted_torrent_response) => Json(OkResponseData { data: deleted_torrent_response, }) @@ -326,7 +315,7 @@ pub async fn create_random_torrent_handler(State(_app_data): State> /// /// - The text fields do not contain a valid UTF8 string. /// - The torrent file data is not valid because: -/// - The content type is not `application/x-bittorrent`. +/// - The content type is not `application/x-bittorrent` or `application/octet-stream`. /// - The multipart content is invalid. /// - The torrent file pieces key has a length that is not a multiple of 20. /// - The binary data cannot be decoded as a torrent file. @@ -375,7 +364,7 @@ async fn build_add_torrent_request_from_payload(mut payload: Multipart) -> Resul "torrent" => { let content_type = field.content_type().unwrap(); - if content_type != "application/x-bittorrent" { + if content_type != "application/x-bittorrent" && content_type != "application/octet-stream" { return Err(errors::Request::InvalidFileType); } diff --git a/src/web/api/v1/contexts/torrent/mod.rs b/src/web/api/server/v1/contexts/torrent/mod.rs similarity index 99% rename from src/web/api/v1/contexts/torrent/mod.rs rename to src/web/api/server/v1/contexts/torrent/mod.rs index 6b047940..3f915f76 100644 --- a/src/web/api/v1/contexts/torrent/mod.rs +++ b/src/web/api/server/v1/contexts/torrent/mod.rs @@ -271,7 +271,7 @@ //! `tags` | `Option>` | The tag Id list | No | `[1,2,3]` //! //! -//! Refer to the [`UpdateTorrentInfoForm`](crate::web::api::v1::contexts::torrent::forms::UpdateTorrentInfoForm) +//! Refer to the [`UpdateTorrentInfoForm`](crate::web::api::server::v1::contexts::torrent::forms::UpdateTorrentInfoForm) //! struct for more information about the request attributes. //! //! **Example request** diff --git a/src/web/api/v1/contexts/torrent/responses.rs b/src/web/api/server/v1/contexts/torrent/responses.rs similarity index 90% rename from src/web/api/v1/contexts/torrent/responses.rs rename to src/web/api/server/v1/contexts/torrent/responses.rs index 9873b420..103e3a85 100644 --- a/src/web/api/v1/contexts/torrent/responses.rs +++ b/src/web/api/server/v1/contexts/torrent/responses.rs @@ -5,14 +5,14 @@ use serde::{Deserialize, Serialize}; use crate::models::torrent::TorrentId; use crate::services::torrent::AddTorrentResponse; -use crate::web::api::v1::responses::OkResponseData; +use crate::web::api::server::v1::responses::OkResponseData; #[allow(clippy::module_name_repetitions)] #[derive(Serialize, Deserialize, Debug)] pub struct NewTorrentResponseData { pub torrent_id: TorrentId, + pub canonical_info_hash: String, pub info_hash: String, - pub original_info_hash: String, } /// Response after successfully uploading a new torrent. @@ -20,8 +20,8 @@ pub fn new_torrent_response(add_torrent_response: &AddTorrentResponse) -> Json) -> Router { let torrent_info_routes = Router::new() .route("/", get(get_torrent_info_handler).with_state(app_data.clone())) @@ -32,7 +32,7 @@ pub fn router_for_single_resources(app_data: Arc) -> Router { .nest("/:info_hash", torrent_info_routes) } -/// Routes for the [`torrent`](crate::web::api::v1::contexts::torrent) API context for multiple resources. +/// Routes for the [`torrent`](crate::web::api::server::v1::contexts::torrent) API context for multiple resources. pub fn router_for_multiple_resources(app_data: Arc) -> Router { Router::new().route("/", get(get_torrents_handler).with_state(app_data)) } diff --git a/src/web/api/v1/contexts/user/forms.rs b/src/web/api/server/v1/contexts/user/forms.rs similarity index 75% rename from src/web/api/v1/contexts/user/forms.rs rename to src/web/api/server/v1/contexts/user/forms.rs index 6365c4da..28238539 100644 --- a/src/web/api/v1/contexts/user/forms.rs +++ b/src/web/api/server/v1/contexts/user/forms.rs @@ -22,3 +22,12 @@ pub struct LoginForm { pub struct JsonWebToken { pub token: String, // // todo: rename to `encoded` or `value` } + +// Profile + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ChangePasswordForm { + pub current_password: String, + pub password: String, + pub confirm_password: String, +} diff --git a/src/web/api/v1/contexts/user/handlers.rs b/src/web/api/server/v1/contexts/user/handlers.rs similarity index 78% rename from src/web/api/v1/contexts/user/handlers.rs rename to src/web/api/server/v1/contexts/user/handlers.rs index 170bd073..e22ed325 100644 --- a/src/web/api/v1/contexts/user/handlers.rs +++ b/src/web/api/server/v1/contexts/user/handlers.rs @@ -1,4 +1,4 @@ -//! API handlers for the the [`user`](crate::web::api::v1::contexts::user) API +//! API handlers for the the [`user`](crate::web::api::server::v1::contexts::user) API //! context. use std::sync::Arc; @@ -7,11 +7,11 @@ use axum::response::{IntoResponse, Response}; use axum::Json; use serde::Deserialize; -use super::forms::{JsonWebToken, LoginForm, RegistrationForm}; +use super::forms::{ChangePasswordForm, JsonWebToken, LoginForm, RegistrationForm}; use super::responses::{self}; use crate::common::AppData; -use crate::web::api::v1::extractors::bearer_token::Extract; -use crate::web::api::v1::responses::OkResponseData; +use crate::web::api::server::v1::extractors::optional_user_id::ExtractOptionalLoggedInUser; +use crate::web::api::server::v1::responses::OkResponseData; // Registration @@ -123,6 +123,33 @@ pub async fn renew_token_handler( } } +/// It changes the user's password. +/// +/// # Errors +/// +/// It returns an error if: +/// +/// - The user account is not found. +#[allow(clippy::unused_async)] +#[allow(clippy::missing_panics_doc)] +pub async fn change_password_handler( + State(app_data): State>, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, + extract::Json(change_password_form): extract::Json, +) -> Response { + match app_data + .profile_service + .change_password(maybe_user_id, &change_password_form) + .await + { + Ok(()) => Json(OkResponseData { + data: format!("Password changed for user with ID: {}", maybe_user_id.unwrap()), + }) + .into_response(), + Err(error) => error.into_response(), + } +} + /// It bans a user from the index. /// /// # Errors @@ -135,16 +162,11 @@ pub async fn renew_token_handler( pub async fn ban_handler( State(app_data): State>, Path(to_be_banned_username): Path, - Extract(maybe_bearer_token): Extract, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, ) -> Response { // todo: add reason and `date_expiry` parameters to request - let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { - Ok(user_id) => user_id, - Err(error) => return error.into_response(), - }; - - match app_data.ban_service.ban_user(&to_be_banned_username.0, &user_id).await { + match app_data.ban_service.ban_user(&to_be_banned_username.0, maybe_user_id).await { Ok(()) => Json(OkResponseData { data: format!("Banned user: {}", to_be_banned_username.0), }) diff --git a/src/web/api/v1/contexts/user/mod.rs b/src/web/api/server/v1/contexts/user/mod.rs similarity index 92% rename from src/web/api/v1/contexts/user/mod.rs rename to src/web/api/server/v1/contexts/user/mod.rs index 4f4682e0..1c146121 100644 --- a/src/web/api/v1/contexts/user/mod.rs +++ b/src/web/api/server/v1/contexts/user/mod.rs @@ -6,7 +6,7 @@ //! - User authentication //! - User ban //! -//! For more information about the API authentication, refer to the [`auth`](crate::web::api::v1::auth) +//! For more information about the API authentication, refer to the [`auth`](crate::web::api::server::v1::auth) //! module. //! //! # Endpoints @@ -45,12 +45,10 @@ //! //! ```toml //! [auth] -//! email_on_signup = "Optional" -//! min_password_length = 6 -//! max_password_length = 64 +//! user_claim_token_pepper = "MaxVerstappenWC2021" //! ``` //! -//! Refer to the [`RegistrationForm`](crate::web::api::v1::contexts::user::forms::RegistrationForm) +//! Refer to the [`RegistrationForm`](crate::web::api::server::v1::contexts::user::forms::RegistrationForm) //! struct for more information about the registration form. //! //! **Example request** @@ -63,7 +61,7 @@ //! http://127.0.0.1:3001/v1/user/register //! ``` //! -//! For more information about the registration process, refer to the [`auth`](crate::web::api::v1::auth) +//! For more information about the registration process, refer to the [`auth`](crate::web::api::server::v1::auth) //! module. //! //! # Email verification @@ -97,7 +95,7 @@ //! `login` | `String` | The password | Yes | `indexadmin` //! `password` | `String` | The password | Yes | `BenoitMandelbrot1924` //! -//! Refer to the [`LoginForm`](crate::web::api::v1::contexts::user::forms::LoginForm) +//! Refer to the [`LoginForm`](crate::web::api::server::v1::contexts::user::forms::LoginForm) //! struct for more information about the login form. //! //! **Example request** @@ -110,7 +108,7 @@ //! http://127.0.0.1:3001/v1/user/login //! ``` //! -//! For more information about the login process, refer to the [`auth`](crate::web::api::v1::auth) +//! For more information about the login process, refer to the [`auth`](crate::web::api::server::v1::auth) //! module. //! //! # Token verification @@ -125,7 +123,7 @@ //! ---|---|---|---|--- //! `token` | `String` | The token you want to verify | Yes | `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI` //! -//! Refer to the [`JsonWebToken`](crate::web::api::v1::contexts::user::forms::JsonWebToken) +//! Refer to the [`JsonWebToken`](crate::web::api::server::v1::contexts::user::forms::JsonWebToken) //! struct for more information about the token. //! //! **Example request** @@ -171,7 +169,7 @@ //! ---|---|---|---|--- //! `token` | `String` | The current valid token | Yes | `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI` //! -//! Refer to the [`JsonWebToken`](crate::web::api::v1::contexts::user::forms::JsonWebToken) +//! Refer to the [`JsonWebToken`](crate::web::api::server::v1::contexts::user::forms::JsonWebToken) //! struct for more information about the token. //! //! **Example request** diff --git a/src/web/api/v1/contexts/user/responses.rs b/src/web/api/server/v1/contexts/user/responses.rs similarity index 95% rename from src/web/api/v1/contexts/user/responses.rs rename to src/web/api/server/v1/contexts/user/responses.rs index 17a06bdf..fde7c78b 100644 --- a/src/web/api/v1/contexts/user/responses.rs +++ b/src/web/api/server/v1/contexts/user/responses.rs @@ -2,7 +2,7 @@ use axum::Json; use serde::{Deserialize, Serialize}; use crate::models::user::{UserCompact, UserId}; -use crate::web::api::v1::responses::OkResponseData; +use crate::web::api::server::v1::responses::OkResponseData; // Registration diff --git a/src/web/api/v1/contexts/user/routes.rs b/src/web/api/server/v1/contexts/user/routes.rs similarity index 68% rename from src/web/api/v1/contexts/user/routes.rs rename to src/web/api/server/v1/contexts/user/routes.rs index b2a21624..9daabc18 100644 --- a/src/web/api/v1/contexts/user/routes.rs +++ b/src/web/api/server/v1/contexts/user/routes.rs @@ -1,17 +1,18 @@ -//! API routes for the [`user`](crate::web::api::v1::contexts::user) API context. +//! API routes for the [`user`](crate::web::api::server::v1::contexts::user) API context. //! -//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::user). +//! Refer to the [API endpoint documentation](crate::web::api::server::v1::contexts::user). use std::sync::Arc; use axum::routing::{delete, get, post}; use axum::Router; use super::handlers::{ - ban_handler, email_verification_handler, login_handler, registration_handler, renew_token_handler, verify_token_handler, + ban_handler, change_password_handler, email_verification_handler, login_handler, registration_handler, renew_token_handler, + verify_token_handler, }; use crate::common::AppData; -/// Routes for the [`user`](crate::web::api::v1::contexts::user) API context. +/// Routes for the [`user`](crate::web::api::server::v1::contexts::user) API context. pub fn router(app_data: Arc) -> Router { Router::new() // Registration @@ -28,6 +29,11 @@ pub fn router(app_data: Arc) -> Router { .route("/login", post(login_handler).with_state(app_data.clone())) .route("/token/verify", post(verify_token_handler).with_state(app_data.clone())) .route("/token/renew", post(renew_token_handler).with_state(app_data.clone())) + // Profile + .route( + "/:user/change-password", + post(change_password_handler).with_state(app_data.clone()), + ) // User ban // code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user. .route("/ban/:user", delete(ban_handler).with_state(app_data)) diff --git a/src/web/api/v1/extractors/bearer_token.rs b/src/web/api/server/v1/extractors/bearer_token.rs similarity index 93% rename from src/web/api/v1/extractors/bearer_token.rs rename to src/web/api/server/v1/extractors/bearer_token.rs index 1c9b5be9..7a166503 100644 --- a/src/web/api/v1/extractors/bearer_token.rs +++ b/src/web/api/server/v1/extractors/bearer_token.rs @@ -4,7 +4,7 @@ use axum::http::request::Parts; use axum::response::Response; use serde::Deserialize; -use crate::web::api::v1::auth::parse_token; +use crate::web::api::server::v1::auth::parse_token; pub struct Extract(pub Option); diff --git a/src/web/api/server/v1/extractors/mod.rs b/src/web/api/server/v1/extractors/mod.rs new file mode 100644 index 00000000..acf2d689 --- /dev/null +++ b/src/web/api/server/v1/extractors/mod.rs @@ -0,0 +1,3 @@ +pub mod bearer_token; +pub mod optional_user_id; +pub mod user_id; diff --git a/src/web/api/server/v1/extractors/optional_user_id.rs b/src/web/api/server/v1/extractors/optional_user_id.rs new file mode 100644 index 00000000..cdef65af --- /dev/null +++ b/src/web/api/server/v1/extractors/optional_user_id.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use axum::extract::{FromRef, FromRequestParts}; +use axum::http::request::Parts; +use axum::response::Response; + +use crate::common::AppData; +use crate::models::user::UserId; +use crate::web::api::server::v1::extractors::bearer_token; + +pub struct ExtractOptionalLoggedInUser(pub Option); + +#[async_trait] +impl FromRequestParts for ExtractOptionalLoggedInUser +where + Arc: FromRef, + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + /* let maybe_bearer_token = match bearer_token::Extract::from_request_parts(parts, state).await { + Ok(maybe_bearer_token) => maybe_bearer_token.0, + Err(_) => return Err(ServiceError::TokenNotFound.into_response()), + }; */ + + let bearer_token = match bearer_token::Extract::from_request_parts(parts, state).await { + Ok(bearer_token) => bearer_token.0, + Err(_) => None, + }; + + //Extracts the app state + let app_data = Arc::from_ref(state); + + match app_data.auth.get_user_id_from_bearer_token(&bearer_token).await { + Ok(user_id) => Ok(ExtractOptionalLoggedInUser(Some(user_id))), + Err(_) => Ok(ExtractOptionalLoggedInUser(None)), + } + } +} diff --git a/src/web/api/server/v1/extractors/user_id.rs b/src/web/api/server/v1/extractors/user_id.rs new file mode 100644 index 00000000..4ea81900 --- /dev/null +++ b/src/web/api/server/v1/extractors/user_id.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use axum::extract::{FromRef, FromRequestParts}; +use axum::http::request::Parts; +use axum::response::{IntoResponse, Response}; + +use super::bearer_token; +use crate::common::AppData; +use crate::errors::ServiceError; +use crate::models::user::UserId; + +pub struct ExtractLoggedInUser(pub UserId); + +#[async_trait] +impl FromRequestParts for ExtractLoggedInUser +where + Arc: FromRef, + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let maybe_bearer_token = match bearer_token::Extract::from_request_parts(parts, state).await { + Ok(maybe_bearer_token) => maybe_bearer_token.0, + Err(_) => return Err(ServiceError::TokenNotFound.into_response()), + }; + + //Extracts the app state + let app_data = Arc::from_ref(state); + + match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { + Ok(user_id) => Ok(ExtractLoggedInUser(user_id)), + Err(_) => Err(ServiceError::LoggedInUserNotFound.into_response()), + } + } +} diff --git a/src/web/api/v1/mod.rs b/src/web/api/server/v1/mod.rs similarity index 100% rename from src/web/api/v1/mod.rs rename to src/web/api/server/v1/mod.rs diff --git a/src/web/api/v1/responses.rs b/src/web/api/server/v1/responses.rs similarity index 100% rename from src/web/api/v1/responses.rs rename to src/web/api/server/v1/responses.rs diff --git a/src/web/api/server/v1/routes.rs b/src/web/api/server/v1/routes.rs new file mode 100644 index 00000000..1826427d --- /dev/null +++ b/src/web/api/server/v1/routes.rs @@ -0,0 +1,101 @@ +//! Route initialization for the v1 API. +use std::env; +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::DefaultBodyLimit; +use axum::http::HeaderName; +use axum::response::{Redirect, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use hyper::Request; +use serde_json::{json, Value}; +use tower_http::compression::CompressionLayer; +use tower_http::cors::CorsLayer; +use tower_http::propagate_header::PropagateHeaderLayer; +use tower_http::request_id::{MakeRequestUuid, SetRequestIdLayer}; +use tower_http::trace::{DefaultMakeSpan, TraceLayer}; +use tracing::{Level, Span}; + +use super::contexts::{about, category, proxy, settings, tag, torrent, user}; +use crate::bootstrap::config::ENV_VAR_CORS_PERMISSIVE; +use crate::common::AppData; + +pub const API_VERSION_URL_PREFIX: &str = "v1"; + +/// Add all API routes to the router. +#[allow(clippy::needless_pass_by_value)] +pub fn router(app_data: Arc) -> Router { + // code-review: should we use plural for the resource prefix: `users`, `categories`, `tags`? + // Some endpoint are using plural (for instance, `get_categories`) and some singular. + // See: https://stackoverflow.com/questions/6845772/should-i-use-singular-or-plural-name-convention-for-rest-resources + + let v1_api_routes = Router::new() + .route("/", get(redirect_to_about)) + .nest("/user", user::routes::router(app_data.clone())) + .nest("/about", about::routes::router(app_data.clone())) + .nest("/category", category::routes::router(app_data.clone())) + .nest("/tag", tag::routes::router_for_single_resources(app_data.clone())) + .nest("/tags", tag::routes::router_for_multiple_resources(app_data.clone())) + .nest("/settings", settings::routes::router(app_data.clone())) + .nest("/torrent", torrent::routes::router_for_single_resources(app_data.clone())) + .nest("/torrents", torrent::routes::router_for_multiple_resources(app_data.clone())) + .nest("/proxy", proxy::routes::router(app_data.clone())); + + let router = Router::new() + .route("/", get(redirect_to_about)) + .route("/health_check", get(health_check_handler).with_state(app_data.clone())) + .nest(&format!("/{API_VERSION_URL_PREFIX}"), v1_api_routes); + + let router = if env::var(ENV_VAR_CORS_PERMISSIVE).is_ok() { + router.layer(CorsLayer::permissive()) + } else { + router + }; + + router + .layer(DefaultBodyLimit::max(10_485_760)) + .layer(CompressionLayer::new()) + .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid)) + .layer(PropagateHeaderLayer::new(HeaderName::from_static("x-request-id"))) + .layer( + TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::new().level(Level::INFO)) + .on_request(|request: &Request, _span: &Span| { + let method = request.method().to_string(); + let uri = request.uri().to_string(); + let request_id = request + .headers() + .get("x-request-id") + .map(|v| v.to_str().unwrap_or_default()) + .unwrap_or_default(); + + tracing::span!( + target: "API", + tracing::Level::INFO, "request", method = %method, uri = %uri, request_id = %request_id); + }) + .on_response(|response: &Response, latency: Duration, _span: &Span| { + let status_code = response.status(); + let request_id = response + .headers() + .get("x-request-id") + .map(|v| v.to_str().unwrap_or_default()) + .unwrap_or_default(); + let latency_ms = latency.as_millis(); + + tracing::span!( + target: "API", + tracing::Level::INFO, "response", latency = %latency_ms, status = %status_code, request_id = %request_id); + }), + ) + .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid)) +} + +/// Endpoint for container health check. +async fn health_check_handler() -> Json { + Json(json!({ "status": "Ok" })) +} + +async fn redirect_to_about() -> Redirect { + Redirect::permanent(&format!("/{API_VERSION_URL_PREFIX}/about")) +} diff --git a/src/web/api/v1/contexts/about/handlers.rs b/src/web/api/v1/contexts/about/handlers.rs deleted file mode 100644 index 07d5977b..00000000 --- a/src/web/api/v1/contexts/about/handlers.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! API handlers for the the [`about`](crate::web::api::v1::contexts::about) API -//! context. -use std::sync::Arc; - -use axum::extract::State; -use axum::http::{header, StatusCode}; -use axum::response::{IntoResponse, Response}; - -use crate::common::AppData; -use crate::services::about; - -#[allow(clippy::unused_async)] -pub async fn about_page_handler(State(_app_data): State>) -> Response { - ( - StatusCode::OK, - [(header::CONTENT_TYPE, "text/html; charset=utf-8")], - about::page(), - ) - .into_response() -} - -#[allow(clippy::unused_async)] -pub async fn license_page_handler(State(_app_data): State>) -> Response { - ( - StatusCode::OK, - [(header::CONTENT_TYPE, "text/html; charset=utf-8")], - about::license_page(), - ) - .into_response() -} diff --git a/src/web/api/v1/contexts/category/responses.rs b/src/web/api/v1/contexts/category/responses.rs deleted file mode 100644 index b1e20d19..00000000 --- a/src/web/api/v1/contexts/category/responses.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! API responses for the the [`category`](crate::web::api::v1::contexts::category) API -//! context. -use axum::Json; - -use crate::web::api::v1::responses::OkResponseData; - -/// Response after successfully creating a new category. -pub fn added_category(category_name: &str) -> Json> { - Json(OkResponseData { - data: category_name.to_string(), - }) -} - -/// Response after successfully deleting a new category. -pub fn deleted_category(category_name: &str) -> Json> { - Json(OkResponseData { - data: category_name.to_string(), - }) -} diff --git a/src/web/api/v1/contexts/mod.rs b/src/web/api/v1/contexts/mod.rs deleted file mode 100644 index f6ef4069..00000000 --- a/src/web/api/v1/contexts/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! The API is organized in the following contexts: -//! -//! Context | Description | Version -//! ---|---|--- -//! `About` | Metadata about the API | [`v1`](crate::web::api::v1::contexts::about) -//! `Category` | Torrent categories | [`v1`](crate::web::api::v1::contexts::category) -//! `Proxy` | Image proxy cache | [`v1`](crate::web::api::v1::contexts::proxy) -//! `Settings` | Index settings | [`v1`](crate::web::api::v1::contexts::settings) -//! `Tag` | Torrent tags | [`v1`](crate::web::api::v1::contexts::tag) -//! `Torrent` | Indexed torrents | [`v1`](crate::web::api::v1::contexts::torrent) -//! `User` | Users | [`v1`](crate::web::api::v1::contexts::user) -//! -pub mod about; -pub mod category; -pub mod proxy; -pub mod settings; -pub mod tag; -pub mod torrent; -pub mod user; diff --git a/src/web/api/v1/contexts/settings/handlers.rs b/src/web/api/v1/contexts/settings/handlers.rs deleted file mode 100644 index f4d94541..00000000 --- a/src/web/api/v1/contexts/settings/handlers.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! API handlers for the the [`category`](crate::web::api::v1::contexts::category) API -//! context. -use std::sync::Arc; - -use axum::extract::State; -use axum::response::{IntoResponse, Json, Response}; - -use crate::common::AppData; -use crate::web::api::v1::extractors::bearer_token::Extract; -use crate::web::api::v1::responses; - -/// Get all settings. -/// -/// # Errors -/// -/// This function will return an error if the user does not have permission to -/// view all the settings. -#[allow(clippy::unused_async)] -pub async fn get_all_handler(State(app_data): State>, Extract(maybe_bearer_token): Extract) -> Response { - let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { - Ok(user_id) => user_id, - Err(error) => return error.into_response(), - }; - - let all_settings = match app_data.settings_service.get_all(&user_id).await { - Ok(all_settings) => all_settings, - Err(error) => return error.into_response(), - }; - - Json(responses::OkResponseData { data: all_settings }).into_response() -} - -/// Get public Settings. -#[allow(clippy::unused_async)] -pub async fn get_public_handler(State(app_data): State>) -> Response { - let public_settings = app_data.settings_service.get_public().await; - - Json(responses::OkResponseData { data: public_settings }).into_response() -} - -/// Get website name. -#[allow(clippy::unused_async)] -pub async fn get_site_name_handler(State(app_data): State>) -> Response { - let site_name = app_data.settings_service.get_site_name().await; - - Json(responses::OkResponseData { data: site_name }).into_response() -} diff --git a/src/web/api/v1/contexts/settings/mod.rs b/src/web/api/v1/contexts/settings/mod.rs deleted file mode 100644 index 5bb35151..00000000 --- a/src/web/api/v1/contexts/settings/mod.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! API context: `settings`. -//! -//! This API context is responsible for handling the application settings. -//! -//! # Endpoints -//! -//! - [Get all settings](#get-all-settings) -//! - [Update all settings](#update-all-settings) -//! - [Get site name](#get-site-name) -//! - [Get public settings](#get-public-settings) -//! -//! # Get all settings -//! -//! `GET /v1/settings` -//! -//! Returns all settings. -//! -//! **Example request** -//! -//! ```bash -//! curl \ -//! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ -//! --request GET \ -//! "http://127.0.0.1:3001/v1/settings" -//! ``` -//! -//! **Example response** `200` -//! -//! ```json -//! { -//! "data": { -//! "website": { -//! "name": "Torrust" -//! }, -//! "tracker": { -//! "url": "udp://localhost:6969", -//! "mode": "Public", -//! "api_url": "http://localhost:1212", -//! "token": "MyAccessToken", -//! "token_valid_seconds": 7257600 -//! }, -//! "net": { -//! "port": 3001, -//! "base_url": null -//! }, -//! "auth": { -//! "email_on_signup": "Optional", -//! "min_password_length": 6, -//! "max_password_length": 64, -//! "secret_key": "MaxVerstappenWC2021" -//! }, -//! "database": { -//! "connect_url": "sqlite://./storage/database/data.db?mode=rwc" -//! }, -//! "mail": { -//! "email_verification_enabled": false, -//! "from": "example@email.com", -//! "reply_to": "noreply@email.com", -//! "username": "", -//! "password": "", -//! "server": "", -//! "port": 25 -//! }, -//! "image_cache": { -//! "max_request_timeout_ms": 1000, -//! "capacity": 128000000, -//! "entry_size_limit": 4000000, -//! "user_quota_period_seconds": 3600, -//! "user_quota_bytes": 64000000 -//! }, -//! "api": { -//! "default_torrent_page_size": 10, -//! "max_torrent_page_size": 30 -//! }, -//! "tracker_statistics_importer": { -//! "torrent_info_update_interval": 3600 -//! } -//! } -//! } -//! ``` -//! **Resource** -//! -//! Refer to the [`TorrustIndex`](crate::config::TorrustIndex) -//! struct for more information about the response attributes. -//! -//! # Update all settings -//! -//! **NOTICE**: This endpoint to update the settings does not work when you use -//! environment variables to configure the application. You need to use a -//! configuration file instead. Because settings are persisted in that file. -//! Refer to the issue [#144](https://github.com/torrust/torrust-index/issues/144) -//! for more information. -//! -//! `POST /v1/settings` -//! -//! **Example request** -//! -//! ```bash -//! curl \ -//! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ -//! --request POST \ -//! --data '{"website":{"name":"Torrust"},"tracker":{"url":"udp://localhost:6969","mode":"Public","api_url":"http://localhost:1212","token":"MyAccessToken","token_valid_seconds":7257600},"net":{"port":3001,"base_url":null},"auth":{"email_on_signup":"Optional","min_password_length":6,"max_password_length":64,"secret_key":"MaxVerstappenWC2021"},"database":{"connect_url":"sqlite://./storage/database/data.db?mode=rwc"},"mail":{"email_verification_enabled":false,"from":"example@email.com","reply_to":"noreply@email.com","username":"","password":"","server":"","port":25},"image_cache":{"max_request_timeout_ms":1000,"capacity":128000000,"entry_size_limit":4000000,"user_quota_period_seconds":3600,"user_quota_bytes":64000000},"api":{"default_torrent_page_size":10,"max_torrent_page_size":30},"tracker_statistics_importer":{"torrent_info_update_interval":3600}}' \ -//! "http://127.0.0.1:3001/v1/settings" -//! ``` -//! -//! The response contains the settings that were updated. -//! -//! **Resource** -//! -//! Refer to the [`TorrustIndex`](crate::config::TorrustIndex) -//! struct for more information about the response attributes. -//! -//! # Get site name -//! -//! `GET /v1/settings/name` -//! -//! It returns the name of the site. -//! -//! **Example request** -//! -//! ```bash -//! curl \ -//! --header "Content-Type: application/json" \ -//! --request GET \ -//! "http://127.0.0.1:3001/v1/settings/name" -//! ``` -//! -//! **Example response** `200` -//! -//! ```json -//! { -//! "data":"Torrust" -//! } -//! ``` -//! -//! # Get public settings -//! -//! `GET /v1/settings/public` -//! -//! It returns all the public settings. -//! -//! **Example request** -//! -//! ```bash -//! curl \ -//! --header "Content-Type: application/json" \ -//! --request GET \ -//! "http://127.0.0.1:3001/v1/settings/public" -//! ``` -//! -//! **Example response** `200` -//! -//! ```json -//! { -//! "data": { -//! "website_name": "Torrust", -//! "tracker_url": "udp://localhost:6969", -//! "tracker_mode": "Public", -//! "email_on_signup": "Optional" -//! } -//! } -//! ``` -//! -//! **Resource** -//! -//! Refer to the [`ConfigurationPublic`](crate::config::ConfigurationPublic) -//! struct for more information about the response attributes. -pub mod handlers; -pub mod routes; diff --git a/src/web/api/v1/extractors/mod.rs b/src/web/api/v1/extractors/mod.rs deleted file mode 100644 index 36d737ca..00000000 --- a/src/web/api/v1/extractors/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod bearer_token; diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs deleted file mode 100644 index 44098f4c..00000000 --- a/src/web/api/v1/routes.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Route initialization for the v1 API. -use std::env; -use std::sync::Arc; - -use axum::extract::DefaultBodyLimit; -use axum::routing::get; -use axum::Router; -use tower_http::compression::CompressionLayer; -use tower_http::cors::CorsLayer; - -use super::contexts::about::handlers::about_page_handler; -use super::contexts::{about, category, proxy, settings, tag, torrent, user}; -use crate::bootstrap::config::ENV_VAR_CORS_PERMISSIVE; -use crate::common::AppData; - -pub const API_VERSION_URL_PREFIX: &str = "v1"; - -/// Add all API routes to the router. -#[allow(clippy::needless_pass_by_value)] -pub fn router(app_data: Arc) -> Router { - // code-review: should we use plural for the resource prefix: `users`, `categories`, `tags`? - // See: https://stackoverflow.com/questions/6845772/should-i-use-singular-or-plural-name-convention-for-rest-resources - - let v1_api_routes = Router::new() - .route("/", get(about_page_handler).with_state(app_data.clone())) - .nest("/user", user::routes::router(app_data.clone())) - .nest("/about", about::routes::router(app_data.clone())) - .nest("/category", category::routes::router(app_data.clone())) - .nest("/tag", tag::routes::router_for_single_resources(app_data.clone())) - .nest("/tags", tag::routes::router_for_multiple_resources(app_data.clone())) - .nest("/settings", settings::routes::router(app_data.clone())) - .nest("/torrent", torrent::routes::router_for_single_resources(app_data.clone())) - .nest("/torrents", torrent::routes::router_for_multiple_resources(app_data.clone())) - .nest("/proxy", proxy::routes::router(app_data.clone())); - - let router = Router::new() - .route("/", get(about_page_handler).with_state(app_data)) - .nest(&format!("/{API_VERSION_URL_PREFIX}"), v1_api_routes); - - let router = if env::var(ENV_VAR_CORS_PERMISSIVE).is_ok() { - router.layer(CorsLayer::permissive()) - } else { - router - }; - - router.layer(DefaultBodyLimit::max(10_485_760)).layer(CompressionLayer::new()) -} diff --git a/src/web/mod.rs b/src/web/mod.rs index 9007e88f..d51f4b78 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -2,5 +2,5 @@ //! //! Currently, the API has only one version: `v1`. //! -//! Refer to the [`v1`](crate::web::api::v1) module for more information. +//! Refer to the [`v1`](crate::web::api::server::v1) module for more information. pub mod api; diff --git a/tests/common/asserts.rs b/tests/common/asserts.rs index 50a60e26..0760d1cd 100644 --- a/tests/common/asserts.rs +++ b/tests/common/asserts.rs @@ -1,6 +1,6 @@ // Text responses -use torrust_index::web::api::v1::responses::ErrorResponseData; +use torrust_index::web::api::server::v1::responses::ErrorResponseData; use super::responses::TextResponse; diff --git a/tests/common/client.rs b/tests/common/client.rs index 97216bfa..c9c22d2e 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -1,4 +1,6 @@ -use reqwest::multipart; +use std::time::Duration; + +use reqwest::{multipart, Error}; use serde::Serialize; use super::connection_info::ConnectionInfo; @@ -6,7 +8,9 @@ use super::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; use super::contexts::tag::forms::{AddTagForm, DeleteTagForm}; use super::contexts::torrent::forms::UpdateTorrentFrom; use super::contexts::torrent::requests::InfoHash; -use super::contexts::user::forms::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; +use super::contexts::user::forms::{ + ChangePasswordForm, LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username, +}; use super::http::{Query, ReqwestQuery}; use super::responses::{self, BinaryResponse, TextResponse}; @@ -19,15 +23,26 @@ impl Client { // todo: forms in POST requests can be passed by reference. fn base_path() -> String { - "/v1".to_string() + "v1".to_string() + } + + /// Remove last '/' char in the address if present. + /// + /// For example: to . + fn base_url(bind_address: &str) -> String { + let mut url = bind_address.to_owned(); + if url.ends_with('/') { + url.pop(); + } + url } pub fn unauthenticated(bind_address: &str) -> Self { - Self::new(ConnectionInfo::anonymous(bind_address, &Self::base_path())) + Self::new(ConnectionInfo::anonymous(&Self::base_url(bind_address), &Self::base_path())) } pub fn authenticated(bind_address: &str, token: &str) -> Self { - Self::new(ConnectionInfo::new(bind_address, &Self::base_path(), token)) + Self::new(ConnectionInfo::new(&Self::base_url(bind_address), &Self::base_path(), token)) } pub fn new(connection_info: ConnectionInfo) -> Self { @@ -37,9 +52,12 @@ impl Client { } /// It checks if the server is running. - pub async fn server_is_running(&self) -> bool { + pub async fn server_is_running(&self) -> Result<(), Error> { let response = self.http_client.inner_get("").await; - response.is_ok() + match response { + Ok(_) => Ok(()), + Err(err) => Err(err), + } } // Context: about @@ -142,6 +160,12 @@ impl Client { self.http_client.post("/user/login", ®istration_form).await } + pub async fn change_password(&self, username: Username, change_password_form: ChangePasswordForm) -> TextResponse { + self.http_client + .post(&format!("/user/{}/change-password", &username.value), &change_password_form) + .await + } + pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> TextResponse { self.http_client.post("/user/token/verify", &token_verification_form).await } @@ -158,16 +182,23 @@ impl Client { /// Generic HTTP Client struct Http { connection_info: ConnectionInfo, + /// The timeout is applied from when the request starts connecting until the + /// response body has finished. + timeout: Duration, } impl Http { pub fn new(connection_info: ConnectionInfo) -> Self { - Self { connection_info } + Self { + connection_info, + timeout: Duration::from_secs(5), + } } pub async fn get(&self, path: &str, params: Query) -> TextResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .get(self.base_url(path).clone()) @@ -177,6 +208,7 @@ impl Http { .await .unwrap(), None => reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .get(self.base_url(path).clone()) @@ -191,6 +223,7 @@ impl Http { pub async fn get_binary(&self, path: &str, params: Query) -> BinaryResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .get(self.base_url(path).clone()) @@ -200,6 +233,7 @@ impl Http { .await .unwrap(), None => reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .get(self.base_url(path).clone()) @@ -217,6 +251,7 @@ impl Http { pub async fn inner_get(&self, path: &str) -> Result { reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .get(self.base_url(path).clone()) @@ -246,6 +281,7 @@ impl Http { pub async fn post_multipart(&self, path: &str, form: multipart::Form) -> TextResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .post(self.base_url(path).clone()) @@ -255,6 +291,7 @@ impl Http { .await .expect("failed to send multipart request with token"), None => reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .post(self.base_url(path).clone()) @@ -323,7 +360,7 @@ impl Http { fn base_url(&self, path: &str) -> String { format!( - "http://{}{}{path}", + "http://{}/{}{path}", // DevSkim: ignore DS137138 &self.connection_info.bind_address, &self.connection_info.base_path ) } diff --git a/tests/common/contexts/settings/mod.rs b/tests/common/contexts/settings/mod.rs index 5ba7a0db..3519d40a 100644 --- a/tests/common/contexts/settings/mod.rs +++ b/tests/common/contexts/settings/mod.rs @@ -1,14 +1,20 @@ pub mod responses; +use std::net::SocketAddr; + use serde::{Deserialize, Serialize}; use torrust_index::config::{ - Api as DomainApi, Auth as DomainAuth, Database as DomainDatabase, ImageCache as DomainImageCache, Mail as DomainMail, - Network as DomainNetwork, TorrustIndex as DomainSettings, Tracker as DomainTracker, - TrackerStatisticsImporter as DomainTrackerStatisticsImporter, Website as DomainWebsite, + Api as DomainApi, ApiToken, Auth as DomainAuth, Credentials as DomainCredentials, Database as DomainDatabase, + Email as DomainEmail, ImageCache as DomainImageCache, Logging as DomainLogging, Mail as DomainMail, Network as DomainNetwork, + PasswordConstraints as DomainPasswordConstraints, Registration as DomainRegistration, Settings as DomainSettings, + Smtp as DomainSmtp, Tracker as DomainTracker, TrackerStatisticsImporter as DomainTrackerStatisticsImporter, + Website as DomainWebsite, }; +use url::Url; #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct Settings { + pub logging: Logging, pub website: Website, pub tracker: Tracker, pub net: Network, @@ -17,9 +23,15 @@ pub struct Settings { pub mail: Mail, pub image_cache: ImageCache, pub api: Api, + pub registration: Option, pub tracker_statistics_importer: TrackerStatisticsImporter, } +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Logging { + pub threshold: String, +} + #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct Website { pub name: String, @@ -27,25 +39,30 @@ pub struct Website { #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct Tracker { - pub url: String, - pub mode: String, - pub api_url: String, - pub token: String, + pub url: Url, + pub listed: bool, + pub private: bool, + pub api_url: Url, + pub token: ApiToken, pub token_valid_seconds: u64, } #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct Network { - pub port: u16, pub base_url: Option, + pub bind_address: SocketAddr, } #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct Auth { - pub email_on_signup: String, + pub user_claim_token_pepper: String, + pub password_constraints: PasswordConstraints, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct PasswordConstraints { pub min_password_length: usize, pub max_password_length: usize, - pub secret_key: String, } #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] @@ -55,13 +72,22 @@ pub struct Database { #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct Mail { - pub email_verification_enabled: bool, pub from: String, pub reply_to: String, - pub username: String, - pub password: String, + pub smtp: Smtp, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Smtp { pub server: String, pub port: u16, + pub credentials: Credentials, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Credentials { + pub username: String, + pub password: String, } #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] @@ -79,14 +105,27 @@ pub struct Api { pub max_torrent_page_size: u8, } +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Registration { + pub email: Option, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Email { + pub required: bool, + pub verification_required: bool, +} + #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct TrackerStatisticsImporter { pub torrent_info_update_interval: u64, + port: u16, } impl From for Settings { fn from(settings: DomainSettings) -> Self { Settings { + logging: Logging::from(settings.logging), website: Website::from(settings.website), tracker: Tracker::from(settings.tracker), net: Network::from(settings.net), @@ -95,11 +134,20 @@ impl From for Settings { mail: Mail::from(settings.mail), image_cache: ImageCache::from(settings.image_cache), api: Api::from(settings.api), + registration: settings.registration.map(Registration::from), tracker_statistics_importer: TrackerStatisticsImporter::from(settings.tracker_statistics_importer), } } } +impl From for Logging { + fn from(logging: DomainLogging) -> Self { + Self { + threshold: logging.threshold.to_string(), + } + } +} + impl From for Website { fn from(website: DomainWebsite) -> Self { Self { name: website.name } @@ -110,7 +158,8 @@ impl From for Tracker { fn from(tracker: DomainTracker) -> Self { Self { url: tracker.url, - mode: format!("{:?}", tracker.mode), + listed: tracker.listed, + private: tracker.private, api_url: tracker.api_url, token: tracker.token, token_valid_seconds: tracker.token_valid_seconds, @@ -121,8 +170,8 @@ impl From for Tracker { impl From for Network { fn from(net: DomainNetwork) -> Self { Self { - port: net.port, - base_url: net.base_url, + bind_address: net.bind_address, + base_url: net.base_url.as_ref().map(std::string::ToString::to_string), } } } @@ -130,10 +179,17 @@ impl From for Network { impl From for Auth { fn from(auth: DomainAuth) -> Self { Self { - email_on_signup: format!("{:?}", auth.email_on_signup), - min_password_length: auth.min_password_length, - max_password_length: auth.max_password_length, - secret_key: auth.secret_key, + user_claim_token_pepper: auth.user_claim_token_pepper.to_string(), + password_constraints: auth.password_constraints.into(), + } + } +} + +impl From for PasswordConstraints { + fn from(password_constraints: DomainPasswordConstraints) -> Self { + Self { + min_password_length: password_constraints.min_password_length, + max_password_length: password_constraints.max_password_length, } } } @@ -141,7 +197,7 @@ impl From for Auth { impl From for Database { fn from(database: DomainDatabase) -> Self { Self { - connect_url: database.connect_url, + connect_url: database.connect_url.to_string(), } } } @@ -149,13 +205,28 @@ impl From for Database { impl From for Mail { fn from(mail: DomainMail) -> Self { Self { - email_verification_enabled: mail.email_verification_enabled, - from: mail.from, - reply_to: mail.reply_to, - username: mail.username, - password: mail.password, - server: mail.server, - port: mail.port, + from: mail.from.to_string(), + reply_to: mail.reply_to.to_string(), + smtp: mail.smtp.into(), + } + } +} + +impl From for Smtp { + fn from(smtp: DomainSmtp) -> Self { + Self { + server: smtp.server, + port: smtp.port, + credentials: smtp.credentials.into(), + } + } +} + +impl From for Credentials { + fn from(credentials: DomainCredentials) -> Self { + Self { + username: credentials.username, + password: credentials.password, } } } @@ -181,10 +252,28 @@ impl From for Api { } } +impl From for Registration { + fn from(registration: DomainRegistration) -> Self { + Self { + email: registration.email.map(Email::from), + } + } +} + +impl From for Email { + fn from(email: DomainEmail) -> Self { + Self { + required: email.required, + verification_required: email.verification_required, + } + } +} + impl From for TrackerStatisticsImporter { fn from(tracker_statistics_importer: DomainTrackerStatisticsImporter) -> Self { Self { torrent_info_update_interval: tracker_statistics_importer.torrent_info_update_interval, + port: tracker_statistics_importer.port, } } } diff --git a/tests/common/contexts/settings/responses.rs b/tests/common/contexts/settings/responses.rs index 096ef1f4..4b7fdfd8 100644 --- a/tests/common/contexts/settings/responses.rs +++ b/tests/common/contexts/settings/responses.rs @@ -1,4 +1,5 @@ use serde::Deserialize; +use url::Url; use super::Settings; @@ -15,8 +16,9 @@ pub struct PublicSettingsResponse { #[derive(Deserialize, PartialEq, Debug)] pub struct Public { pub website_name: String, - pub tracker_url: String, - pub tracker_mode: String, + pub tracker_url: Url, + pub tracker_listed: bool, + pub tracker_private: bool, pub email_on_signup: String, } diff --git a/tests/common/contexts/torrent/asserts.rs b/tests/common/contexts/torrent/asserts.rs index d0f1a8cf..f612ba70 100644 --- a/tests/common/contexts/torrent/asserts.rs +++ b/tests/common/contexts/torrent/asserts.rs @@ -15,10 +15,7 @@ pub fn assert_expected_torrent_details(torrent: &TorrentDetails, expected_torren ("info_hash", torrent.info_hash == expected_torrent.info_hash), ("title", torrent.title == expected_torrent.title), ("description", torrent.description == expected_torrent.description), - ( - "category.category_id", - torrent.category.category_id == expected_torrent.category.category_id, - ), + ("category.id", torrent.category.id == expected_torrent.category.id), ("category.name", torrent.category.name == expected_torrent.category.name), ("file_size", torrent.file_size == expected_torrent.file_size), ("seeders", torrent.seeders == expected_torrent.seeders), diff --git a/tests/common/contexts/torrent/file.rs b/tests/common/contexts/torrent/file.rs index b5f58339..d9d3783f 100644 --- a/tests/common/contexts/torrent/file.rs +++ b/tests/common/contexts/torrent/file.rs @@ -8,12 +8,14 @@ use serde::Deserialize; use which::which; /// Attributes parsed from a torrent file. +#[allow(dead_code)] #[derive(Deserialize, Clone, Debug)] pub struct TorrentFileInfo { pub name: String, pub comment: Option, - pub creation_date: Option, + pub creation_date: Option, pub created_by: Option, + pub encoding: Option, pub source: Option, pub info_hash: String, pub torrent_size: u64, diff --git a/tests/common/contexts/torrent/fixtures.rs b/tests/common/contexts/torrent/fixtures.rs index e60b6089..b085ddec 100644 --- a/tests/common/contexts/torrent/fixtures.rs +++ b/tests/common/contexts/torrent/fixtures.rs @@ -40,6 +40,7 @@ impl From for UploadTorrentMultipartForm { } /// Torrent that has been added to the index. +#[allow(dead_code)] pub struct TorrentListedInIndex { pub torrent_id: Id, pub title: String, diff --git a/tests/common/contexts/torrent/responses.rs b/tests/common/contexts/torrent/responses.rs index f95d67ce..84e0529f 100644 --- a/tests/common/contexts/torrent/responses.rs +++ b/tests/common/contexts/torrent/responses.rs @@ -41,6 +41,9 @@ pub struct ListItem { pub leechers: i64, pub name: String, pub comment: Option, + pub creation_date: Option, + pub created_by: Option, + pub encoding: Option, } #[derive(Deserialize, PartialEq, Debug)] @@ -66,11 +69,15 @@ pub struct TorrentDetails { pub tags: Vec, pub name: String, pub comment: Option, + pub creation_date: Option, + pub created_by: Option, + pub encoding: Option, + pub canonical_info_hash_group: Vec, } #[derive(Deserialize, PartialEq, Debug)] pub struct Category { - pub category_id: CategoryId, + pub id: CategoryId, pub name: String, pub num_torrents: u64, } @@ -96,6 +103,7 @@ pub struct UploadedTorrentResponse { #[derive(Deserialize, PartialEq, Debug)] pub struct UploadedTorrent { pub torrent_id: Id, + pub canonical_info_hash: String, pub info_hash: String, } diff --git a/tests/common/contexts/user/asserts.rs b/tests/common/contexts/user/asserts.rs index dfa5352b..959d0931 100644 --- a/tests/common/contexts/user/asserts.rs +++ b/tests/common/contexts/user/asserts.rs @@ -12,13 +12,13 @@ pub fn assert_added_user_response(response: &TextResponse) { assert_json_ok_response(response); } -pub fn assert_successful_login_response(response: &TextResponse, registered_user: &RegistrationForm) { +pub fn assert_successful_login_response(response: &TextResponse, username: &str) { let successful_login_response: SuccessfulLoginResponse = serde_json::from_str(&response.body) .unwrap_or_else(|_| panic!("response {:#?} should be a SuccessfulLoginResponse", response.body)); let logged_in_user = successful_login_response.data; - assert_eq!(logged_in_user.username, registered_user.username); + assert_eq!(logged_in_user.username, username); assert_json_ok_response(response); } diff --git a/tests/common/contexts/user/fixtures.rs b/tests/common/contexts/user/fixtures.rs index fea39e7f..4d5ce357 100644 --- a/tests/common/contexts/user/fixtures.rs +++ b/tests/common/contexts/user/fixtures.rs @@ -2,13 +2,19 @@ use rand::Rng; use crate::common::contexts::user::forms::RegistrationForm; +/// Default password used in tests +pub const DEFAULT_PASSWORD: &str = "password"; + +/// Sample valid password used in tests +pub const VALID_PASSWORD: &str = "12345678"; + pub fn random_user_registration_form() -> RegistrationForm { let user_id = random_user_id(); RegistrationForm { username: format!("username_{user_id}"), email: Some(format!("email_{user_id}@email.com")), - password: "password".to_string(), - confirm_password: "password".to_string(), + password: DEFAULT_PASSWORD.to_string(), + confirm_password: DEFAULT_PASSWORD.to_string(), } } diff --git a/tests/common/contexts/user/forms.rs b/tests/common/contexts/user/forms.rs index 359252a8..bc2e9a1b 100644 --- a/tests/common/contexts/user/forms.rs +++ b/tests/common/contexts/user/forms.rs @@ -35,3 +35,10 @@ impl Username { Self { value } } } + +#[derive(Serialize)] +pub struct ChangePasswordForm { + pub current_password: String, + pub password: String, + pub confirm_password: String, +} diff --git a/tests/common/contexts/user/responses.rs b/tests/common/contexts/user/responses.rs index 1a9a3837..f917be18 100644 --- a/tests/common/contexts/user/responses.rs +++ b/tests/common/contexts/user/responses.rs @@ -1,10 +1,12 @@ use serde::Deserialize; +#[allow(dead_code)] #[derive(Deserialize, Debug)] pub struct AddedUserResponse { pub data: NewUserData, } +#[allow(dead_code)] #[derive(Deserialize, Debug)] pub struct NewUserData { pub user_id: i64, diff --git a/tests/common/responses.rs b/tests/common/responses.rs index 2545a9e8..0d9388e2 100644 --- a/tests/common/responses.rs +++ b/tests/common/responses.rs @@ -54,7 +54,7 @@ impl BinaryResponse { } } pub fn is_a_bit_torrent_file(&self) -> bool { - self.is_ok() && self.is_bittorrent_content_type() + self.is_ok() && (self.is_bittorrent_content_type() || self.is_octet_stream_content_type()) } pub fn is_bittorrent_content_type(&self) -> bool { @@ -64,6 +64,13 @@ impl BinaryResponse { false } + pub fn is_octet_stream_content_type(&self) -> bool { + if let Some(content_type) = &self.content_type { + return content_type == "application/octet-stream"; + } + false + } + pub fn is_ok(&self) -> bool { self.status == 200 } diff --git a/tests/e2e/config.rs b/tests/e2e/config.rs index 747e0a05..be46b7e2 100644 --- a/tests/e2e/config.rs +++ b/tests/e2e/config.rs @@ -7,28 +7,21 @@ use torrust_index::config::{Configuration, Info}; -/// The whole `index.toml` file content. It has priority over the config file. -/// Even if the file is not on the default path. -const ENV_VAR_CONFIG: &str = "TORRUST_INDEX_E2E_CONFIG"; - -/// Token needed to communicate with the Torrust Tracker -const ENV_VAR_API_ADMIN_TOKEN: &str = "TORRUST_INDEX_E2E_TRACKER_API_TOKEN"; - -/// The `index.toml` file location. -pub const ENV_VAR_PATH_CONFIG: &str = "TORRUST_INDEX_E2E_PATH_CONFIG"; - // Default values pub const DEFAULT_PATH_CONFIG: &str = "./share/default/config/index.development.sqlite3.toml"; /// If present, E2E tests will run against a shared instance of the server pub const ENV_VAR_INDEX_SHARED: &str = "TORRUST_INDEX_E2E_SHARED"; +/// `SQLx` connection string to connect to the E2E database +pub const ENV_VAR_DB_CONNECT_URL: &str = "TORRUST_INDEX_E2E_DB_CONNECT_URL"; + /// It loads the application configuration from the environment. /// /// There are two methods to inject the configuration: /// /// 1. By using a config file: `index.toml`. -/// 2. Environment variable: `TORRUST_INDEX_E2E_CONFIG`. The variable contains the same contents as the `index.toml` file. +/// 2. Environment variable: `TORRUST_INDEX_CONFIG_TOML`. The variable contains the same contents as the `index.toml` file. /// /// Environment variable has priority over the config file. /// @@ -37,16 +30,10 @@ pub const ENV_VAR_INDEX_SHARED: &str = "TORRUST_INDEX_E2E_SHARED"; /// # Panics /// /// Will panic if it can't load the configuration from either -/// `./index.toml` file or the env var `TORRUST_INDEX_CONFIG`. +/// `./index.toml` file or the env var `TORRUST_INDEX_CONFIG_TOML`. #[must_use] pub fn initialize_configuration() -> Configuration { - let info = Info::new( - ENV_VAR_CONFIG.to_string(), - ENV_VAR_PATH_CONFIG.to_string(), - DEFAULT_PATH_CONFIG.to_string(), - ENV_VAR_API_ADMIN_TOKEN.to_string(), - ) - .unwrap(); + let info = Info::new(DEFAULT_PATH_CONFIG.to_string()).unwrap(); Configuration::load(&info).unwrap() } diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index 73652725..fd72458e 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -1,9 +1,10 @@ use std::env; -use torrust_index::databases::database; +use torrust_index::config::ApiToken; use torrust_index::web::api::Version; +use url::Url; -use super::config::{initialize_configuration, ENV_VAR_INDEX_SHARED}; +use super::config::{initialize_configuration, ENV_VAR_DB_CONNECT_URL, ENV_VAR_INDEX_SHARED}; use crate::common::contexts::settings::Settings; use crate::environments::{isolated, shared}; @@ -74,16 +75,51 @@ impl TestEnv { } } - /// Some test requires the real tracker to be running, so they can only - /// be run in shared mode. + /// Some test requires a real tracker running. pub fn provides_a_tracker(&self) -> bool { - self.is_shared() + self.is_shared() && self.server_settings().is_some() + } + + /// Some test requires a real tracker running in `private` mode. + pub fn provides_a_private_tracker(&self) -> bool { + if !self.is_shared() { + return false; + }; + + match self.server_settings() { + Some(settings) => settings.tracker.private, + None => false, + } } /// Returns the server starting settings if the servers was already started. /// We do not know the settings until we start the server. pub fn server_settings(&self) -> Option { - self.starting_settings.as_ref().cloned() + self.starting_settings.clone() + } + + /// Returns the server starting settings if the servers was already started, + /// masking secrets with asterisks. + pub fn server_settings_masking_secrets(&self) -> Option { + match self.starting_settings.clone() { + Some(mut settings) => { + // Mask password in DB connect URL if present + let mut connect_url = Url::parse(&settings.database.connect_url).expect("valid database connect URL"); + if let Some(_password) = connect_url.password() { + let _ = connect_url.set_password(Some("***")); + settings.database.connect_url = connect_url.to_string(); + } + + settings.tracker.token = ApiToken::new("***"); + + "***".clone_into(&mut settings.mail.smtp.credentials.password); + + "***".clone_into(&mut settings.auth.user_claim_token_pepper); + + Some(settings) + } + None => None, + } } /// Provides the API server socket address. @@ -98,9 +134,10 @@ impl TestEnv { /// Provides a database connect URL to connect to the database. For example: /// - /// `sqlite://storage/database/torrust_index_e2e_testing.db?mode=rwc`. + /// - `sqlite://storage/database/torrust_index_e2e_testing.db?mode=rwc`. + /// - `mysql://root:root_secret_password@localhost:3306/torrust_index_e2e_testing`. /// - /// It's used to run SQL queries against the database needed for some tests. + /// It's used to run SQL queries against the E2E database needed for some tests. pub fn database_connect_url(&self) -> Option { let internal_connect_url = self .starting_settings @@ -109,42 +146,19 @@ impl TestEnv { match self.state() { State::RunningShared => { - if let Some(db_path) = internal_connect_url { - let maybe_db_driver = database::get_driver(&db_path); - - return match maybe_db_driver { - Ok(db_driver) => match db_driver { - database::Driver::Sqlite3 => Some(db_path), - database::Driver::Mysql => Some(Self::overwrite_mysql_host(&db_path, "localhost")), - }, - Err(_) => None, - }; + let connect_url_env_var = ENV_VAR_DB_CONNECT_URL; + + if let Ok(connect_url) = env::var(connect_url_env_var) { + Some(connect_url) + } else { + None } - None } State::RunningIsolated => internal_connect_url, State::Stopped => None, } } - /// It overrides the "Host" in a `SQLx` database connection URL. For example: - /// - /// For: - /// - /// `mysql://root:root_secret_password@mysql:3306/torrust_index_e2e_testing`. - /// - /// It changes the `mysql` host name to `localhost`: - /// - /// `mysql://root:root_secret_password@localhost:3306/torrust_index_e2e_testing`. - /// - /// For E2E tests, we use docker compose, internally the index connects to - /// the database using the "mysql" host, which is the docker compose service - /// name, but tests connects directly to the localhost since the `MySQL` - /// is exposed to the host. - fn overwrite_mysql_host(db_path: &str, new_host: &str) -> String { - db_path.replace("@mysql:", &format!("@{new_host}:")) - } - fn state(&self) -> State { if self.is_shared() { return State::RunningShared; diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 07c53151..7f691e69 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -4,14 +4,14 @@ //! against an in-process server (isolated). //! //! If you want to run the tests against an out-of-process server, you need to -//! set the environment variable `TORRUST_IDX_BACK_E2E_SHARED` to `true`. +//! set the environment variable `TORRUST_INDEX_E2E_SHARED` to `true`. //! //! > **NOTICE**: The server must be running before running the tests. The -//! server url is hardcoded to `http://localhost:3001` for now. We are planning -//! to make it configurable in the future via a environment variable. +//! > server url is hardcoded to `http://localhost:3001` for now. We are planning +//! > to make it configurable in the future via a environment variable. //! //! ```text -//! TORRUST_IDX_BACK_E2E_SHARED=true cargo test +//! TORRUST_INDEX_E2E_SHARED=true cargo test //! ``` //! //! If you want to run the tests against an isolated server, you need to execute @@ -22,8 +22,8 @@ //! ``` //! //! > **NOTICE**: Some tests require the real tracker to be running, so they -//! can only be run in shared mode until we implement a mock for the -//! `torrust_index::tracker::TrackerService`. +//! > can only be run in shared mode until we implement a mock for the +//! > `torrust_index::tracker::TrackerService`. //! //! You may have errors like `Too many open files (os error 24)`. If so, you //! need to increase the limit of open files for the current user. You can do diff --git a/tests/e2e/web/api/v1/contexts/settings/contract.rs b/tests/e2e/web/api/v1/contexts/settings/contract.rs index fbef5659..02d30a36 100644 --- a/tests/e2e/web/api/v1/contexts/settings/contract.rs +++ b/tests/e2e/web/api/v1/contexts/settings/contract.rs @@ -1,5 +1,6 @@ //! API contract for `settings` context. +use torrust_index::services::settings::EmailOnSignup; use torrust_index::web::api; use crate::common::asserts::assert_json_ok_response; @@ -20,13 +21,28 @@ async fn it_should_allow_guests_to_get_the_public_settings() { let res: PublicSettingsResponse = serde_json::from_str(&response.body) .unwrap_or_else(|_| panic!("response {:#?} should be a PublicSettingsResponse", response.body)); + let email_on_signup = match &env.server_settings().unwrap().registration { + Some(registration) => match ®istration.email { + Some(email) => { + if email.required { + EmailOnSignup::Required + } else { + EmailOnSignup::Optional + } + } + None => EmailOnSignup::NotIncluded, + }, + None => EmailOnSignup::NotIncluded, + }; + assert_eq!( res.data, Public { website_name: env.server_settings().unwrap().website.name, tracker_url: env.server_settings().unwrap().tracker.url, - tracker_mode: env.server_settings().unwrap().tracker.mode, - email_on_signup: env.server_settings().unwrap().auth.email_on_signup, + tracker_listed: env.server_settings().unwrap().tracker.listed, + tracker_private: env.server_settings().unwrap().tracker.private, + email_on_signup: email_on_signup.to_string(), } ); @@ -61,7 +77,7 @@ async fn it_should_allow_admins_to_get_all_the_settings() { let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); - assert_eq!(res.data, env.server_settings().unwrap()); + assert_eq!(res.data, env.server_settings_masking_secrets().unwrap()); assert_json_ok_response(&response); } diff --git a/tests/e2e/web/api/v1/contexts/torrent/asserts.rs b/tests/e2e/web/api/v1/contexts/torrent/asserts.rs index 376f9c82..e268c9d6 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/asserts.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/asserts.rs @@ -26,14 +26,6 @@ pub async fn canonical_torrent_for( uploaded_torrent.announce = Some(build_announce_url(&tracker_url, &tracker_key)); uploaded_torrent.announce_list = Some(build_announce_list(&tracker_url, &tracker_key)); - // These fields are not persisted in the database yet. - // See https://github.com/torrust/torrust-index/issues/284 - // They are ignore when the user uploads the torrent. So the stored - // canonical torrent does not contain them. - uploaded_torrent.encoding = None; - uploaded_torrent.creation_date = None; - uploaded_torrent.created_by = None; - uploaded_torrent } diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index 1ebf8f70..ed3b4f33 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -172,7 +172,7 @@ mod for_guests { let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); - let tracker_url = env.server_settings().unwrap().tracker.url; + let tracker_url = env.server_settings().unwrap().tracker.url.to_string(); let encoded_tracker_url = urlencoding::encode(&tracker_url); let expected_torrent = TorrentDetails { @@ -182,7 +182,7 @@ mod for_guests { title: test_torrent.index_info.title.clone(), description: test_torrent.index_info.description, category: Category { - category_id: software_predefined_category_id(), + id: software_predefined_category_id(), name: test_torrent.index_info.category, num_torrents: 19, // Ignored in assertion }, @@ -194,25 +194,23 @@ mod for_guests { path: vec![test_torrent.file_info.files[0].clone()], // Using one file torrent for testing: content_size = first file size length: test_torrent.file_info.content_size, - md5sum: None, + md5sum: None, // DevSkim: ignore DS126858 }], - // code-review: why is this duplicated? It seems that is adding the - // same tracker twice because first ti adds all trackers and then - // it adds the tracker with the personal announce url, if the user - // is logged in. If the user is not logged in, it adds the default - // tracker again, and it ends up with two trackers. - trackers: vec![tracker_url.clone(), tracker_url.clone()], + trackers: vec![tracker_url.clone().to_string()], magnet_link: format!( // cspell:disable-next-line - "magnet:?xt=urn:btih:{}&dn={}&tr={}&tr={}", + "magnet:?xt=urn:btih:{}&dn={}&tr={}", test_torrent.file_info.info_hash.to_lowercase(), urlencoding::encode(&test_torrent.index_info.title), - encoded_tracker_url, encoded_tracker_url ), tags: vec![], name: test_torrent.index_info.name.clone(), comment: test_torrent.file_info.comment.clone(), + creation_date: test_torrent.file_info.creation_date, + created_by: test_torrent.file_info.created_by.clone(), + encoding: test_torrent.file_info.encoding.clone(), + canonical_info_hash_group: vec![test_torrent.file_info.info_hash.to_lowercase()], }; assert_expected_torrent_details(&torrent_details_response.data, &expected_torrent); @@ -240,7 +238,7 @@ mod for_guests { // Upload the first torrent let mut first_torrent = TestTorrent::with_custom_info_dict_field(id, &file_contents, "custom 01"); - first_torrent.index_info.title = title.clone(); + first_torrent.index_info.title.clone_from(&title); let first_torrent_canonical_info_hash = upload_test_torrent(&client, &first_torrent) .await @@ -378,7 +376,7 @@ mod for_guests { // Upload the first torrent let mut first_torrent = TestTorrent::with_custom_info_dict_field(id, &file_contents, "custom 01"); - first_torrent.index_info.title = title.clone(); + first_torrent.index_info.title.clone_from(&title); let first_torrent_canonical_info_hash = upload_test_torrent(&client, &first_torrent) .await @@ -470,15 +468,6 @@ mod for_guests { mod for_authenticated_users { - use torrust_index::utils::parse_torrent::decode_torrent; - use torrust_index::web::api; - - use crate::common::client::Client; - use crate::e2e::environment::TestEnv; - use crate::e2e::web::api::v1::contexts::torrent::asserts::{build_announce_url, get_user_tracker_key}; - use crate::e2e::web::api::v1::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; - mod uploading_a_torrent { use torrust_index::web::api; @@ -515,7 +504,7 @@ mod for_authenticated_users { let uploaded_torrent_response: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); assert_eq!( - uploaded_torrent_response.data.info_hash.to_lowercase(), + uploaded_torrent_response.data.canonical_info_hash.to_lowercase(), info_hash.to_lowercase() ); assert!(response.is_json_and_ok()); @@ -739,7 +728,7 @@ mod for_authenticated_users { let form: UploadTorrentMultipartForm = first_torrent_clone.index_info.into(); let response = client.upload_torrent(form.into()).await; - assert_eq!(response.status, 400); + assert_eq!(response.status, 409); } #[tokio::test] @@ -772,44 +761,49 @@ mod for_authenticated_users { let form: UploadTorrentMultipartForm = torrent_with_the_same_canonical_info_hash.index_info.into(); let response = client.upload_torrent(form.into()).await; - assert_eq!(response.status, 400); + assert_eq!(response.status, 409); } } - #[tokio::test] - async fn it_should_allow_authenticated_users_to_download_a_torrent_with_a_personal_announce_url() { - let mut env = TestEnv::new(); - env.start(api::Version::V1).await; + mod downloading_a_torrent { - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } + use regex::Regex; + use torrust_index::utils::parse_torrent::decode_torrent; + use torrust_index::web::api; + use url::Url; - // Given a previously uploaded torrent - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; + use crate::common::client::Client; + use crate::e2e::environment::TestEnv; + use crate::e2e::web::api::v1::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; - // And a logged in user who is going to download the torrent - let downloader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &downloader.token); + #[tokio::test] + async fn it_should_include_the_tracker_key_when_the_tracker_is_running_in_private_mode() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; - // When the user downloads the torrent - let response = client.download_torrent(&test_torrent.file_info_hash()).await; + if !env.provides_a_private_tracker() { + println!("test skipped. It requires a private tracker to be running."); + return; + } - let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - // Then the torrent should have the personal announce URL - let tracker_key = get_user_tracker_key(&downloader, &env) - .await - .expect("uploader should have a valid tracker key"); + // Upload + let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - let tracker_url = env.server_settings().unwrap().tracker.url; + // Download + let response = client.download_torrent(&test_torrent.file_info_hash()).await; - assert_eq!( - torrent.announce.unwrap(), - build_announce_url(&tracker_url, &Some(tracker_key)) - ); + let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); + + let announce_url = Url::parse(&torrent.announce.unwrap()).unwrap(); + + let re = Regex::new(r"^http://tracker:7070/[a-zA-Z0-9]{32}$").unwrap(); // DevSkim: ignore DS137138 + + assert!(re.is_match(announce_url.as_ref()), "Invalid announce URL: '{announce_url}'."); + } } mod and_non_admins { diff --git a/tests/e2e/web/api/v1/contexts/torrent/steps.rs b/tests/e2e/web/api/v1/contexts/torrent/steps.rs index 56c7a648..27b21c7d 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/steps.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/steps.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use torrust_index::models::info_hash::InfoHash; -use torrust_index::web::api::v1::responses::ErrorResponseData; +use torrust_index::web::api::server::v1::responses::ErrorResponseData; use crate::common::client::Client; use crate::common::contexts::torrent::fixtures::{random_torrent, TestTorrent, TorrentIndexInfo, TorrentListedInIndex}; @@ -28,7 +28,7 @@ pub async fn upload_torrent(uploader: &LoggedInUserData, torrent: &TorrentIndexI let res = serde_json::from_str::(&response.body); if res.is_err() { - println!("Error deserializing response: {res:?}"); + println!("Error deserializing response: {res:?}. Body: {0}", response.body); } TorrentListedInIndex::from(torrent.clone(), res.unwrap().data.torrent_id) @@ -50,7 +50,7 @@ pub async fn upload_test_torrent(client: &Client, test_torrent: &TestTorrent) -> } let uploaded_torrent_response: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); - let canonical_info_hash_hex = uploaded_torrent_response.data.info_hash.to_lowercase(); + let canonical_info_hash_hex = uploaded_torrent_response.data.canonical_info_hash.to_lowercase(); let canonical_info_hash = InfoHash::from_str(&canonical_info_hash_hex) .unwrap_or_else(|_| panic!("Invalid info-hash in database: {canonical_info_hash_hex}")); diff --git a/tests/e2e/web/api/v1/contexts/user/contract.rs b/tests/e2e/web/api/v1/contexts/user/contract.rs index 809a2cb9..3124fc28 100644 --- a/tests/e2e/web/api/v1/contexts/user/contract.rs +++ b/tests/e2e/web/api/v1/contexts/user/contract.rs @@ -58,7 +58,10 @@ mod authentication { use crate::common::contexts::user::asserts::{ assert_successful_login_response, assert_token_renewal_response, assert_token_verified_response, }; - use crate::common::contexts::user::forms::{LoginForm, TokenRenewalForm, TokenVerificationForm}; + use crate::common::contexts::user::fixtures::{DEFAULT_PASSWORD, VALID_PASSWORD}; + use crate::common::contexts::user::forms::{ + ChangePasswordForm, LoginForm, TokenRenewalForm, TokenVerificationForm, Username, + }; use crate::e2e::environment::TestEnv; use crate::e2e::web::api::v1::contexts::user::steps::{new_logged_in_user, new_registered_user}; @@ -78,7 +81,64 @@ mod authentication { }) .await; - assert_successful_login_response(&response, ®istered_user); + assert_successful_login_response(&response, ®istered_user.username); + } + + #[tokio::test] + async fn it_should_allow_logged_in_users_to_change_their_passwords() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let logged_in_user = new_logged_in_user(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_user.token); + + let new_password = VALID_PASSWORD.to_string(); + + let response = client + .change_password( + Username::new(logged_in_user.username.clone()), + ChangePasswordForm { + current_password: DEFAULT_PASSWORD.to_string(), + password: new_password.clone(), + confirm_password: new_password.clone(), + }, + ) + .await; + + assert_eq!(response.status, 200); + + let response = client + .login_user(LoginForm { + login: logged_in_user.username.clone(), + password: new_password, + }) + .await; + + assert_successful_login_response(&response, &logged_in_user.username); + } + + #[tokio::test] + async fn it_should_fail_changing_the_password_if_the_user_does_not_provide_the_current_password() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let logged_in_user = new_logged_in_user(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_user.token); + + let response = client + .change_password( + Username::new(logged_in_user.username.clone()), + ChangePasswordForm { + current_password: "INVALID PASSWORD".to_string(), + password: VALID_PASSWORD.to_string(), + confirm_password: VALID_PASSWORD.to_string(), + }, + ) + .await; + + assert_eq!(response.status, 403); } #[tokio::test] diff --git a/tests/environments/app_starter.rs b/tests/environments/app_starter.rs index b21f80d5..adf18589 100644 --- a/tests/environments/app_starter.rs +++ b/tests/environments/app_starter.rs @@ -1,16 +1,15 @@ use std::net::SocketAddr; -use log::info; use tokio::sync::{oneshot, RwLock}; use tokio::task::JoinHandle; use torrust_index::config::Configuration; use torrust_index::web::api::Version; use torrust_index::{app, config}; +use tracing::info; /// It launches the app and provides a way to stop it. pub struct AppStarter { - configuration: config::TorrustIndex, - config_path: Option, + configuration: config::Settings, /// The application binary state (started or not): /// - `None`: if the app is not started, /// - `RunningState`: if the app was started. @@ -19,10 +18,9 @@ pub struct AppStarter { impl AppStarter { #[must_use] - pub fn with_custom_configuration(configuration: config::TorrustIndex, config_path: Option) -> Self { + pub fn with_custom_configuration(configuration: config::Settings) -> Self { Self { configuration, - config_path, running_state: None, } } @@ -35,7 +33,6 @@ impl AppStarter { pub async fn start(&mut self, api_version: Version) { let configuration = Configuration { settings: RwLock::new(self.configuration.clone()), - config_path: self.config_path.clone(), }; // Open a channel to communicate back with this function @@ -54,7 +51,7 @@ impl AppStarter { .expect("the app starter should not be dropped"); match api_version { - Version::V1 => app.api_server.unwrap().await, + Version::V1 => app.api_server.await, } }); @@ -71,17 +68,14 @@ impl AppStarter { } pub fn stop(&mut self) { - match &self.running_state { - Some(running_state) => { - running_state.app_handle.abort(); - self.running_state = None; - } - None => {} + if let Some(running_state) = &self.running_state { + running_state.app_handle.abort(); + self.running_state = None; } } #[must_use] - pub fn server_configuration(&self) -> config::TorrustIndex { + pub fn server_configuration(&self) -> config::Settings { self.configuration.clone() } @@ -92,7 +86,7 @@ impl AppStarter { #[must_use] pub fn database_connect_url(&self) -> String { - self.configuration.database.connect_url.clone() + self.configuration.database.connect_url.clone().to_string() } } diff --git a/tests/environments/isolated.rs b/tests/environments/isolated.rs index e30c8907..fb25f0cf 100644 --- a/tests/environments/isolated.rs +++ b/tests/environments/isolated.rs @@ -1,7 +1,11 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use tempfile::TempDir; use torrust_index::config; -use torrust_index::config::FREE_PORT; +use torrust_index::config::v2::registration::{Email, Registration}; +use torrust_index::config::{Threshold, FREE_PORT}; use torrust_index::web::api::Version; +use url::Url; use super::app_starter::AppStarter; use crate::common::random; @@ -33,12 +37,8 @@ impl TestEnv { let temp_dir = TempDir::new().expect("failed to create a temporary directory"); let configuration = ephemeral(&temp_dir); - // Even if we load the configuration from the environment variable, we - // still need to provide a path to save the configuration when the - // configuration is updated via the `POST /settings` endpoints. - let config_path = format!("{}/config.toml", temp_dir.path().to_string_lossy()); - let app_starter = AppStarter::with_custom_configuration(configuration, Some(config_path)); + let app_starter = AppStarter::with_custom_configuration(configuration); Self { app_starter, temp_dir } } @@ -50,7 +50,7 @@ impl TestEnv { /// Provides the whole server configuration. #[must_use] - pub fn server_configuration(&self) -> config::TorrustIndex { + pub fn server_configuration(&self) -> config::Settings { self.app_starter.server_configuration() } @@ -73,17 +73,28 @@ impl Default for TestEnv { } /// Provides a configuration with ephemeral data for testing. -fn ephemeral(temp_dir: &TempDir) -> config::TorrustIndex { - let mut configuration = config::TorrustIndex { - log_level: Some("off".to_owned()), // Change to `debug` for tests debugging - ..config::TorrustIndex::default() - }; +fn ephemeral(temp_dir: &TempDir) -> config::Settings { + let mut configuration = config::Settings::default(); + + configuration.logging.threshold = Threshold::Off; // Change to `debug` for tests debugging // Ephemeral API port - configuration.net.port = FREE_PORT; + configuration.net.bind_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), FREE_PORT); + + // Ephemeral Importer API port + configuration.tracker_statistics_importer.port = FREE_PORT; // Ephemeral SQLite database - configuration.database.connect_url = format!("sqlite://{}?mode=rwc", random_database_file_path_in(temp_dir)); + configuration.database.connect_url = + Url::parse(&format!("sqlite://{}?mode=rwc", random_database_file_path_in(temp_dir))).unwrap(); + + // Enable user registration + configuration.registration = Some(Registration { + email: Some(Email { + required: false, + verification_required: false, + }), + }); configuration } diff --git a/tests/environments/shared.rs b/tests/environments/shared.rs index d9db57be..936cf375 100644 --- a/tests/environments/shared.rs +++ b/tests/environments/shared.rs @@ -16,8 +16,10 @@ impl TestEnv { pub async fn running() -> Self { let env = Self::default(); let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - let is_running = client.server_is_running().await; - assert!(is_running, "Test server is not running on {}", env.authority); + match client.server_is_running().await { + Ok(()) => {} + Err(err) => panic!("Test server is not running on {}. Error: {err}", env.authority), + } env } @@ -34,7 +36,7 @@ impl TestEnv { impl Default for TestEnv { fn default() -> Self { Self { - authority: "localhost:3001".to_string(), + authority: "127.0.0.1:3001".to_string(), } } } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs index 6677b04b..078f5630 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs @@ -112,14 +112,15 @@ impl TorrentTester { assert_eq!(imported_torrent.info_hash, torrent.info_hash); assert_eq!(imported_torrent.size, torrent.file_size); assert_eq!(imported_torrent.name, torrent_file.info.name); - assert_eq!(imported_torrent.pieces, torrent_file.info.get_pieces_as_string()); + assert_eq!(imported_torrent.pieces, Some(torrent_file.info.get_pieces_as_string())); + assert_eq!(imported_torrent.root_hash, None); assert_eq!(imported_torrent.piece_length, torrent_file.info.piece_length); if torrent_file.info.private.is_none() { assert_eq!(imported_torrent.private, Some(0)); } else { assert_eq!(imported_torrent.private, torrent_file.info.private); } - assert_eq!(imported_torrent.root_hash, torrent_file.info.get_root_hash_as_i64()); + assert_eq!(imported_torrent.is_bep_30, i64::from(torrent_file.info.is_bep_30())); assert_eq!( imported_torrent.date_uploaded, convert_timestamp_to_datetime(torrent.upload_date)

Hi! This is a running torrust-index.