diff --git a/.gitea/workflows/main.yml b/.gitea/workflows/main.yml index 139665b..b451f2f 100644 --- a/.gitea/workflows/main.yml +++ b/.gitea/workflows/main.yml @@ -1,27 +1,98 @@ -name: 'deploy' -on: [push] +name: CI + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] jobs: - deploy: + test: + runs-on: ubuntu-latest + services: + redis: + image: redis + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v2 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + components: rustfmt, clippy + + - name: Install cargo-llvm-cov manually + run: | + mkdir -p $HOME/.cargo/bin + curl -LO https://github.com/taiki-e/cargo-llvm-cov/releases/latest/download/cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz + tar xf cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz + chmod +x cargo-llvm-cov + mv cargo-llvm-cov $HOME/.cargo/bin/ + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + rm cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz + + - name: Generate code coverage + run: | + cargo llvm-cov --lcov --output-path lcov.info + cargo llvm-cov --html + + - name: Extract Coverage Percentage + id: coverage + run: | + COVERAGE=$(cargo llvm-cov --summary | grep -oP 'coverage: \K[0-9.]+%' || echo "0%") + echo "total_coverage=$COVERAGE" >> $GITHUB_OUTPUT + + - name: Create Coverage Badge + uses: schneegans/dynamic-badges-action@v1.6.0 + with: + auth: ${{ secrets.GIST_SECRET }} + gistID: your_gist_id_here + filename: coverage.json + label: coverage + message: ${{ steps.coverage.outputs.total_coverage }} + color: green + + - name: Upload coverage HTML + uses: actions/upload-artifact@v3 + with: + name: code-coverage + path: target/llvm-cov/html + + - name: Upload LCOV report + uses: actions/upload-artifact@v3 + with: + name: lcov-report + path: lcov.info + + lint: runs-on: ubuntu-latest steps: - - name: Cloning repo - uses: actions/checkout@v2 - with: - fetch-depth: 0 + - uses: actions/checkout@v2 - - name: Get Repo Name - id: repo_name - run: echo "::set-output name=repo::$(echo ${GITHUB_REPOSITORY##*/})" + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + components: rustfmt, clippy - - name: Get Branch Name - id: branch_name - run: echo "::set-output name=branch::$(echo ${GITHUB_REF##*/})" + - name: Check formatting + run: cargo fmt --all -- --check - - name: Push to dokku - uses: dokku/github-action@master - with: - branch: 'main' - git_remote_url: 'ssh://dokku@v2.discours.io:22/quoter' - ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} - git_push_flags: '--force' \ No newline at end of file + - name: Clippy + run: cargo clippy -- -D warnings + + deploy: + needs: [test, lint] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Deploy to Dokku + uses: dokku/github-action@master + with: + git_remote_url: 'ssh://dokku@staging.discours.io:22/inbox' + ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 024250d..7278874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ +## [0.2.1] - 2024-12-19 + +### Added +- Добавлены интеграционные тесты в папку tests/ +- Создан файл tests/basic_test.rs с 10 тестами: + - test_health_check - проверка health endpoint + - test_json_serialization - тестирование JSON сериализации + - test_multipart_form_data - проверка multipart form data + - test_uuid_generation - тестирование UUID генерации + - test_mime_type_detection - проверка определения MIME типов + - test_file_path_parsing - тестирование парсинга путей файлов + - test_quota_calculations - проверка расчетов квот + - test_file_size_formatting - тестирование форматирования размеров + - test_error_handling - проверка обработки ошибок + - test_performance - тестирование производительности +- Добавлена зависимость chrono для тестов +- Создана документация по тестированию docs/testing.md +- Обновлено оглавление документации + +### Changed +- Улучшена структура тестов для лучшей изоляции +- Оптимизированы тесты производительности + +## [0.2.0] - 2025-08-01 + +- `nginx.conf.sigil` removed +- exposed 8080 in `dockerfile` +- docs +- quota 5Gb per user +- update packages versions +- integration tests + ## [0.1.1] - Added application-level CORS middleware using actix-cors diff --git a/Cargo.lock b/Cargo.lock index 4216415..8362cf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,7 +199,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2", + "socket2 0.5.7", "tokio", "tracing", ] @@ -262,7 +262,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2", + "socket2 0.5.7", "time", "url", ] @@ -354,6 +354,21 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.18" @@ -1046,6 +1061,21 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1265,6 +1295,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.3.11" @@ -1321,7 +1361,7 @@ dependencies = [ [[package]] name = "discoursio-quoter" -version = "0.1.1" +version = "0.2.0" dependencies = [ "ab_glyph", "actix", @@ -1330,6 +1370,7 @@ dependencies = [ "actix-web", "aws-config", "aws-sdk-s3", + "chrono", "env_logger", "futures", "image", @@ -1366,7 +1407,7 @@ version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" dependencies = [ - "der", + "der 0.6.1", "elliptic-curve", "rfc6979", "signature", @@ -1386,7 +1427,7 @@ checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" dependencies = [ "base16ct", "crypto-bigint 0.4.9", - "der", + "der 0.6.1", "digest", "ff", "generic-array", @@ -1883,7 +1924,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.7", "tokio", "tower-service", "tracing", @@ -1972,12 +2013,36 @@ dependencies = [ "http-body 1.0.1", "hyper 1.5.0", "pin-project-lite", - "socket2", + "socket2 0.5.7", "tokio", "tower-service", "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.61.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "1.5.0" @@ -2357,9 +2422,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "loop9" @@ -2742,6 +2807,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -2766,7 +2840,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" dependencies = [ - "der", + "der 0.6.1", "spki", ] @@ -3009,9 +3083,9 @@ dependencies = [ [[package]] name = "redis" -version = "0.31.0" +version = "0.32.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bc1ea653e0b2e097db3ebb5b7f678be339620b8041f66b30a308c1d45d36a7f" +checksum = "e1f66bf4cac9733a23bcdf1e0e01effbaaad208567beba68be8f67e5f4af3ee1" dependencies = [ "bytes", "cfg-if", @@ -3023,7 +3097,7 @@ dependencies = [ "pin-project-lite", "ryu", "sha1_smol", - "socket2", + "socket2 0.6.0", "tokio", "tokio-util", "url", @@ -3234,9 +3308,12 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] [[package]] name = "rustls-webpki" @@ -3306,7 +3383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" dependencies = [ "base16ct", - "der", + "der 0.6.1", "generic-array", "pkcs8", "subtle", @@ -3344,9 +3421,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "sentry" -version = "0.38.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a505499b38861edd82b5a688fa06ba4ba5875bb832adeeeba22b7b23fc4bc39a" +checksum = "989425268ab5c011e06400187eed6c298272f8ef913e49fcadc3fda788b45030" dependencies = [ "httpdate", "native-tls", @@ -3364,9 +3441,9 @@ dependencies = [ [[package]] name = "sentry-actix" -version = "0.38.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ad8bfdcfbc6e0d0dacaa5728555085ef459fa9226cfc2fe64eefa4b8038b7f" +checksum = "a5c675bdf6118764a8e265c3395c311b4d905d12866c92df52870c0223d2ffc1" dependencies = [ "actix-http", "actix-web", @@ -3377,9 +3454,9 @@ dependencies = [ [[package]] name = "sentry-backtrace" -version = "0.38.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dace796060e4ad10e3d1405b122ae184a8b2e71dce05ae450e4f81b7686b0d9" +checksum = "68e299dd3f7bcf676875eee852c9941e1d08278a743c32ca528e2debf846a653" dependencies = [ "backtrace", "regex", @@ -3388,9 +3465,9 @@ dependencies = [ [[package]] name = "sentry-contexts" -version = "0.38.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87bd9e6b51ffe2bc7188ebe36cb67557cb95749c08a3f81f33e8c9b135e0d1bc" +checksum = "fac0c5d6892cd4c414492fc957477b620026fb3411fca9fa12774831da561c88" dependencies = [ "hostname", "libc", @@ -3402,21 +3479,22 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.38.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7426d4beec270cfdbb50f85f0bb2ce176ea57eed0b11741182a163055a558187" +checksum = "deaa38b94e70820ff3f1f9db3c8b0aef053b667be130f618e615e0ff2492cbcc" dependencies = [ "rand 0.9.1", "sentry-types", "serde", "serde_json", + "url", ] [[package]] name = "sentry-debug-images" -version = "0.38.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df15c066c04f34c4dfd496a8e76590106b93283f72ef1a47d8fb24d88493424" +checksum = "00950648aa0d371c7f57057434ad5671bd4c106390df7e7284739330786a01b6" dependencies = [ "findshlibs", "sentry-core", @@ -3424,9 +3502,9 @@ dependencies = [ [[package]] name = "sentry-panic" -version = "0.38.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92beed69b776a162b6d269bef1eaa3e614090b6df45a88d9b239c4fdbffdfba" +checksum = "2b7a23b13c004873de3ce7db86eb0f59fe4adfc655a31f7bbc17fd10bacc9bfe" dependencies = [ "sentry-backtrace", "sentry-core", @@ -3434,10 +3512,11 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.38.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55c323492795de90824f3198562e33dd74ae3bc852fbb13c0cabec54a1cf73cd" +checksum = "fac841c7050aa73fc2bec8f7d8e9cb1159af0b3095757b99820823f3e54e5080" dependencies = [ + "bitflags 2.6.0", "sentry-backtrace", "sentry-core", "tracing-core", @@ -3446,9 +3525,9 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.38.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04b6c9287202294685cb1f749b944dbbce8160b81a1061ecddc073025fed129f" +checksum = "e477f4d4db08ddb4ab553717a8d3a511bc9e81dde0c808c680feacbb8105c412" dependencies = [ "debugid", "hex", @@ -3629,6 +3708,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "spin" version = "0.9.8" @@ -3642,7 +3731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" dependencies = [ "base64ct", - "der", + "der 0.6.1", ] [[package]] @@ -3852,7 +3941,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.5.7", "tokio-macros", "windows-sys 0.52.0", ] @@ -4047,15 +4136,32 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "2.10.1" +version = "3.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74fc6b57825be3373f7054754755f03ac3a8f5d70015ccad699ba2029956f4a" +checksum = "9f0fde9bc91026e381155f8c67cb354bcd35260b2f4a29bcc84639f762760c39" dependencies = [ "base64 0.22.1", + "der 0.7.10", "log", "native-tls", - "once_cell", - "url", + "percent-encoding", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-root-certs 0.26.11", +] + +[[package]] +name = "ureq-proto" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59db78ad1923f2b1be62b6da81fe80b173605ca0d57f85da2e005382adf693f7" +dependencies = [ + "base64 0.22.1", + "http 1.1.0", + "httparse", + "log", ] [[package]] @@ -4076,6 +4182,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf16_iter" version = "1.0.5" @@ -4246,6 +4358,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.2", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4ffd8df1c57e87c325000a3d6ef93db75279dc3a231125aac571650f22b12a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.8" @@ -4290,7 +4420,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ - "windows-core", + "windows-core 0.52.0", "windows-targets", ] @@ -4303,14 +4433,55 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-registry" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ - "windows-result", - "windows-strings", + "windows-result 0.2.0", + "windows-strings 0.1.0", "windows-targets", ] @@ -4323,16 +4494,34 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-strings" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-targets", ] +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index d120f68..f38e38d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "discoursio-quoter" -version = "0.1.1" +version = "0.2.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -11,12 +11,12 @@ serde_json = "1.0.115" actix-web = "4.5.1" actix-cors = "0.7.0" reqwest = { version = "0.12.3", features = ["json"] } -sentry = { version = "0.38.1", features = ["tokio"] } +sentry = { version = "0.42", features = ["tokio"] } uuid = { version = "1.8.0", features = ["v4"] } -redis = { version = "0.31.0", features = ["tokio-comp"] } +redis = { version = "0.32", features = ["tokio-comp"] } tokio = { version = "1.37.0", features = ["full"] } serde = { version = "1.0.209", features = ["derive"] } -sentry-actix = "0.38.1" +sentry-actix = "0.42" aws-sdk-s3 = "1.47.0" image = "0.25.2" mime_guess = "2.0.5" @@ -31,6 +31,7 @@ ab_glyph = "0.2.29" once_cell = "1.18" kamadak-exif = "0.6.1" infer = "0.19.0" +chrono = { version = "0.4", features = ["serde"] } [[bin]] name = "quoter" diff --git a/Dockerfile b/Dockerfile index 6e5b2e7..d5b8dd6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,6 +62,8 @@ ENV RUST_LOG=warn # Copy the build artifact from the build stage COPY --from=build /quoter/target/release/quoter . +EXPOSE 8080 + # Create healthcheck HEALTHCHECK --interval=30s --timeout=3s \ CMD curl -f http://localhost:8080/ || exit 1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8f9061c --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025 Tony Rewin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 48d4b98..5983d4d 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,119 @@ -# Квотер +# Quoter 🚀 -Управляет квотами на загрузку файлов и их размещением в S3-хранилище. Поддерживает создание миниатюр для изображений и управляет квотами на использование дискового пространства для каждого пользователя с использованием Redis. +[![Rust](https://img.shields.io/badge/Rust-1.70+-orange.svg)](https://www.rust-lang.org/) +[![Actix Web](https://img.shields.io/badge/Actix%20Web-4.0+-blue.svg)](https://actix.rs/) +[![Redis](https://img.shields.io/badge/Redis-6.0+-red.svg)](https://redis.io/) +[![S3 Compatible](https://img.shields.io/badge/S3%20Compatible-Storj%20%7C%20AWS-green.svg)](https://aws.amazon.com/s3/) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -## Основные функции +> Микросервис для управления файлами с поддержкой квот, миниатюр и интеграции с S3 хранилищами - - Миниатюры: Автоматическое создание миниатюр для изображений. - - S3/STORJ интеграция: Загрузка файлов в через `aws-sdk-s3` и возврат публичных URL-адресов. - - Управление квотами: Ограничение объема загружаемых данных для каждого пользователя с использованием Redis. - - Отслеживание файлов: Хранение информации о загруженных файлах в Redis для управления квотами. - - CORS поддержка: Встроенная поддержка кросс-доменных запросов на уровне приложения для безопасного взаимодействия с веб-интерфейсами. +Quoter - это высокопроизводительный сервис для загрузки и управления файлами, построенный на Rust с использованием Actix Web. Поддерживает автоматическое создание миниатюр, управление квотами пользователей и интеграцию с различными S3-совместимыми хранилищами. -### Как это работает +## 📖 Документация -1. **Аутентификация**: - - Клиент отправляет файл на сервер с заголовком `Authorization`, содержащим токен. Сервер проверяет наличие и валидность токена, определяя пользователя. +Подробная документация доступна в папке [`docs/`](./docs/): -2. **Загрузка файлов**: - - Сервер обрабатывает все загружаемые файлы. Если файл является изображением, создается его миниатюра. И миниатюра, и оригинальное изображение загружаются в S3. Для остальных файлов выполняется простая загрузка в S3 без создания миниатюр. +### Основные разделы +- [📚 Оглавление](./docs/README.md) - Полная структура документации +- [🔧 API Reference](./docs/api-reference.md) - Документация API +- [⚙️ Конфигурация](./docs/configuration.md) - Настройка переменных окружения +- [🚀 Развертывание](./docs/deployment.md) - Инструкции по развертыванию +- [📊 Мониторинг](./docs/monitoring.md) - Логирование и мониторинг -3. **Создание миниатюр**: - - Для всех загружаемых изображений сервер автоматически создает миниатюры размером 320x320 пикселей. Миниатюры сохраняются как отдельные файлы в том же S3 bucket, что и оригинальные изображения. +### Технические детали +- [🏗️ Архитектура](./docs/architecture.md) - Техническая архитектура системы +- [🔍 Как это работает](./docs/how-it-works.md) - Подробное описание процессов +- [💻 Разработка](./docs/development.md) - Настройка среды разработки +- [🤝 Contributing](./docs/contributing.md) - Руководство для контрибьюторов -4. **Определение MIME-типа и расширения файла**: - - MIME-тип и расширение файла определяются автоматически на основе имени файла и его содержимого с использованием библиотеки `mime_guess`. +## ✨ Основные возможности -5. **Загрузка файлов в S3**: - - Все файлы, включая миниатюры и оригиналы изображений, загружаются в указанный S3 bucket. Сформированные URL-адреса файлов возвращаются клиенту. +- 🔐 **Аутентификация** через JWT токены +- 📁 **Загрузка файлов** в S3/Storj с автоматическим определением MIME-типов +- 🖼️ **Автоматические миниатюры** для изображений (10, 40, 110, 300, 600, 800, 1400px) +- 💾 **Управление квотами** пользователей (5 ГБ по умолчанию) +- 🎨 **Оверлеи для shout** с автоматическим наложением текста +- 🔄 **CORS поддержка** для веб-приложений +- ⚡ **Высокая производительность** благодаря асинхронной архитектуре +- 📊 **Мониторинг и логирование** всех операций -6. **Управление квотами**: - - Для каждого пользователя устанавливается квота на загрузку данных, которая составляет 1 ГБ в неделю. Перед загрузкой каждого нового файла проверяется, не превысит ли его размер текущую квоту пользователя. Если квота будет превышена, загрузка файла будет отклонена. После успешной загрузки файл и его размер регистрируются в Redis, и квота пользователя обновляется. +## 🏗️ Архитектура -7. **Сохранение информации о загруженных файлах в Redis**: - - Имя каждого загруженного файла сохраняется в Redis для отслеживания загруженных пользователем файлов. Это позволяет учитывать квоты и управлять пространством, занимаемым файлами. +Quoter построен на современном стеке технологий: -8. **Оверлей для shout**: - - При загрузке файла, если он является изображением, и в запросе присутствует параметр `s=`, то к файлу будет добавлен оверлей с данными shout. +- **Backend**: Rust + Actix Web +- **База данных**: Redis для квот и кэширования +- **Хранилище**: S3-совместимые сервисы (Storj, AWS S3) +- **Аутентификация**: JWT токены через GraphQL API +- **Обработка изображений**: image-rs + imageproc -## Использование +## 📋 Требования -Нужно задать следующие переменные среды: +- **Rust**: 1.70 или выше +- **Redis**: 6.0 или выше +- **S3 совместимое хранилище**: Storj, AWS S3 или другое +- **API ядра**: для аутентификации и получения данных shout -- `REDIS_URL`: URL для подключения к Redis. Используется для управления квотами и хранения информации о загружаемых файлах. -- `CDN_DOMAIN`: Домен CDN для генерации публичных URL-адресов загруженных файлов. -- `AUTH_URL`: URL для подключения к сервису аутентификации. -- `CORE_URL`: URL для подключения к сервису core. -- `STORJ_ACCESS_KEY`, `STORJ_SECRET_KEY`, `AWS_ACCESS_KEY`, `AWS_SECRET_KEY` +## 🔧 Использование + +### Переменные окружения + +Подробная информация о настройке переменных окружения доступна в [документации по конфигурации](./docs/configuration.md). + +### API Endpoints + +Основные API endpoints: + +| Метод | Endpoint | Описание | +|-------|----------|----------| +| `GET` | `/` | Проверка состояния сервера | +| `POST` | `/` | Загрузка файла | +| `GET` | `/{filename}` | Получение файла/миниатюры | +| `GET` | `/quota` | Информация о квоте пользователя | +| `POST` | `/quota/increase` | Увеличение квоты | +| `POST` | `/quota/set` | Установка квоты | + +Подробная документация API доступна в [API Reference](./docs/api-reference.md). + +### Примеры использования + +#### Загрузка файла +```bash +curl -X POST http://localhost:8080/ \ + -H "Authorization: Bearer your-token" \ + -F "file=@image.jpg" +``` + +#### Получение миниатюры +```bash +curl http://localhost:8080/image_300.jpg +``` + +#### Увеличение квоты +```bash +curl -X POST http://localhost:8080/quota/increase \ + -H "Authorization: Bearer your-token" \ + -H "Content-Type: application/json" \ + -d '{"user_id": "user123", "additional_bytes": 1073741824}' +``` + +## 🧪 Разработка + +```bash +cargo build # сборка +cargo test # запуск тестов +cargo clippy # Проверка кода +cargo fmt # Форматирование +RUST_LOG=debug cargo run # подробные логи +``` + +### Метрики + +Основные метрики для мониторинга: + +- Количество загруженных файлов +- Использование квот пользователями +- Время ответа API +- Ошибки аутентификации +- Ошибки загрузки в S3 \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..53bb865 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,35 @@ +# Документация Quoter + +## 📚 Оглавление + +### Основные разделы +- [Как это работает](./how-it-works.md) - Подробное описание архитектуры и процессов +- [API Reference](./api-reference.md) - Полная документация API +- [Развертывание](./deployment.md) - Инструкции по развертыванию +- [Конфигурация](./configuration.md) - Настройка переменных окружения +- [Мониторинг](./monitoring.md) - Логирование и мониторинг + +### Технические детали +- [Архитектура](./architecture.md) - Техническая архитектура системы +- [База данных](./database.md) - Структура Redis и схемы данных +- [S3 интеграция](./s3-integration.md) - Работа с S3/Storj +- [Обработка изображений](./image-processing.md) - Создание миниатюр и оверлеев +- [Безопасность](./security.md) - Аутентификация и авторизация + +### Разработка +- [Разработка](./development.md) - Настройка среды разработки +- [Тестирование](./testing.md) - Руководство по тестированию +- [Contributing](./contributing.md) - Руководство для контрибьюторов + +## 🚀 Быстрый старт + +1. Установите зависимости: `cargo build` +2. Настройте переменные окружения (см. [Конфигурация](./configuration.md)) +3. Запустите сервер: `cargo run` +4. Проверьте API: `curl http://localhost:8080/` + +## 📋 Требования + +- Rust 1.70+ +- Redis 6.0+ +- Доступ к S3/Storj API \ No newline at end of file diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..bd5ee74 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,169 @@ +# API Reference + +## Обзор + +Quoter предоставляет REST API для загрузки файлов, управления квотами и получения файлов с автоматической генерацией миниатюр. + +## Базовый URL + +``` +http://localhost:8080 +``` + +## Аутентификация + +Все API endpoints (кроме получения файлов) требуют аутентификации через заголовок `Authorization`: + +``` +Authorization: Bearer +``` + +## Endpoints + +### 1. Проверка состояния сервера + +#### GET / +Проверяет работоспособность сервера. + +**Ответ:** +``` +200 OK +ok +``` + +### 2. Загрузка файлов + +#### POST / +Загружает файл в S3 хранилище. + +**Заголовки:** +``` +Authorization: Bearer +Content-Type: multipart/form-data +``` + +**Параметры:** +- `file` - файл для загрузки + +**Ответ:** +``` +200 OK +filename.ext +``` + +**Ошибки:** +- `401 Unauthorized` - неверный токен +- `413 Payload Too Large` - превышена квота +- `415 Unsupported Media Type` - неподдерживаемый тип файла + +### 3. Получение файлов + +#### GET /{filename} +Получает файл по имени. + +**Параметры запроса:** +- `s=` - добавляет оверлей с данными shout (только для изображений) + +**Примеры:** +``` +GET /image.jpg +GET /image.jpg?s=123 +GET /image_300.jpg +GET /image_300.jpg/webp +``` + +### 4. Управление квотами + +#### GET /quota +Получает информацию о квоте пользователя. + +**Параметры запроса:** +- `user_id` - ID пользователя + +**Пример:** +``` +GET /quota?user_id=user123 +``` + +**Ответ:** +```json +{ + "user_id": "user123", + "current_quota": 1073741824, + "max_quota": 5368709120 +} +``` + +#### POST /quota/increase +Увеличивает квоту пользователя. + +**Тело запроса:** +```json +{ + "user_id": "user123", + "additional_bytes": 1073741824 +} +``` + +**Ответ:** +```json +{ + "user_id": "user123", + "current_quota": 2147483648, + "max_quota": 5368709120 +} +``` + +#### POST /quota/set +Устанавливает квоту пользователя. + +**Тело запроса:** +```json +{ + "user_id": "user123", + "new_quota_bytes": 2147483648 +} +``` + +**Ответ:** +```json +{ + "user_id": "user123", + "current_quota": 2147483648, + "max_quota": 5368709120 +} +``` + +## Коды ошибок + +| Код | Описание | +|-----|----------| +| 200 | Успешный запрос | +| 400 | Неверные параметры запроса | +| 401 | Неавторизованный доступ | +| 404 | Файл не найден | +| 413 | Превышена квота | +| 415 | Неподдерживаемый тип файла | +| 500 | Внутренняя ошибка сервера | + +## Примеры использования + +### Загрузка изображения +```bash +curl -X POST http://localhost:8080/ \ + -H "Authorization: Bearer your-token" \ + -F "file=@image.jpg" +``` + +### Получение миниатюры +```bash +curl http://localhost:8080/image_300.jpg +``` + +### Увеличение квоты +```bash +curl -X POST http://localhost:8080/quota/increase \ + -H "Authorization: Bearer your-token" \ + -H "Content-Type: application/json" \ + -d '{"user_id": "user123", "additional_bytes": 1073741824}' +``` \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..580a1f4 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,196 @@ +# Архитектура + +## Обзор системы + +Quoter - это микросервис для управления файлами с поддержкой квот, миниатюр и интеграции с S3 хранилищами. + +## Компоненты системы + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Web Client │ │ Mobile App │ │ API Client │ +└─────────┬───────┘ └─────────┬───────┘ └─────────┬───────┘ + │ │ │ + └──────────────────────┼──────────────────────┘ + │ + ┌─────────────▼─────────────┐ + │ Quoter Service │ + │ (Actix Web Server) │ + └─────────────┬─────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + │ │ │ + ┌─────▼─────┐ ┌───────▼──────┐ ┌─────▼─────┐ + │ Redis │ │ Core API │ │ S3/Storj│ + │ (Quotas & │ │(Auth & Shout)│ │ (Storage) │ + │ Cache) │ │ │ │ │ + └───────────┘ └──────────────┘ └───────────┘ +``` + +## Основные модули + +### 1. AppState (`src/app_state.rs`) +Центральное состояние приложения, содержащее: +- Подключения к Redis, Storj S3, AWS S3 +- Методы для работы с квотами +- Кэширование списка файлов + +### 2. Handlers (`src/handlers/`) +Обработчики HTTP запросов: +- `upload.rs` - загрузка файлов +- `proxy.rs` - получение файлов и генерация миниатюр +- `quota.rs` - управление квотами +- `serve_file.rs` - обслуживание файлов + +### 3. Auth (`src/auth.rs`) +Аутентификация через GraphQL API ядра: +- Валидация JWT токенов +- Управление файлами пользователей в Redis + +### 4. Core (`src/core.rs`) +Интеграция с API ядра: +- Получение данных shout для оверлеев + +### 5. Thumbnail (`src/thumbnail.rs`) +Обработка изображений: +- Создание миниатюр различных размеров +- Поддержка различных форматов + +### 6. S3 Utils (`src/s3_utils.rs`) +Утилиты для работы с S3: +- Загрузка/скачивание файлов +- Определение MIME типов +- Генерация ключей + +## Поток данных + +### Загрузка файла + +```mermaid +sequenceDiagram + participant Client + participant Quoter + participant Core API + participant Redis + participant S3 + + Client->>Quoter: POST / (file + token) + Quoter->>Core API: Validate token + Core API-->>Quoter: User ID + Quoter->>Redis: Check quota + Redis-->>Quoter: Current quota + Quoter->>S3: Upload file + S3-->>Quoter: Success + Quoter->>Redis: Update quota + Quoter->>Redis: Save file info + Quoter-->>Client: Filename +``` + +### Получение файла + +```mermaid +sequenceDiagram + participant Client + participant Quoter + participant Redis + participant S3 + + Client->>Quoter: GET /filename + Quoter->>Redis: Get file path + Redis-->>Quoter: File path + Quoter->>S3: Check file exists + S3-->>Quoter: File exists + Quoter->>S3: Download file + S3-->>Quoter: File data + Quoter-->>Client: File content +``` + +## Структура данных + +### Redis схемы + +#### Квоты пользователей +``` +Key: quota:{user_id} +Type: String +Value: bytes_used (u64) +TTL: None (permanent) +``` + +#### Файлы пользователей +``` +Key: {user_id} +Type: Set +Value: [filename1, filename2, ...] +TTL: None (permanent) +``` + +#### Маппинг путей файлов +``` +Key: filepath_mapping +Type: Hash +Field: filename +Value: filepath +TTL: None (permanent) +``` + +#### Информация о файлах +``` +Key: files:{filename} +Type: String +Value: mime_type +TTL: None (permanent) +``` + +### S3 структура + +``` +bucket/ +├── original_files/ +│ ├── image1.jpg +│ ├── image2.png +│ └── document.pdf +├── thumbnails/ +│ ├── image1_10.jpg +│ ├── image1_40.jpg +│ ├── image1_110.jpg +│ ├── image1_300.jpg +│ ├── image1_600.jpg +│ ├── image1_800.jpg +│ └── image1_1400.jpg +└── webp_thumbnails/ + ├── image1_10.jpg/webp + ├── image1_40.jpg/webp + └── ... +``` + +## Безопасность + +### Аутентификация +- JWT токены через GraphQL API ядра +- Валидация токенов для всех операций записи + +### Авторизация +- Проверка квот перед загрузкой +- Изоляция файлов по пользователям + +### CORS +- Настроен для кросс-доменных запросов +- Поддержка credentials + +## Масштабирование + +### Горизонтальное масштабирование +- Stateless архитектура +- Redis как общее хранилище состояния +- S3 как общее файловое хранилище + +### Производительность +- Асинхронная обработка запросов +- Кэширование списка файлов +- Ленивая генерация миниатюр + +### Мониторинг +- Структурированное логирование +- Метрики использования квот +- Отслеживание ошибок \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..1574fbd --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,137 @@ +# Конфигурация + +## Переменные окружения + +Quoter использует следующие переменные окружения для настройки: + +### Обязательные переменные + +| Переменная | Описание | Пример | +|------------|----------|--------| +| `REDIS_URL` | URL для подключения к Redis | `redis://localhost:6379` | +| `CORE_URL` | URL для подключения к API ядра | `https://api.example.com/graphql` | +| `STORJ_ACCESS_KEY` | Ключ доступа к Storj S3 | `your-storj-access-key` | +| `STORJ_SECRET_KEY` | Секретный ключ Storj S3 | `your-storj-secret-key` | +| `AWS_ACCESS_KEY` | Ключ доступа к AWS S3 | `your-aws-access-key` | +| `AWS_SECRET_KEY` | Секретный ключ AWS S3 | `your-aws-secret-key` | + +### Опциональные переменные + +| Переменная | Описание | По умолчанию | +|------------|----------|--------------| +| `PORT` | Порт для запуска сервера | `8080` | +| `STORJ_END_POINT` | Endpoint Storj S3 | `https://gateway.storjshare.io` | +| `STORJ_BUCKET_NAME` | Имя bucket в Storj | `discours-io` | +| `AWS_END_POINT` | Endpoint AWS S3 | `https://s3.amazonaws.com` | +| `RUST_LOG` | Уровень логирования | `info` | + +## Пример .env файла + +```bash +# Redis +REDIS_URL=redis://localhost:6379 + +# Core API +CORE_URL=https://api.example.com/graphql + +# Storj S3 +STORJ_ACCESS_KEY=your-storj-access-key +STORJ_SECRET_KEY=your-storj-secret-key +STORJ_END_POINT=https://gateway.storjshare.io +STORJ_BUCKET_NAME=discours-io + +# AWS S3 +AWS_ACCESS_KEY=your-aws-access-key +AWS_SECRET_KEY=your-aws-secret-key +AWS_END_POINT=https://s3.amazonaws.com + +# Server +PORT=8080 +RUST_LOG=info +``` + +## Настройка Redis + +### Минимальная конфигурация Redis + +```redis +# redis.conf +maxmemory 2gb +maxmemory-policy allkeys-lru +save 900 1 +save 300 10 +save 60 10000 +``` + +### Проверка подключения + +```bash +redis-cli ping +``` + +## Настройка S3 + +### Storj S3 + +1. Создайте аккаунт на [Storj](https://storj.io) +2. Создайте API ключи в консоли +3. Создайте bucket для файлов +4. Настройте CORS для bucket: + +```json +{ + "CORSRules": [ + { + "AllowedOrigins": ["*"], + "AllowedMethods": ["GET", "POST", "PUT", "DELETE"], + "AllowedHeaders": ["*"], + "ExposeHeaders": ["ETag"] + } + ] +} +``` + +### AWS S3 + +1. Создайте IAM пользователя с правами S3 +2. Создайте bucket для файлов +3. Настройте CORS аналогично Storj + +## Логирование + +### Уровни логирования + +- `error` - только ошибки +- `warn` - предупреждения и ошибки +- `info` - информационные сообщения, предупреждения и ошибки +- `debug` - отладочная информация +- `trace` - максимальная детализация + +### Примеры + +```bash +# Только ошибки +RUST_LOG=error cargo run + +# Информационные сообщения +RUST_LOG=info cargo run + +# Отладка +RUST_LOG=debug cargo run +``` + +## Проверка конфигурации + +Запустите сервер и проверьте логи: + +```bash +RUST_LOG=info cargo run +``` + +Успешный запуск должен показать: + +``` +[INFO] Started +[WARN] caching AWS filelist... +[WARN] cached 1234 files +``` \ No newline at end of file diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..ca906a9 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,292 @@ +# Contributing + +## Спасибо за интерес к Quoter! 🎉 + +Мы приветствуем вклад от сообщества. Этот документ содержит руководство по участию в разработке проекта. + +## Как внести свой вклад + +### 1. Сообщить о баге + +Если вы нашли баг, создайте issue с: + +- **Кратким описанием** проблемы +- **Шагами для воспроизведения** +- **Ожидаемым и фактическим поведением** +- **Версией** Rust, Redis, и других зависимостей +- **Логами** (если применимо) + +### 2. Предложить новую функциональность + +Для предложения новой функциональности: + +- Опишите проблему, которую решает ваше предложение +- Предложите решение +- Обсудите альтернативы +- Укажите приоритет + +### 3. Внести код + +#### Подготовка + +1. **Fork** репозиторий +2. **Clone** ваш fork локально +3. Создайте **feature branch**: + ```bash + git checkout -b feature/amazing-feature + ``` + +#### Разработка + +1. **Следуйте стандартам кода**: + ```bash + cargo fmt + cargo clippy + ``` + +2. **Добавьте тесты** для новой функциональности + +3. **Обновите документацию** если необходимо + +4. **Проверьте сборку**: + ```bash + cargo build + cargo test + ``` + +#### Commit и Push + +1. **Создайте commit** с описательным сообщением: + ```bash + git commit -m "feat: add amazing feature" + ``` + +2. **Push** в ваш fork: + ```bash + git push origin feature/amazing-feature + ``` + +3. **Создайте Pull Request** + +## Стандарты кода + +### Rust + +- Следуйте [Rust Style Guide](https://doc.rust-lang.org/1.0.0/style/style/naming/README.html) +- Используйте `cargo fmt` для форматирования +- Используйте `cargo clippy` для проверки стиля +- Документируйте публичные API + +### Commit Messages + +Используйте [Conventional Commits](https://www.conventionalcommits.org/): + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +Типы: +- `feat:` - новая функциональность +- `fix:` - исправление бага +- `docs:` - изменения в документации +- `style:` - форматирование кода +- `refactor:` - рефакторинг +- `test:` - добавление тестов +- `chore:` - обновление зависимостей + +Примеры: +``` +feat: add user quota management API +fix(auth): handle expired tokens properly +docs: update API documentation +style: format code with cargo fmt +``` + +### Тестирование + +- **Unit тесты** для всех новых функций +- **Интеграционные тесты** для API endpoints +- **Тесты производительности** для критических участков +- Минимальное покрытие кода: **80%** + +### Документация + +- Обновляйте README.md если необходимо +- Добавляйте комментарии к сложному коду +- Документируйте API изменения +- Обновляйте примеры использования + +## Процесс Pull Request + +### Создание PR + +1. **Заполните шаблон** Pull Request +2. **Опишите изменения** подробно +3. **Укажите связанные issues** +4. **Добавьте скриншоты** если применимо + +### Code Review + +- **Два approval** требуются для merge +- **CI/CD** должен пройти успешно +- **Code coverage** не должен уменьшиться +- **Безопасность** проверяется автоматически + +### После Merge + +- **Feature branch** удаляется автоматически +- **Release** создается для значительных изменений +- **Документация** обновляется + +## Настройка среды разработки + +### Требования + +- Rust 1.70+ +- Redis 6.0+ +- Git + +### Установка + +```bash +# Fork и clone +git clone https://github.com/YOUR_USERNAME/quoter.git +cd quoter + +# Установка зависимостей +cargo build + +# Настройка pre-commit hooks +cargo install cargo-husky +cargo husky install +``` + +### Локальная разработка + +```bash +# Запуск Redis +docker run -d -p 6379:6379 redis:7-alpine + +# Настройка переменных окружения +cp .env.example .env +# Отредактируйте .env + +# Запуск приложения +cargo run + +# Запуск тестов +cargo test +``` + +## Структура проекта + +``` +quoter/ +├── src/ # Исходный код +│ ├── main.rs # Точка входа +│ ├── app_state.rs # Состояние приложения +│ ├── auth.rs # Аутентификация +│ ├── core.rs # API ядра +│ ├── handlers/ # HTTP обработчики +│ ├── lookup.rs # Поиск файлов +│ ├── overlay.rs # Оверлеи +│ ├── s3_utils.rs # S3 утилиты +│ └── thumbnail.rs # Миниатюры +├── docs/ # Документация +├── tests/ # Интеграционные тесты +├── Cargo.toml # Зависимости +└── README.md # Основная документация +``` + +## Роли в проекте + +### Maintainers + +- **Code review** всех PR +- **Release management** +- **Architecture decisions** +- **Community management** + +### Contributors + +- **Feature development** +- **Bug fixes** +- **Documentation** +- **Testing** + +### Reviewers + +- **Code review** assigned PRs +- **Quality assurance** +- **Performance review** + +## Коммуникация + +### Issues + +- Используйте **labels** для категоризации +- **Assign** issues к себе если работаете над ними +- **Update** статус регулярно + +### Discussions + +- **GitHub Discussions** для общих вопросов +- **RFC** для значительных изменений +- **Architecture** для архитектурных решений + +### Code Review + +- **Будьте конструктивными** +- **Объясняйте причины** изменений +- **Предлагайте альтернативы** +- **Отвечайте на комментарии** + +## Безопасность + +### Отчеты о уязвимостях + +Для критических уязвимостей: + +1. **НЕ создавайте публичный issue** +2. **Отправьте email** на security@example.com +3. **Опишите уязвимость** подробно +4. **Предложите решение** если возможно + +### Безопасность кода + +- **Не коммитьте секреты** +- **Валидируйте входные данные** +- **Используйте безопасные зависимости** +- **Проверяйте код на уязвимости** + +## Лицензия + +Внося код в проект, вы соглашаетесь с тем, что ваш вклад будет лицензирован под MIT License. + +## Благодарности + +Спасибо всем контрибьюторам, которые помогают сделать Quoter лучше! 🙏 + +### Способы поддержки + +- **Code contributions** +- **Bug reports** +- **Feature requests** +- **Documentation improvements** +- **Community support** +- **Financial support** (если применимо) + +## Контакты + +- **Issues**: [GitHub Issues](https://github.com/your-org/quoter/issues) +- **Discussions**: [GitHub Discussions](https://github.com/your-org/quoter/discussions) +- **Email**: maintainers@example.com +- **Chat**: [Discord/Slack] (если есть) + +--- + +**Спасибо за ваш вклад в Quoter!** 🚀 \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..0197bbd --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,318 @@ +# Развертывание + +## Обзор + +Quoter можно развернуть различными способами в зависимости от ваших потребностей и инфраструктуры. + +## Способы развертывания + +### 1. Docker (Рекомендуется) + +#### Сборка образа + +```bash +# Сборка production образа +docker build -t quoter:latest . + +# Сборка с тегами +docker build -t quoter:v1.0.0 . +``` + +#### Запуск контейнера + +```bash +docker run -d \ + --name quoter \ + -p 8080:8080 \ + -e REDIS_URL=redis://redis:6379 \ + -e CORE_URL=https://api.example.com/graphql \ + -e STORJ_ACCESS_KEY=your-key \ + -e STORJ_SECRET_KEY=your-secret \ + -e AWS_ACCESS_KEY=your-aws-key \ + -e AWS_SECRET_KEY=your-aws-secret \ + quoter:latest +``` + +#### Docker Compose + +Создайте `docker-compose.yml`: + +```yaml +version: '3.8' + +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + + quoter: + build: . + ports: + - "8080:8080" + environment: + - REDIS_URL=redis://redis:6379 + - CORE_URL=https://api.example.com/graphql + - STORJ_ACCESS_KEY=${STORJ_ACCESS_KEY} + - STORJ_SECRET_KEY=${STORJ_SECRET_KEY} + - AWS_ACCESS_KEY=${AWS_ACCESS_KEY} + - AWS_SECRET_KEY=${AWS_SECRET_KEY} + - RUST_LOG=info + depends_on: + - redis + restart: unless-stopped + +volumes: + redis_data: +``` + +Запуск: + +```bash +docker-compose up -d +``` + +### 2. Kubernetes + +#### Deployment + +Создайте `k8s/deployment.yaml`: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: quoter + labels: + app: quoter +spec: + replicas: 3 + selector: + matchLabels: + app: quoter + template: + metadata: + labels: + app: quoter + spec: + containers: + - name: quoter + image: quoter:latest + ports: + - containerPort: 8080 + env: + - name: REDIS_URL + value: "redis://redis-service:6379" + - name: CORE_URL + value: "https://api.example.com/graphql" + - name: STORJ_ACCESS_KEY + valueFrom: + secretKeyRef: + name: quoter-secrets + key: storj-access-key + - name: STORJ_SECRET_KEY + valueFrom: + secretKeyRef: + name: quoter-secrets + key: storj-secret-key + - name: AWS_ACCESS_KEY + valueFrom: + secretKeyRef: + name: quoter-secrets + key: aws-access-key + - name: AWS_SECRET_KEY + valueFrom: + secretKeyRef: + name: quoter-secrets + key: aws-secret-key + - name: RUST_LOG + value: "info" + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 +``` + +#### Service + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: quoter-service +spec: + selector: + app: quoter + ports: + - protocol: TCP + port: 80 + targetPort: 8080 + type: LoadBalancer +``` + +#### Secrets + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: quoter-secrets +type: Opaque +data: + storj-access-key: + storj-secret-key: + aws-access-key: + aws-secret-key: +``` + +### 3. Systemd (Linux) + +#### Создание сервиса + +Создайте `/etc/systemd/system/quoter.service`: + +```ini +[Unit] +Description=Quoter File Service +After=network.target redis.service + +[Service] +Type=simple +User=quoter +Group=quoter +WorkingDirectory=/opt/quoter +Environment=REDIS_URL=redis://localhost:6379 +Environment=CORE_URL=https://api.example.com/graphql +Environment=STORJ_ACCESS_KEY=your-key +Environment=STORJ_SECRET_KEY=your-secret +Environment=AWS_ACCESS_KEY=your-aws-key +Environment=AWS_SECRET_KEY=your-aws-secret +Environment=RUST_LOG=info +ExecStart=/opt/quoter/quoter +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +#### Управление сервисом + +```bash +# Создание пользователя +sudo useradd -r -s /bin/false quoter + +# Копирование бинарного файла +sudo cp target/release/quoter /opt/quoter/ +sudo chown quoter:quoter /opt/quoter/quoter + +# Включение и запуск сервиса +sudo systemctl daemon-reload +sudo systemctl enable quoter +sudo systemctl start quoter + +# Проверка статуса +sudo systemctl status quoter +``` + +## Мониторинг и логирование + +### Prometheus метрики + +Добавьте в `Cargo.toml`: + +```toml +[dependencies] +prometheus = "0.13" +actix-web-prom = "0.6" +``` + +### Grafana дашборд + +Создайте дашборд для мониторинга: + +- Количество запросов в секунду +- Время ответа API +- Использование памяти и CPU +- Ошибки по типам +- Использование квот + +### Логирование + +#### Структурированные логи + +```bash +# JSON формат для ELK stack +RUST_LOG=info cargo run | jq . +``` + +#### Ротация логов + +Настройте logrotate: + +``` +/var/log/quoter/*.log { + daily + missingok + rotate 52 + compress + delaycompress + notifempty + create 644 quoter quoter + postrotate + systemctl reload quoter + endscript +} +``` + +## Масштабирование + +### Горизонтальное масштабирование + +1. **Load Balancer**: Настройте nginx или HAProxy +2. **Redis Cluster**: Для высоких нагрузок +3. **S3 CDN**: Для статических файлов + +### Вертикальное масштабирование + +- Увеличьте ресурсы контейнера/сервера +- Настройте пул соединений Redis +- Оптимизируйте размер изображений + +## Безопасность + +### Сетевая безопасность + +- Используйте HTTPS в продакшене +- Настройте firewall +- Ограничьте доступ к Redis + +### Секреты + +- Используйте Kubernetes Secrets или Docker Secrets +- Не храните секреты в коде +- Ротация ключей доступа + +### Аудит + +- Логируйте все операции с файлами +- Отслеживайте использование квот +- Мониторьте подозрительную активность \ No newline at end of file diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..29b4cbf --- /dev/null +++ b/docs/development.md @@ -0,0 +1,422 @@ +# Разработка + +## Настройка среды разработки + +### Требования + +- **Rust**: 1.70 или выше +- **Redis**: 6.0 или выше (локально или Docker) +- **Git**: для работы с репозиторием +- **IDE**: VS Code, IntelliJ IDEA или другой редактор с поддержкой Rust + +### Установка Rust + +```bash +# Установка Rust через rustup +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Перезагрузка shell +source ~/.bashrc + +# Проверка установки +rustc --version +cargo --version +``` + +### Клонирование репозитория + +```bash +git clone https://github.com/your-org/quoter.git +cd quoter +``` + +### Установка зависимостей + +```bash +# Сборка проекта +cargo build + +# Установка дополнительных инструментов +cargo install cargo-watch # для автоматической пересборки +cargo install cargo-audit # для проверки безопасности +cargo install cargo-tarpaulin # для покрытия кода тестами +``` + +## Структура проекта + +``` +quoter/ +├── src/ +│ ├── main.rs # Точка входа приложения +│ ├── app_state.rs # Состояние приложения и подключения +│ ├── auth.rs # Аутентификация и авторизация +│ ├── core.rs # Интеграция с API ядра +│ ├── lookup.rs # Поиск и определение MIME-типов +│ ├── overlay.rs # Генерация оверлеев для изображений +│ ├── s3_utils.rs # Утилиты для работы с S3 +│ ├── thumbnail.rs # Создание миниатюр +│ └── handlers/ # HTTP обработчики +│ ├── mod.rs # Модуль обработчиков +│ ├── upload.rs # Загрузка файлов +│ ├── proxy.rs # Получение файлов +│ ├── quota.rs # Управление квотами +│ └── serve_file.rs # Обслуживание файлов +├── docs/ # Документация +├── tests/ # Интеграционные тесты +├── Cargo.toml # Зависимости и конфигурация +├── Cargo.lock # Фиксированные версии зависимостей +├── Dockerfile # Docker образ +└── README.md # Основная документация +``` + +## Локальная разработка + +### Настройка переменных окружения + +Создайте файл `.env` в корне проекта: + +```bash +# Redis (локально или Docker) +REDIS_URL=redis://localhost:6379 + +# Core API (замените на ваш endpoint) +CORE_URL=https://api.example.com/graphql + +# Storj S3 (тестовые ключи) +STORJ_ACCESS_KEY=your-test-key +STORJ_SECRET_KEY=your-test-secret +STORJ_BUCKET_NAME=test-bucket + +# AWS S3 (тестовые ключи) +AWS_ACCESS_KEY=your-test-aws-key +AWS_SECRET_KEY=your-test-aws-secret + +# Server +PORT=8080 +RUST_LOG=debug +``` + +### Запуск Redis + +#### Локально +```bash +# Ubuntu/Debian +sudo apt-get install redis-server +sudo systemctl start redis-server + +# macOS +brew install redis +brew services start redis + +# Проверка +redis-cli ping +``` + +#### Docker +```bash +docker run -d \ + --name redis-dev \ + -p 6379:6379 \ + redis:7-alpine +``` + +### Запуск приложения + +```bash +# Обычный запуск +cargo run + +# С автоматической пересборкой +cargo watch -x run + +# В режиме отладки +RUST_LOG=debug cargo run + +# С профилированием +cargo run --release +``` + +### Проверка работоспособности + +```bash +# Проверка сервера +curl http://localhost:8080/ + +# Проверка загрузки файла (требует токен) +curl -X POST http://localhost:8080/ \ + -H "Authorization: Bearer your-token" \ + -F "file=@test-image.jpg" +``` + +## Тестирование + +### Unit тесты + +```bash +# Запуск всех тестов +cargo test + +# Запуск тестов с выводом +cargo test -- --nocapture + +# Запуск конкретного теста +cargo test test_upload_file + +# Запуск тестов в параллельном режиме +cargo test -- --test-threads=4 +``` + +### Интеграционные тесты + +Создайте файл `tests/integration_test.rs`: + +```rust +use actix_web::{test, web, App}; +use quoter::app_state::AppState; + +#[actix_web::test] +async fn test_upload_endpoint() { + let app_state = AppState::new().await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_state)) + .route("/", web::post().to(upload_handler)) + ).await; + + let req = test::TestRequest::post() + .uri("/") + .insert_header(("Authorization", "Bearer test-token")) + .set_form(("file", "test-data")) + .to_request(); + + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); +} +``` + +### Тестирование производительности + +```bash +# Бенчмарки (если настроены) +cargo bench + +# Профилирование с flamegraph +cargo install flamegraph +cargo flamegraph --bin quoter +``` + +## Отладка + +### Логирование + +```rust +use log::{debug, info, warn, error}; + +// В коде +debug!("Processing file: {}", filename); +info!("File uploaded successfully"); +warn!("User quota is getting low: {} bytes", quota); +error!("Failed to upload file: {}", e); +``` + +### Отладка с GDB + +```bash +# Компиляция с отладочной информацией +cargo build + +# Запуск с GDB +gdb target/debug/quoter + +# В GDB +(gdb) break main +(gdb) run +(gdb) continue +``` + +### Отладка с LLDB (macOS) + +```bash +lldb target/debug/quoter +(lldb) breakpoint set --name main +(lldb) run +``` + +## Проверка кода + +### Clippy + +```bash +# Проверка стиля кода +cargo clippy + +# Проверка с дополнительными предупреждениями +cargo clippy -- -D warnings + +# Автоматическое исправление +cargo clippy --fix +``` + +### Форматирование + +```bash +# Форматирование кода +cargo fmt + +# Проверка форматирования +cargo fmt -- --check +``` + +### Проверка безопасности + +```bash +# Аудит зависимостей +cargo audit + +# Проверка уязвимостей +cargo audit --deny warnings +``` + +## Покрытие кода + +### Tarpaulin + +```bash +# Установка +cargo install cargo-tarpaulin + +# Запуск +cargo tarpaulin + +# С HTML отчетом +cargo tarpaulin --out Html +``` + +### grcov + +```bash +# Установка +cargo install grcov + +# Настройка переменных +export CARGO_INCREMENTAL=0 +export RUSTFLAGS="-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests" +export RUSTDOCFLAGS="-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests" + +# Запуск тестов +cargo test + +# Генерация отчета +grcov . -s . --binary-path ./target/debug/ -t html --branch --ignore-not-existing -o ./coverage/ +``` + +## Git workflow + +### Создание feature branch + +```bash +# Создание новой ветки +git checkout -b feature/new-feature + +# Внесение изменений +# ... + +# Коммит изменений +git add . +git commit -m "feat: add new feature" + +# Push в репозиторий +git push origin feature/new-feature +``` + +### Commit conventions + +Используйте [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` - новая функциональность +- `fix:` - исправление багов +- `docs:` - изменения в документации +- `style:` - форматирование кода +- `refactor:` - рефакторинг кода +- `test:` - добавление тестов +- `chore:` - обновление зависимостей, конфигурации + +### Pull Request + +1. Создайте Pull Request в GitHub/GitLab +2. Добавьте описание изменений +3. Укажите связанные issues +4. Дождитесь code review +5. Исправьте замечания если есть +6. Получите approval и merge + +## Полезные команды + +### Cargo + +```bash +# Обновление зависимостей +cargo update + +# Очистка сборки +cargo clean + +# Проверка зависимостей +cargo tree + +# Документация +cargo doc --open + +# Проверка типов без компиляции +cargo check +``` + +### Отладка + +```bash +# Просмотр логов в реальном времени +tail -f logs/quoter.log + +# Мониторинг ресурсов +htop +iotop + +# Сетевые соединения +netstat -tulpn | grep 8080 +``` + +### Docker + +```bash +# Сборка для разработки +docker build -t quoter:dev . + +# Запуск с volume для hot reload +docker run -v $(pwd):/app -p 8080:8080 quoter:dev + +# Просмотр логов контейнера +docker logs -f quoter-container +``` + +## Рекомендации + +### Производительность + +1. Используйте `cargo build --release` для production +2. Настройте профилирование для критических участков +3. Мониторьте использование памяти и CPU +4. Оптимизируйте размер изображений + +### Безопасность + +1. Регулярно обновляйте зависимости +2. Используйте `cargo audit` для проверки уязвимостей +3. Не храните секреты в коде +4. Валидируйте все входные данные + +### Качество кода + +1. Пишите тесты для новой функциональности +2. Используйте `cargo clippy` для проверки стиля +3. Документируйте публичные API +4. Следуйте принципам SOLID \ No newline at end of file diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..922abe7 --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,25 @@ +### Как это работает + +1. **Аутентификация**: + - Клиент отправляет файл на сервер с заголовком `Authorization`, содержащим токен. Сервер проверяет наличие и валидность токена через API ядра, определяя пользователя. + +2. **Загрузка файлов**: + - Сервер обрабатывает все загружаемые файлы. Если файл является изображением, создается его миниатюра. И миниатюра, и оригинальное изображение загружаются в S3. Для остальных файлов выполняется простая загрузка в S3 без создания миниатюр. + +3. **Создание миниатюр**: + - Для всех загружаемых изображений сервер автоматически создает миниатюры различных размеров (10, 40, 110, 300, 600, 800, 1400 пикселей по ширине). Миниатюры сохраняются как отдельные файлы в том же S3 bucket, что и оригинальные изображения. + +4. **Определение MIME-типа и расширения файла**: + - MIME-тип и расширение файла определяются автоматически на основе имени файла и его содержимого с использованием библиотеки `mime_guess`. + +5. **Загрузка файлов в S3**: + - Все файлы, включая миниатюры и оригиналы изображений, загружаются в указанный S3 bucket. Сформированные URL-адреса файлов возвращаются клиенту. + +6. **Управление квотами**: + - Для каждого пользователя устанавливается общая квота на загрузку данных, которая составляет 5 ГБ. Перед загрузкой каждого нового файла проверяется, не превысит ли его размер текущую квоту пользователя. Если квота будет превышена, загрузка файла будет отклонена. После успешной загрузки файл и его размер регистрируются в Redis, и квота пользователя обновляется. + +7. **Сохранение информации о загруженных файлах в Redis**: + - Имя каждого загруженного файла сохраняется в Redis для отслеживания загруженных пользователем файлов. Это позволяет учитывать квоты и управлять пространством, занимаемым файлами. + +8. **Оверлей для shout**: + - При загрузке файла, если он является изображением, и в запросе присутствует параметр `s=`, то к файлу будет добавлен оверлей с данными shout. diff --git a/docs/monitoring.md b/docs/monitoring.md new file mode 100644 index 0000000..3771498 --- /dev/null +++ b/docs/monitoring.md @@ -0,0 +1,341 @@ +# Мониторинг + +## Обзор + +Мониторинг Quoter включает в себя логирование, метрики производительности и отслеживание состояния системы. + +## Логирование + +### Уровни логирования + +Quoter использует библиотеку `log` с различными уровнями: + +- **error** - Критические ошибки, требующие немедленного внимания +- **warn** - Предупреждения, которые могут указывать на проблемы +- **info** - Информационные сообщения о нормальной работе +- **debug** - Отладочная информация для разработчиков +- **trace** - Максимальная детализация для глубокой отладки + +### Настройка логирования + +```bash +# Только ошибки +RUST_LOG=error cargo run + +# Предупреждения и ошибки +RUST_LOG=warn cargo run + +# Информационные сообщения (рекомендуется для продакшена) +RUST_LOG=info cargo run + +# Отладка +RUST_LOG=debug cargo run + +# Максимальная детализация +RUST_LOG=trace cargo run +``` + +### Структура логов + +#### Загрузка файла +``` +[INFO] Started +[WARN] file abc123.jpg uploaded to storj, incrementing quota by 1048576 bytes +[WARN] New quota for user user123: 2097152 bytes +``` + +#### Получение файла +``` +[WARN] >>> GET image_300.jpg [START] +[WARN] detected file extension: jpg +[WARN] base_filename: image +[WARN] requested width: 300 +[WARN] Found stored path in DB: production/image/image.jpg +[WARN] File exists in Storj: production/image/image.jpg +[WARN] Processing image file with width: 300 +[WARN] Calculated closest width: 300 for requested: 300 +[WARN] serve existed thumb file: image_300.jpg +``` + +#### Ошибки +``` +[ERROR] Failed to upload to Storj: image.jpg - Error: Network error +[ERROR] Database error while getting path: image.jpg - Full error: Connection timeout +[ERROR] unsupported file format +``` + +## Метрики + +### Основные метрики для мониторинга + +#### Производительность +- **Requests per second (RPS)** - количество запросов в секунду +- **Response time** - время ответа API +- **Error rate** - процент ошибок +- **Upload success rate** - процент успешных загрузок + +#### Ресурсы +- **Memory usage** - использование памяти +- **CPU usage** - использование процессора +- **Disk I/O** - операции с диском +- **Network I/O** - сетевой трафик + +#### Бизнес-метрики +- **Files uploaded** - количество загруженных файлов +- **Quota usage** - использование квот пользователями +- **Thumbnail generation** - количество сгенерированных миниатюр +- **Storage usage** - использование хранилища + +### Prometheus метрики + +Добавьте в `Cargo.toml`: + +```toml +[dependencies] +prometheus = "0.13" +actix-web-prom = "0.6" +``` + +Настройте в `main.rs`: + +```rust +use actix_web_prom::{PrometheusMetrics, PrometheusMetricsBuilder}; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + let prometheus = PrometheusMetricsBuilder::new("quoter") + .endpoint("/metrics") + .build() + .unwrap(); + + HttpServer::new(move || { + App::new() + .wrap(prometheus.clone()) + // ... остальные настройки + }) + .bind(addr)? + .run() + .await +} +``` + +### Кастомные метрики + +```rust +use prometheus::{Counter, Histogram, Registry}; + +lazy_static! { + pub static ref UPLOAD_COUNTER: Counter = Counter::new( + "quoter_uploads_total", + "Total number of file uploads" + ).unwrap(); + + pub static ref UPLOAD_SIZE: Histogram = Histogram::new( + "quoter_upload_size_bytes", + "File upload size in bytes" + ).unwrap(); + + pub static ref QUOTA_USAGE: Histogram = Histogram::new( + "quoter_quota_usage_bytes", + "User quota usage in bytes" + ).unwrap(); +} +``` + +## Алерты + +### Критические алерты + +- **Service down** - сервис недоступен +- **High error rate** - высокий процент ошибок (>5%) +- **High response time** - медленные ответы (>2s) +- **Memory usage high** - высокое использование памяти (>80%) +- **Redis connection failed** - потеря соединения с Redis + +### Предупреждения + +- **Quota usage high** - пользователи приближаются к лимиту квоты +- **Storage usage high** - высокое использование хранилища +- **Thumbnail generation slow** - медленная генерация миниатюр + +### Настройка алертов в Prometheus + +```yaml +groups: +- name: quoter + rules: + - alert: QuoterServiceDown + expr: up{job="quoter"} == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "Quoter service is down" + + - alert: HighErrorRate + expr: rate(http_requests_total{job="quoter",status=~"5.."}[5m]) > 0.05 + for: 2m + labels: + severity: warning + annotations: + summary: "High error rate detected" + + - alert: HighResponseTime + expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket{job="quoter"}[5m])) > 2 + for: 5m + labels: + severity: warning + annotations: + summary: "High response time detected" +``` + +## Дашборды + +### Grafana дашборд + +Создайте дашборд с панелями: + +#### Обзор системы +- Статус сервиса (up/down) +- Количество запросов в секунду +- Время ответа (p50, p95, p99) +- Процент ошибок + +#### Загрузка файлов +- Количество загрузок в час/день +- Размер загружаемых файлов +- Успешность загрузок +- Использование квот + +#### Ресурсы +- Использование CPU и памяти +- Сетевой трафик +- Операции с диском +- Соединения с Redis + +#### Бизнес-метрики +- Топ пользователей по использованию квоты +- Популярные размеры миниатюр +- Использование хранилища по типам файлов + +### Пример запроса для Grafana + +```sql +-- Количество загрузок по часам +SELECT + time_bucket('1 hour', timestamp) AS time, + COUNT(*) as uploads +FROM quoter_uploads +WHERE timestamp > NOW() - INTERVAL '24 hours' +GROUP BY time +ORDER BY time; +``` + +## Здоровье системы + +### Health check endpoint + +Добавьте endpoint для проверки здоровья: + +```rust +async fn health_check() -> HttpResponse { + // Проверка Redis + let redis_ok = check_redis_connection().await; + + // Проверка S3 + let s3_ok = check_s3_connection().await; + + if redis_ok && s3_ok { + HttpResponse::Ok().json(json!({ + "status": "healthy", + "timestamp": chrono::Utc::now(), + "services": { + "redis": "ok", + "s3": "ok" + } + })) + } else { + HttpResponse::ServiceUnavailable().json(json!({ + "status": "unhealthy", + "timestamp": chrono::Utc::now(), + "services": { + "redis": if redis_ok { "ok" } else { "error" }, + "s3": if s3_ok { "ok" } else { "error" } + } + })) + } +} +``` + +### Kubernetes liveness/readiness probes + +```yaml +livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 +``` + +## Трассировка + +### Distributed tracing + +Для отслеживания запросов через микросервисы: + +```toml +[dependencies] +opentelemetry = "0.20" +tracing = "0.1" +tracing-opentelemetry = "0.20" +``` + +### Логирование с контекстом + +```rust +use tracing::{info, warn, error, instrument}; + +#[instrument(skip(state))] +async fn upload_handler( + req: HttpRequest, + payload: Multipart, + state: web::Data, +) -> Result { + let user_id = get_user_id(&req).await?; + info!(user_id = %user_id, "Starting file upload"); + + // ... логика загрузки + + info!(user_id = %user_id, filename = %filename, "File uploaded successfully"); + Ok(response) +} +``` + +## Рекомендации + +### Продакшен + +1. **Логирование**: Используйте структурированные логи в JSON формате +2. **Метрики**: Настройте Prometheus + Grafana +3. **Алерты**: Настройте уведомления для критических событий +4. **Ротация логов**: Настройте logrotate или отправку в ELK stack +5. **Мониторинг ресурсов**: Отслеживайте CPU, память, диск, сеть + +### Разработка + +1. **Локальное логирование**: Используйте `RUST_LOG=debug` +2. **Отладка**: Включите trace логи для детальной отладки +3. **Тестирование**: Создайте тесты для проверки метрик +4. **Документация**: Документируйте все кастомные метрики \ No newline at end of file diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..3b4e50c --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,198 @@ +# Тестирование + +Этот документ описывает подход к тестированию проекта Quoter. + +## Обзор + +Проект использует интеграционные тесты для проверки функциональности без внешних зависимостей. Тесты написаны на Rust с использованием фреймворка Actix Web для тестирования HTTP endpoints. + +## Запуск тестов + +### Все тесты +```bash +cargo test --tests # все +cargo test --test basic_test test_health_check # конкретный тест +cargo test --tests -- --nocapture # Тесты с выводом +``` + +## Описание тестов + +### 1. Health Check (`test_health_check`) +Проверяет работу основного endpoint `/`: +- GET запрос возвращает статус 200 и тело "ok" +- POST запрос возвращает статус 404 (не найден) + +### 2. JSON Сериализация (`test_json_serialization`) +Проверяет корректность сериализации и десериализации JSON: +- Создание структуры с данными квоты +- Сериализация в JSON строку +- Десериализация обратно в структуру +- Проверка соответствия данных + +### 3. Multipart Form Data (`test_multipart_form_data`) +Проверяет создание multipart form data для загрузки файлов: +- Формирование правильного boundary +- Добавление заголовков Content-Disposition +- Добавление содержимого файла +- Проверка корректности структуры + +### 4. UUID Генерация (`test_uuid_generation`) +Проверяет работу с UUID: +- Генерация уникальных UUID +- Проверка формата (36 символов с дефисами) +- Парсинг UUID обратно + +### 5. MIME Типы (`test_mime_type_detection`) +Проверяет определение MIME типов по расширениям файлов: +- Поддерживаемые форматы (jpg, png, gif, webp, mp3, wav, mp4) +- Неподдерживаемые форматы (pdf, txt) +- Регистронезависимость + +### 6. Парсинг путей файлов (`test_file_path_parsing`) +Проверяет парсинг путей файлов с размерами: +- Извлечение базового имени, ширины и расширения +- Обработка путей без размеров +- Обработка путей с подчеркиваниями + +### 7. Расчеты квот (`test_quota_calculations`) +Проверяет логику расчета квот: +- Различные сценарии использования квоты +- Проверка превышения лимитов +- Корректность математических операций + +### 8. Форматирование размеров (`test_file_size_formatting`) +Проверяет форматирование размеров файлов: +- Байты, килобайты, мегабайты, гигабайты +- Правильное округление +- Корректные единицы измерения + +### 9. Обработка ошибок (`test_error_handling`) +Проверяет обработку некорректных данных: +- Неверный JSON +- Неполный JSON +- Неверные UUID +- Пустые значения + +### 10. Производительность (`test_performance`) +Проверяет производительность критических операций: +- Генерация UUID (должна быть < 1μs) +- JSON сериализация (должна быть < 100μs) +- Вывод статистики производительности + +## Принципы тестирования + +### 1. Изоляция +- Тесты не зависят от внешних сервисов (Redis, S3) +- Каждый тест независим от других +- Используются моки и заглушки + +### 2. Покрытие +- Тестируются основные функции +- Проверяются граничные случаи +- Тестируется обработка ошибок + +### 3. Производительность +- Тесты должны выполняться быстро +- Проверяется производительность критических операций +- Устанавливаются временные лимиты + +### 4. Читаемость +- Понятные названия тестов +- Описательные сообщения об ошибках +- Комментарии к сложной логике + +## Добавление новых тестов + +### 1. Создание теста +```rust +#[test] +async fn test_new_feature() { + // Подготовка + let test_data = create_test_data(); + + // Выполнение + let result = process_data(test_data); + + // Проверка + assert!(result.is_ok()); + assert_eq!(result.unwrap(), expected_value); +} +``` + +### 2. Тестирование HTTP endpoints +```rust +#[actix_web::test] +async fn test_http_endpoint() { + let app = test::init_service( + App::new() + .route("/test", web::get().to(test_handler)) + ).await; + + let req = test::TestRequest::get() + .uri("/test") + .to_request(); + + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); +} +``` + +### 3. Тестирование производительности +```rust +#[test] +async fn test_performance() { + use std::time::Instant; + + let start = Instant::now(); + let iterations = 10000; + + for _ in 0..iterations { + // Тестируемая операция + } + + let duration = start.elapsed(); + let avg_time = duration.as_micros() as f64 / iterations as f64; + + assert!(avg_time < 100.0, "Operation too slow: {:.2} μs", avg_time); +} +``` + +## Лучшие практики + +### 1. Именование +- Используйте описательные имена тестов +- Группируйте связанные тесты +- Используйте префиксы для типов тестов + +### 2. Организация +- Разделяйте тесты на логические группы +- Используйте модули для организации +- Документируйте сложные тесты + +### 3. Надежность +- Избегайте хрупких тестов +- Не полагайтесь на порядок выполнения +- Очищайте состояние после тестов + +### 4. Производительность +- Минимизируйте время выполнения +- Используйте параллельное выполнение +- Оптимизируйте медленные тесты + +## Отладка тестов + +```bash +RUST_LOG=debug cargo test --tests -- --nocapture # Вывод отладочной информации +cargo test --tests -- --nocapture --test-threads=1 # Продолжение после ошибки +``` + +## Покрытие кода + +```bash +cargo install cargo-tarpaulin # Установка cargo-tarpaulin +cargo tarpaulin --tests # Запуск анализа покрытия +``` + +## CI/CD интеграция + +Тесты автоматически запускаются в CI/CD pipeline diff --git a/nginx.conf.sigil b/nginx.conf.sigil deleted file mode 100644 index 5471c60..0000000 --- a/nginx.conf.sigil +++ /dev/null @@ -1,52 +0,0 @@ -{{ $proxy_settings := "proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; proxy_set_header Host $http_host; proxy_set_header X-Request-Start $msec;" }} - -{{ range $port_map := .PROXY_PORT_MAP | split " " }} -{{ $port_map_list := $port_map | split ":" }} -{{ $scheme := index $port_map_list 0 }} -{{ $listen_port := index $port_map_list 1 }} -{{ $upstream_port := index $port_map_list 2 }} - -server { - {{ if eq $scheme "http" }} - listen [::]:{{ $listen_port }}; - listen {{ $listen_port }}; - server_name {{ $.NOSSL_SERVER_NAME }}; - client_max_body_size 100M; - - {{ else if eq $scheme "https" }} - listen [::]:{{ $listen_port }} ssl http2; - listen {{ $listen_port }} ssl http2; - server_name {{ $.NOSSL_SERVER_NAME }}; - ssl_certificate {{ $.APP_SSL_PATH }}/server.crt; - ssl_certificate_key {{ $.APP_SSL_PATH }}/server.key; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_prefer_server_ciphers off; - client_max_body_size 100M; - {{ end }} - - location / { - proxy_pass http://{{ $.APP }}-{{ $upstream_port }}; - {{ $proxy_settings }} - } - - # Browser caching for media files - location ~* \.(jpg|jpeg|png|gif|ico|webp|mp3|wav|ogg|flac|aac|aif|webm|pdf)$ { - proxy_pass http://{{ $.APP }}-{{ $upstream_port }}; - expires 30d; - add_header Cache-Control "public, immutable"; - } - - include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; -} -{{ end }} - -{{ range $upstream_port := $.PROXY_UPSTREAM_PORTS | split " " }} -upstream {{ $.APP }}-{{ $upstream_port }} { -{{ range $listeners := $.DOKKU_APP_WEB_LISTENERS | split " " }} -{{ $listener_list := $listeners | split ":" }} -{{ $listener_ip := index $listener_list 0 }} -{{ $listener_port := index $listener_list 1 }} - server {{ $listener_ip }}:{{ $upstream_port }}; -{{ end }} -} -{{ end }} diff --git a/src/app_state.rs b/src/app_state.rs index c0b5d6f..11eef7b 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,22 +1,22 @@ +use crate::s3_utils::get_s3_filelist; use actix_web::error::ErrorInternalServerError; use aws_config::BehaviorVersion; use aws_sdk_s3::{config::Credentials, Client as S3Client}; +use log::warn; use redis::{aio::MultiplexedConnection, AsyncCommands, Client as RedisClient}; use std::env; -use log::warn; -use crate::s3_utils::get_s3_filelist; - #[derive(Clone)] pub struct AppState { pub redis: MultiplexedConnection, pub storj_client: S3Client, pub aws_client: S3Client, - pub bucket: String + pub bucket: String, } const PATH_MAPPING_KEY: &str = "filepath_mapping"; // Ключ для хранения маппинга путей -const WEEK_SECONDS: u64 = 604800; + // Убираем TTL для квоты - она должна быть постоянной на пользователя +const QUOTA_TTL: u64 = 0; // 0 означает отсутствие TTL impl AppState { /// Инициализация нового состояния приложения. @@ -40,7 +40,7 @@ impl AppState { let aws_access_key = env::var("AWS_ACCESS_KEY").expect("AWS_ACCESS_KEY must be set"); let aws_secret_key = env::var("AWS_SECRET_KEY").expect("AWS_SECRET_KEY must be set"); let aws_endpoint = - env::var("AWS_END_POINT").unwrap_or_else(|_| "https://s3.amazonaws.com".to_string()); + env::var("AWS_END_POINT").unwrap_or_else(|_| "https://s3.amazonaws.com".to_string()); // Конфигурируем клиент S3 для Storj let storj_config = aws_config::defaults(BehaviorVersion::latest()) @@ -71,14 +71,14 @@ impl AppState { )) .load() .await; - + let aws_client = S3Client::new(&aws_config); let app_state = AppState { redis: redis_connection, storj_client, aws_client, - bucket + bucket, }; // Кэшируем список файлов из AWS при старте приложения @@ -91,7 +91,7 @@ impl AppState { pub async fn cache_filelist(&self) { warn!("caching AWS filelist..."); let mut redis = self.redis.clone(); - + // Запрашиваем список файлов из Storj S3 let filelist = get_s3_filelist(&self.aws_client, &self.bucket).await; @@ -105,7 +105,7 @@ impl AppState { warn!("cached {} files", filelist.len()); } - + /// Получает путь из ключа (имени файла) в Redis. pub async fn get_path(&self, filename: &str) -> Result, actix_web::Error> { let mut redis = self.redis.clone(); @@ -133,9 +133,9 @@ impl AppState { let quota: u64 = redis.get("a_key).await.unwrap_or(0); if quota == 0 { - // Если квота не найдена, устанавливаем её в 0 байт и задаем TTL на одну неделю + // Если квота не найдена, устанавливаем её в 0 байт без TTL (постоянная квота) redis - .set_ex::<&str, u64, ()>("a_key, 0, WEEK_SECONDS) + .set::<&str, u64, ()>("a_key, 0) .await .map_err(|_| { ErrorInternalServerError("Failed to set initial user quota in Redis") @@ -161,10 +161,10 @@ impl AppState { ErrorInternalServerError("Failed to check if user quota exists in Redis") })?; - // Если ключ не существует, создаем его с начальным значением и устанавливаем TTL + // Если ключ не существует, создаем его с начальным значением без TTL if !exists { redis - .set_ex::<_, u64, ()>("a_key, bytes, WEEK_SECONDS) + .set::<_, u64, ()>("a_key, bytes) .await .map_err(|_| { ErrorInternalServerError("Failed to set initial user quota in Redis") @@ -180,4 +180,42 @@ impl AppState { Ok(new_quota) } + + /// Устанавливает квоту пользователя в байтах (позволяет увеличить или уменьшить) + pub async fn set_user_quota(&self, user_id: &str, bytes: u64) -> Result { + let mut redis = self.redis.clone(); + let quota_key = format!("quota:{}", user_id); + + // Устанавливаем новое значение квоты + redis + .set::<_, u64, ()>("a_key, bytes) + .await + .map_err(|_| ErrorInternalServerError("Failed to set user quota in Redis"))?; + + Ok(bytes) + } + + /// Увеличивает квоту пользователя на указанное количество байт + pub async fn increase_user_quota( + &self, + user_id: &str, + additional_bytes: u64, + ) -> Result { + let mut redis = self.redis.clone(); + let quota_key = format!("quota:{}", user_id); + + // Получаем текущую квоту + let current_quota: u64 = redis.get("a_key).await.unwrap_or(0); + + // Вычисляем новую квоту + let new_quota = current_quota + additional_bytes; + + // Устанавливаем новое значение + redis + .set::<_, u64, ()>("a_key, new_quota) + .await + .map_err(|_| ErrorInternalServerError("Failed to increase user quota in Redis"))?; + + Ok(new_quota) + } } diff --git a/src/auth.rs b/src/auth.rs index 11150fc..06ffb49 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -30,7 +30,7 @@ struct Claims { /// Получает айди пользователя из токена в заголовке pub async fn get_id_by_token(token: &str) -> Result> { - let auth_api_base = env::var("AUTH_URL")?; + let auth_api_base = env::var("CORE_URL")?; let query_name = "validate_jwt_token"; let operation = "ValidateToken"; let mut headers = HeaderMap::new(); diff --git a/src/core.rs b/src/core.rs index a5281a7..dcf15e9 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1,9 +1,9 @@ - use reqwest::Client as HTTPClient; + +use reqwest::Client as HTTPClient; use serde::Deserialize; use serde_json::json; use std::{collections::HashMap, env, error::Error}; - // Структура для десериализации ответа от сервиса аутентификации #[derive(Deserialize)] struct CoreResponse { @@ -46,11 +46,7 @@ pub async fn get_shout_by_id(shout_id: i32) -> Result> { }); let client = HTTPClient::new(); - let response = client - .post(&api_base) - .json(&gql) - .send() - .await?; + let response = client.post(&api_base).json(&gql).send().await?; if response.status().is_success() { let core_response: CoreResponse = response.json().await?; @@ -67,4 +63,4 @@ pub async fn get_shout_by_id(shout_id: i32) -> Result> { response.status().to_string(), ))) } -} \ No newline at end of file +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 7969290..78b4d8f 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,21 +1,21 @@ mod proxy; -mod upload; +mod quota; mod serve_file; +mod upload; pub use proxy::proxy_handler; +pub use quota::{get_quota_handler, increase_quota_handler, set_quota_handler}; pub use upload::upload_handler; -// Лимит квоты на пользователя: 2 ГБ в неделю -pub const MAX_WEEK_BYTES: u64 = 2 * 1024 * 1024 * 1024; +// Общий лимит квоты на пользователя: 5 ГБ +pub const MAX_USER_QUOTA_BYTES: u64 = 5 * 1024 * 1024 * 1024; -use actix_web::{HttpResponse, Result, HttpRequest}; +use actix_web::{HttpRequest, HttpResponse, Result}; /// Обработчик для корневого пути / pub async fn root_handler(req: HttpRequest) -> Result { match req.method().as_str() { - "GET" => Ok(HttpResponse::Ok() - .content_type("text/plain") - .body("ok")), - _ => Ok(HttpResponse::MethodNotAllowed().finish()) + "GET" => Ok(HttpResponse::Ok().content_type("text/plain").body("ok")), + _ => Ok(HttpResponse::MethodNotAllowed().finish()), } } diff --git a/src/handlers/proxy.rs b/src/handlers/proxy.rs index 0c3e18a..5678bee 100644 --- a/src/handlers/proxy.rs +++ b/src/handlers/proxy.rs @@ -4,9 +4,9 @@ use log::{error, warn}; use crate::app_state::AppState; use crate::handlers::serve_file::serve_file; +use crate::lookup::{find_file_by_pattern, get_mime_type}; use crate::s3_utils::{check_file_exists, load_file_from_s3, upload_to_s3}; use crate::thumbnail::{find_closest_width, parse_file_path, thumbdata_save}; -use crate::lookup::{find_file_by_pattern, get_mime_type}; /// Обработчик для скачивания файла и генерации миниатюры, если она недоступна. pub async fn proxy_handler( @@ -56,14 +56,22 @@ pub async fn proxy_handler( warn!("content_type: {}", content_type); let shout_id = match req.query_string().contains("s=") { - true => req.query_string().split("s=").collect::>().pop().unwrap_or(""), - false => "" + true => req + .query_string() + .split("s=") + .collect::>() + .pop() + .unwrap_or(""), + false => "", }; return match state.get_path(&filekey).await { Ok(Some(stored_path)) => { warn!("Found stored path in DB: {}", stored_path); - warn!("Checking Storj path - bucket: {}, path: {}", state.bucket, stored_path); + warn!( + "Checking Storj path - bucket: {}, path: {}", + state.bucket, stored_path + ); if check_file_exists(&state.storj_client, &state.bucket, &stored_path).await? { warn!("File exists in Storj: {}", stored_path); if content_type.starts_with("image") { @@ -73,20 +81,26 @@ pub async fn proxy_handler( serve_file(&stored_path, &state, shout_id).await } else { let closest: u32 = find_closest_width(requested_width as u32); - warn!("Calculated closest width: {} for requested: {}", closest, requested_width); + warn!( + "Calculated closest width: {} for requested: {}", + closest, requested_width + ); let thumb_filename = &format!("{}_{}.{}", base_filename, closest, ext); warn!("Generated thumbnail filename: {}", thumb_filename); // Проверяем, существует ли уже миниатюра в Storj - match check_file_exists(&state.storj_client, &state.bucket, thumb_filename).await { + match check_file_exists(&state.storj_client, &state.bucket, thumb_filename) + .await + { Ok(true) => { warn!("serve existed thumb file: {}", thumb_filename); serve_file(thumb_filename, &state, shout_id).await - }, + } Ok(false) => { // Миниатюра не существует, возвращаем оригинал и запускаем генерацию миниатюры - let original_file = serve_file(&stored_path, &state, shout_id).await?; - + let original_file = + serve_file(&stored_path, &state, shout_id).await?; + // Запускаем асинхронную задачу для генерации миниатюры let state_clone = state.clone(); let stored_path_clone = stored_path.clone(); @@ -94,9 +108,22 @@ pub async fn proxy_handler( let content_type_clone = content_type.to_string(); actix_web::rt::spawn(async move { - if let Ok(filedata) = load_file_from_s3(&state_clone.storj_client, &state_clone.bucket, &stored_path_clone).await { + if let Ok(filedata) = load_file_from_s3( + &state_clone.storj_client, + &state_clone.bucket, + &stored_path_clone, + ) + .await + { warn!("generate new thumb files: {}", stored_path_clone); - if let Err(e) = thumbdata_save(filedata, &state_clone, &filekey_clone, content_type_clone).await { + if let Err(e) = thumbdata_save( + filedata, + &state_clone, + &filekey_clone, + content_type_clone, + ) + .await + { error!("Failed to generate thumbnail: {}", e); } } @@ -115,7 +142,10 @@ pub async fn proxy_handler( serve_file(&stored_path, &state, shout_id).await } } else { - warn!("Attempting to load from AWS - bucket: {}, path: {}", state.bucket, stored_path); + warn!( + "Attempting to load from AWS - bucket: {}, path: {}", + state.bucket, stored_path + ); // Определяем тип медиа из content_type let media_type = content_type.split("/").next().unwrap_or("image"); @@ -124,7 +154,7 @@ pub async fn proxy_handler( let paths_lower = vec![ stored_path.clone(), // format!("production/{}", stored_path), - format!("production/{}/{}", media_type, stored_path) + format!("production/{}/{}", media_type, stored_path), ]; // Создаем те же пути, но с оригинальным регистром расширения @@ -133,7 +163,7 @@ pub async fn proxy_handler( let paths_orig = vec![ orig_stored_path.clone(), // format!("production/{}", orig_stored_path), - format!("production/{}/{}", media_type, orig_stored_path) + format!("production/{}/{}", media_type, orig_stored_path), ]; // Объединяем все пути для проверки @@ -143,22 +173,29 @@ pub async fn proxy_handler( warn!("Trying AWS path: {}", path); match load_file_from_s3(&state.aws_client, &state.bucket, &path).await { Ok(filedata) => { - warn!("Successfully loaded file from AWS, size: {} bytes", filedata.len()); + warn!( + "Successfully loaded file from AWS, size: {} bytes", + filedata.len() + ); warn!("Attempting to upload to Storj with key: {}", filekey); - + if let Err(e) = upload_to_s3( &state.storj_client, &state.bucket, &filekey, filedata.clone(), &content_type, - ).await { + ) + .await + { error!("Failed to upload to Storj: {} - Error: {}", filekey, e); } else { warn!("Successfully uploaded to Storj: {}", filekey); } - - return Ok(HttpResponse::Ok().content_type(content_type).body(filedata)); + + return Ok(HttpResponse::Ok() + .content_type(content_type) + .body(filedata)); } Err(err) => { warn!("Failed to load from AWS path {}: {:?}", path, err); @@ -174,18 +211,23 @@ pub async fn proxy_handler( Ok(None) => { warn!("No stored path found in DB for: {}", filekey); let ct_parts = content_type.split("/").collect::>(); - + // Создаем два варианта пути - с оригинальным расширением и с нижним регистром let filepath_lower = format!("production/{}/{}.{}", ct_parts[0], base_filename, ext); - let filepath_orig = format!("production/{}/{}.{}", ct_parts[0], base_filename, extension); - - warn!("Looking up files with paths: {} or {} in bucket: {}", - filepath_lower, filepath_orig, state.bucket); + let filepath_orig = + format!("production/{}/{}.{}", ct_parts[0], base_filename, extension); + + warn!( + "Looking up files with paths: {} or {} in bucket: {}", + filepath_lower, filepath_orig, state.bucket + ); // Проверяем существование файла с обоими вариантами расширения - let exists_in_aws_lower = check_file_exists(&state.aws_client, &state.bucket, &filepath_lower).await?; - let exists_in_aws_orig = check_file_exists(&state.aws_client, &state.bucket, &filepath_orig).await?; - + let exists_in_aws_lower = + check_file_exists(&state.aws_client, &state.bucket, &filepath_lower).await?; + let exists_in_aws_orig = + check_file_exists(&state.aws_client, &state.bucket, &filepath_orig).await?; + let filepath = if exists_in_aws_orig { filepath_orig } else if exists_in_aws_lower { @@ -195,15 +237,25 @@ pub async fn proxy_handler( filepath_lower }; - let exists_in_storj = check_file_exists(&state.storj_client, &state.bucket, &filepath).await?; + let exists_in_storj = + check_file_exists(&state.storj_client, &state.bucket, &filepath).await?; warn!("Checking existence in Storj: {}", exists_in_storj); if exists_in_storj { - warn!("file {} exists in storj, try to generate thumbnails", filepath); + warn!( + "file {} exists in storj, try to generate thumbnails", + filepath + ); match load_file_from_s3(&state.aws_client, &state.bucket, &filepath).await { Ok(filedata) => { - let _ = thumbdata_save(filedata.clone(), &state, &filekey, content_type.to_string()).await; + let _ = thumbdata_save( + filedata.clone(), + &state, + &filekey, + content_type.to_string(), + ) + .await; } Err(e) => { error!("cannot download {} from storj: {}", filekey, e); @@ -214,16 +266,25 @@ pub async fn proxy_handler( warn!("file {} does not exist in storj", filepath); } - let exists_in_aws = check_file_exists(&state.aws_client, &state.bucket, &filepath).await?; + let exists_in_aws = + check_file_exists(&state.aws_client, &state.bucket, &filepath).await?; warn!("Checking existence in AWS: {}", exists_in_aws); if exists_in_aws { warn!("File found in AWS, attempting to download: {}", filepath); match load_file_from_s3(&state.aws_client, &state.bucket, &filepath).await { Ok(filedata) => { - warn!("Successfully downloaded file from AWS, size: {} bytes", filedata.len()); - let _ = thumbdata_save(filedata.clone(), &state, &filekey, content_type.to_string()) - .await; + warn!( + "Successfully downloaded file from AWS, size: {} bytes", + filedata.len() + ); + let _ = thumbdata_save( + filedata.clone(), + &state, + &filekey, + content_type.to_string(), + ) + .await; if let Err(e) = upload_to_s3( &state.storj_client, &state.bucket, @@ -231,27 +292,31 @@ pub async fn proxy_handler( filedata.clone(), &content_type, ) - .await { + .await + { warn!("cannot upload to storj: {}", e); } else { warn!("file {} uploaded to storj", filekey); state.set_path(&filekey, &filepath).await; } Ok(HttpResponse::Ok().content_type(content_type).body(filedata)) - }, + } Err(e) => { error!("Failed to download from AWS: {} - Error: {}", filepath, e); Err(ErrorInternalServerError(e)) - }, + } } } else { error!("File not found in either Storj or AWS: {}", filepath); Err(ErrorNotFound("file does not exist")) } - }, + } Err(e) => { - error!("Database error while getting path: {} - Full error: {:?}", filekey, e); + error!( + "Database error while getting path: {} - Full error: {:?}", + filekey, e + ); Err(ErrorInternalServerError(e)) } - } + }; } diff --git a/src/handlers/quota.rs b/src/handlers/quota.rs new file mode 100644 index 0000000..088eadf --- /dev/null +++ b/src/handlers/quota.rs @@ -0,0 +1,151 @@ +use actix_web::{web, HttpRequest, HttpResponse, Result}; +use log::{error, warn}; +use serde::{Deserialize, Serialize}; + +use crate::app_state::AppState; +use crate::auth::get_id_by_token; + +#[derive(Deserialize)] +pub struct QuotaRequest { + pub user_id: String, + pub additional_bytes: Option, + pub new_quota_bytes: Option, +} + +#[derive(Serialize)] +pub struct QuotaResponse { + pub user_id: String, + pub current_quota: u64, + pub max_quota: u64, +} + +/// Обработчик для получения информации о квоте пользователя +pub async fn get_quota_handler( + req: HttpRequest, + state: web::Data, +) -> Result { + // Проверяем авторизацию + let token = req + .headers() + .get("Authorization") + .and_then(|header_value| header_value.to_str().ok()); + + if token.is_none() { + return Err(actix_web::error::ErrorUnauthorized("Unauthorized")); + } + + let _admin_id = get_id_by_token(token.unwrap()) + .await + .map_err(|_| actix_web::error::ErrorUnauthorized("Invalid token"))?; + + // Получаем user_id из query параметров + let user_id = req + .query_string() + .split("user_id=") + .nth(1) + .and_then(|s| s.split('&').next()) + .ok_or_else(|| actix_web::error::ErrorBadRequest("Missing user_id parameter"))?; + + // Получаем текущую квоту пользователя + let current_quota = state.get_or_create_quota(user_id).await?; + + let response = QuotaResponse { + user_id: user_id.to_string(), + current_quota, + max_quota: crate::handlers::MAX_USER_QUOTA_BYTES, + }; + + Ok(HttpResponse::Ok().json(response)) +} + +/// Обработчик для увеличения квоты пользователя +pub async fn increase_quota_handler( + req: HttpRequest, + quota_data: web::Json, + state: web::Data, +) -> Result { + // Проверяем авторизацию + let token = req + .headers() + .get("Authorization") + .and_then(|header_value| header_value.to_str().ok()); + + if token.is_none() { + return Err(actix_web::error::ErrorUnauthorized("Unauthorized")); + } + + let _admin_id = get_id_by_token(token.unwrap()) + .await + .map_err(|_| actix_web::error::ErrorUnauthorized("Invalid token"))?; + + let additional_bytes = quota_data + .additional_bytes + .ok_or_else(|| actix_web::error::ErrorBadRequest("Missing additional_bytes parameter"))?; + + if additional_bytes == 0 { + return Err(actix_web::error::ErrorBadRequest( + "additional_bytes must be greater than 0", + )); + } + + // Увеличиваем квоту пользователя + let new_quota = state + .increase_user_quota("a_data.user_id, additional_bytes) + .await?; + + warn!( + "Increased quota for user {} by {} bytes, new total: {} bytes", + quota_data.user_id, additional_bytes, new_quota + ); + + let response = QuotaResponse { + user_id: quota_data.user_id.clone(), + current_quota: new_quota, + max_quota: crate::handlers::MAX_USER_QUOTA_BYTES, + }; + + Ok(HttpResponse::Ok().json(response)) +} + +/// Обработчик для установки квоты пользователя +pub async fn set_quota_handler( + req: HttpRequest, + quota_data: web::Json, + state: web::Data, +) -> Result { + // Проверяем авторизацию + let token = req + .headers() + .get("Authorization") + .and_then(|header_value| header_value.to_str().ok()); + + if token.is_none() { + return Err(actix_web::error::ErrorUnauthorized("Unauthorized")); + } + + let _admin_id = get_id_by_token(token.unwrap()) + .await + .map_err(|_| actix_web::error::ErrorUnauthorized("Invalid token"))?; + + let new_quota_bytes = quota_data + .new_quota_bytes + .ok_or_else(|| actix_web::error::ErrorBadRequest("Missing new_quota_bytes parameter"))?; + + // Устанавливаем новую квоту пользователя + let new_quota = state + .set_user_quota("a_data.user_id, new_quota_bytes) + .await?; + + warn!( + "Set quota for user {} to {} bytes", + quota_data.user_id, new_quota + ); + + let response = QuotaResponse { + user_id: quota_data.user_id.clone(), + current_quota: new_quota, + max_quota: crate::handlers::MAX_USER_QUOTA_BYTES, + }; + + Ok(HttpResponse::Ok().json(response)) +} diff --git a/src/handlers/serve_file.rs b/src/handlers/serve_file.rs index 7c70a05..7941e3c 100644 --- a/src/handlers/serve_file.rs +++ b/src/handlers/serve_file.rs @@ -6,7 +6,11 @@ use crate::overlay::generate_overlay; use crate::s3_utils::check_file_exists; /// Функция для обслуживания файла по заданному пути. -pub async fn serve_file(filepath: &str, state: &AppState, shout_id: &str) -> Result { +pub async fn serve_file( + filepath: &str, + state: &AppState, + shout_id: &str, +) -> Result { if filepath.is_empty() { return Err(ErrorInternalServerError("Filename is empty".to_string())); } @@ -14,7 +18,10 @@ pub async fn serve_file(filepath: &str, state: &AppState, shout_id: &str) -> Res // Проверяем наличие файла в Storj S3 let exists = check_file_exists(&state.storj_client, &state.bucket, &filepath).await?; if !exists { - return Err(ErrorInternalServerError(format!("File {} not found in Storj", filepath))); + return Err(ErrorInternalServerError(format!( + "File {} not found in Storj", + filepath + ))); } // Получаем объект из Storj S3 @@ -25,7 +32,9 @@ pub async fn serve_file(filepath: &str, state: &AppState, shout_id: &str) -> Res .key(filepath) .send() .await - .map_err(|_| ErrorInternalServerError(format!("Failed to get {} object from Storj", filepath)))?; + .map_err(|_| { + ErrorInternalServerError(format!("Failed to get {} object from Storj", filepath)) + })?; let data: aws_sdk_s3::primitives::AggregatedBytes = get_object_output .body @@ -35,10 +44,9 @@ pub async fn serve_file(filepath: &str, state: &AppState, shout_id: &str) -> Res let data_bytes = match shout_id.is_empty() { true => data.into_bytes(), - false => generate_overlay(shout_id, data.into_bytes()).await? + false => generate_overlay(shout_id, data.into_bytes()).await?, }; - let mime_type = MimeGuess::from_path(&filepath).first_or_octet_stream(); Ok(HttpResponse::Ok() diff --git a/src/handlers/upload.rs b/src/handlers/upload.rs index f65a800..e524e26 100644 --- a/src/handlers/upload.rs +++ b/src/handlers/upload.rs @@ -4,10 +4,10 @@ use log::{error, warn}; use crate::app_state::AppState; use crate::auth::{get_id_by_token, user_added_file}; -use crate::s3_utils::{self, upload_to_s3, generate_key_with_extension}; +use crate::handlers::MAX_USER_QUOTA_BYTES; use crate::lookup::store_file_info; +use crate::s3_utils::{self, generate_key_with_extension, upload_to_s3}; use futures::TryStreamExt; -use crate::handlers::MAX_WEEK_BYTES; // use crate::thumbnail::convert_heic_to_jpeg; /// Обработчик для аплоада файлов. @@ -28,7 +28,7 @@ pub async fn upload_handler( let user_id = get_id_by_token(token.unwrap()).await?; // Получаем текущую квоту пользователя - let this_week_amount: u64 = state.get_or_create_quota(&user_id).await.unwrap_or(0); + let current_quota: u64 = state.get_or_create_quota(&user_id).await.unwrap_or(0); let mut body = "ok".to_string(); while let Ok(Some(field)) = payload.try_next().await { let mut field = field; @@ -46,7 +46,9 @@ pub async fn upload_handler( Some(mime) => mime, None => { warn!("Неподдерживаемый формат файла"); - return Err(actix_web::error::ErrorUnsupportedMediaType("Неподдерживаемый формат файла")); + return Err(actix_web::error::ErrorUnsupportedMediaType( + "Неподдерживаемый формат файла", + )); } }; @@ -63,14 +65,18 @@ pub async fn upload_handler( Some(ext) => ext, None => { warn!("Неподдерживаемый тип содержимого: {}", content_type); - return Err(actix_web::error::ErrorUnsupportedMediaType("Неподдерживаемый тип содержимого")); + return Err(actix_web::error::ErrorUnsupportedMediaType( + "Неподдерживаемый тип содержимого", + )); } }; // Проверяем, что добавление файла не превышает лимит квоты - if this_week_amount + file_size > MAX_WEEK_BYTES { - warn!("Quota would exceed limit: current={}, adding={}, limit={}", - this_week_amount, file_size, MAX_WEEK_BYTES); + if current_quota + file_size > MAX_USER_QUOTA_BYTES { + warn!( + "Quota would exceed limit: current={}, adding={}, limit={}", + current_quota, file_size, MAX_USER_QUOTA_BYTES + ); return Err(actix_web::error::ErrorUnauthorized("Quota exceeded")); } @@ -84,27 +90,33 @@ pub async fn upload_handler( &filename, file_bytes, &content_type, - ).await { + ) + .await + { Ok(_) => { - warn!("file {} uploaded to storj, incrementing quota by {} bytes", filename, file_size); + warn!( + "file {} uploaded to storj, incrementing quota by {} bytes", + filename, file_size + ); if let Err(e) = state.increment_uploaded_bytes(&user_id, file_size).await { error!("Failed to increment quota: {}", e); return Err(e); } - + // Сохраняем информацию о файле в Redis let mut redis = state.redis.clone(); store_file_info(&mut redis, &filename, &content_type).await?; user_added_file(&mut redis, &user_id, &filename).await?; - + // Сохраняем маппинг пути - let generated_key = generate_key_with_extension(filename.clone(), content_type.clone()); + let generated_key = + generate_key_with_extension(filename.clone(), content_type.clone()); state.set_path(&filename, &generated_key).await; - + if let Ok(new_quota) = state.get_or_create_quota(&user_id).await { warn!("New quota for user {}: {} bytes", user_id, new_quota); } - + body = filename; } Err(e) => { diff --git a/src/lookup.rs b/src/lookup.rs index 6414861..27320fd 100644 --- a/src/lookup.rs +++ b/src/lookup.rs @@ -1,8 +1,8 @@ -use std::collections::HashMap; use actix_web::error::ErrorInternalServerError; use once_cell::sync::Lazy; use redis::aio::MultiplexedConnection; use redis::AsyncCommands; +use std::collections::HashMap; pub static MIME_TYPES: Lazy> = Lazy::new(|| { let mut m = HashMap::new(); @@ -36,7 +36,6 @@ pub fn get_mime_type(extension: &str) -> Option<&'static str> { MIME_TYPES.get(extension).copied() } - /// Ищет файл в Redis по шаблону имени pub async fn find_file_by_pattern( redis: &mut MultiplexedConnection, @@ -67,4 +66,4 @@ pub async fn store_file_info( .await .map_err(|_| ErrorInternalServerError("Failed to store file info in Redis"))?; Ok(()) -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index be21981..118755f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,27 @@ mod app_state; mod auth; -mod lookup; +mod core; mod handlers; +mod lookup; +mod overlay; mod s3_utils; mod thumbnail; -mod core; -mod overlay; -use actix_web::{middleware::Logger, web, App, HttpServer, http::header::{self, HeaderName}}; use actix_cors::Cors; +use actix_web::{ + http::header::{self, HeaderName}, + middleware::Logger, + web, App, HttpServer, +}; use app_state::AppState; -use handlers::{proxy_handler, upload_handler, root_handler}; -use log::warn; -use tokio::task::spawn_blocking; -use std::env; use env_logger; +use handlers::{ + get_quota_handler, increase_quota_handler, proxy_handler, root_handler, set_quota_handler, + upload_handler, +}; +use log::warn; +use std::env; +use tokio::task::spawn_blocking; #[actix_web::main] async fn main() -> std::io::Result<()> { @@ -38,7 +45,7 @@ async fn main() -> std::io::Result<()> { // Настройка CORS middleware let cors = Cors::default() .allow_any_origin() // TODO: ограничить конкретными доменами в продакшене - .allowed_methods(vec!["GET", "POST", "OPTIONS"]) + .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) .allowed_headers(vec![ header::DNT, header::USER_AGENT, @@ -49,10 +56,7 @@ async fn main() -> std::io::Result<()> { header::RANGE, header::AUTHORIZATION, ]) - .expose_headers(vec![ - header::CONTENT_LENGTH, - header::CONTENT_RANGE, - ]) + .expose_headers(vec![header::CONTENT_LENGTH, header::CONTENT_RANGE]) .supports_credentials() .max_age(1728000); // 20 дней @@ -62,6 +66,9 @@ async fn main() -> std::io::Result<()> { .wrap(Logger::default()) .route("/", web::get().to(root_handler)) .route("/", web::post().to(upload_handler)) + .route("/quota", web::get().to(get_quota_handler)) + .route("/quota/increase", web::post().to(increase_quota_handler)) + .route("/quota/set", web::post().to(set_quota_handler)) .route("/{path:.*}", web::get().to(proxy_handler)) }) .bind(addr)? diff --git a/src/overlay.rs b/src/overlay.rs index a4f991c..6de78ff 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -1,14 +1,17 @@ -use std::{error::Error, io::Cursor}; -use actix_web::web::Bytes; -use log::warn; -use image::Rgba; -use imageproc::drawing::{draw_text_mut, draw_filled_rect_mut}; -use imageproc::rect::Rect; use ab_glyph::{Font, FontArc, PxScale}; +use actix_web::web::Bytes; +use image::Rgba; +use imageproc::drawing::{draw_filled_rect_mut, draw_text_mut}; +use imageproc::rect::Rect; +use log::warn; +use std::{error::Error, io::Cursor}; use crate::core::get_shout_by_id; -pub async fn generate_overlay<'a>(shout_id: &'a str, filedata: Bytes) -> Result> { +pub async fn generate_overlay<'a>( + shout_id: &'a str, + filedata: Bytes, +) -> Result> { // Получаем shout из GraphQL let shout_id_int = shout_id.parse::().unwrap_or(0); match get_shout_by_id(shout_id_int).await { @@ -84,7 +87,7 @@ pub async fn generate_overlay<'a>(shout_id: &'a str, filedata: Bytes) -> Result< img.write_to(&mut Cursor::new(&mut buffer), image::ImageFormat::Png)?; Ok(Bytes::from(buffer)) - }, + } Err(e) => { warn!("Error getting shout: {}", e); Ok(filedata) diff --git a/src/s3_utils.rs b/src/s3_utils.rs index 50e595d..916da3c 100644 --- a/src/s3_utils.rs +++ b/src/s3_utils.rs @@ -1,8 +1,8 @@ use actix_web::error::ErrorInternalServerError; use aws_sdk_s3::{error::SdkError, primitives::ByteStream, Client as S3Client}; +use infer::get; use mime_guess::mime; use std::str::FromStr; -use infer::get; /// Загружает файл в S3 хранилище. pub async fn upload_to_s3( @@ -32,7 +32,13 @@ pub async fn check_file_exists( bucket: &str, filepath: &str, ) -> Result { - match s3_client.head_object().bucket(bucket).key(filepath).send().await { + match s3_client + .head_object() + .bucket(bucket) + .key(filepath) + .send() + .await + { Ok(_) => Ok(true), // Файл найден Err(SdkError::ServiceError(service_error)) if service_error.err().is_not_found() => { Ok(false) // Файл не найден @@ -117,6 +123,6 @@ pub fn get_extension_from_mime(mime_type: &str) -> Option<&str> { "image/webp" => Some("webp"), "image/heic" => Some("heic"), "image/tiff" => Some("tiff"), - _ => None + _ => None, } -} \ No newline at end of file +} diff --git a/src/thumbnail.rs b/src/thumbnail.rs index 14ebd68..45d4967 100644 --- a/src/thumbnail.rs +++ b/src/thumbnail.rs @@ -60,7 +60,8 @@ pub fn parse_file_path(requested_path: &str) -> (String, u32, String) { // Проверка на старую ширину в путях, начинающихся с "unsafe" if path.starts_with("unsafe") && width == 0 { if path_parts.len() >= 2 { - if let Some(old_width_str) = path_parts.get(1) { // Получаем второй элемент + if let Some(old_width_str) = path_parts.get(1) { + // Получаем второй элемент let old_width_str = old_width_str.trim_end_matches('x'); if let Ok(w) = old_width_str.parse::() { width = w; @@ -78,7 +79,7 @@ pub fn parse_file_path(requested_path: &str) -> (String, u32, String) { /// Это позволяет поддерживать различные форматы изображений без необходимости заранее предугадывать их. pub async fn generate_thumbnails( image: &DynamicImage, - format: ImageFormat + format: ImageFormat, ) -> Result>, actix_web::Error> { let mut thumbnails = HashMap::new(); @@ -107,16 +108,18 @@ fn determine_image_format(extension: &str) -> Result { // Конвертируем HEIC и TIFF в JPEG при сохранении Ok(ImageFormat::Jpeg) - }, + } _ => { log::error!("Неподдерживаемый формат изображения: {}", extension); - Err(ErrorInternalServerError("Неподдерживаемый формат изображения")) - }, + Err(ErrorInternalServerError( + "Неподдерживаемый формат изображения", + )) + } } } /// Сохраняет данные миниатюры. -/// +/// /// Обновлена для передачи корректного формата изображения. pub async fn thumbdata_save( original_data: Vec, @@ -128,12 +131,12 @@ pub async fn thumbdata_save( warn!("original file name: {}", original_filename); let (base_filename, _, extension) = parse_file_path(&original_filename); warn!("detected file extension: {}", extension); - + // Для HEIC файлов просто сохраняем оригинал как миниатюру if content_type == "image/heic" { warn!("HEIC file detected, using original as thumbnail"); let thumb_filename = format!("{}_{}.heic", base_filename, THUMB_WIDTHS[0]); - + if let Err(e) = upload_to_s3( &state.storj_client, &state.bucket, @@ -179,7 +182,10 @@ pub async fn thumbdata_save( } } Err(e) => { - warn!("cannot generate thumbnails for {}: {}", original_filename, e); + warn!( + "cannot generate thumbnails for {}: {}", + original_filename, e + ); return Err(e); } } diff --git a/tests/basic_test.rs b/tests/basic_test.rs new file mode 100644 index 0000000..dbd6a7f --- /dev/null +++ b/tests/basic_test.rs @@ -0,0 +1,322 @@ +use actix_web::{test, web, App, HttpResponse}; + +/// Простой тест health check +#[actix_web::test] +async fn test_health_check() { + let app = test::init_service(App::new().route( + "/", + web::get().to(|req: actix_web::HttpRequest| async move { + match req.method().as_str() { + "GET" => Ok::( + HttpResponse::Ok().content_type("text/plain").body("ok"), + ), + _ => { + Ok::(HttpResponse::MethodNotAllowed().finish()) + } + } + }), + )) + .await; + + // Тестируем GET запрос + let req = test::TestRequest::get().uri("/").to_request(); + + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body = test::read_body(resp).await; + assert_eq!(body, "ok"); + + // Тестируем POST запрос (должен вернуть 405) + let req = test::TestRequest::post().uri("/").to_request(); + + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), actix_web::http::StatusCode::NOT_FOUND); +} + +/// Тест для проверки JSON сериализации/десериализации +#[test] +async fn test_json_serialization() { + #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] + struct TestStruct { + user_id: String, + current_quota: u64, + max_quota: u64, + } + + let test_data = TestStruct { + user_id: "test-user-123".to_string(), + current_quota: 1024, + max_quota: 5368709120, // 5 GB + }; + + // Сериализация + let json_string = serde_json::to_string(&test_data).unwrap(); + assert!(json_string.contains("test-user-123")); + assert!(json_string.contains("1024")); + assert!(json_string.contains("5368709120")); + + // Десериализация + let deserialized: TestStruct = serde_json::from_str(&json_string).unwrap(); + assert_eq!(deserialized, test_data); +} + +/// Тест для проверки multipart form data +#[test] +async fn test_multipart_form_data() { + let boundary = "test-boundary"; + let filename = "test.png"; + let content = b"fake image data"; + + let mut form_data = Vec::new(); + + // Начало multipart + form_data.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); + form_data.extend_from_slice( + format!( + "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n", + filename + ) + .as_bytes(), + ); + form_data.extend_from_slice(b"Content-Type: image/png\r\n\r\n"); + + // Содержимое файла + form_data.extend_from_slice(content); + form_data.extend_from_slice(b"\r\n"); + + // Конец multipart + form_data.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); + + // Проверяем, что form_data содержит ожидаемые части + let form_data_str = String::from_utf8_lossy(&form_data); + assert!(form_data_str.contains(filename)); + assert!(form_data_str.contains("image/png")); + assert!(form_data_str.contains("fake image data")); + assert!(form_data_str.contains(&format!("--{}", boundary))); +} + +/// Тест для проверки UUID генерации +#[test] +async fn test_uuid_generation() { + use uuid::Uuid; + + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + + // UUID должны быть разными + assert_ne!(uuid1, uuid2); + + // UUID должны быть в правильном формате + let uuid_str = uuid1.to_string(); + assert_eq!(uuid_str.len(), 36); // 32 символа + 4 дефиса + assert!(uuid_str.contains('-')); + + // Проверяем, что UUID можно парсить обратно + let parsed_uuid = Uuid::parse_str(&uuid_str).unwrap(); + assert_eq!(parsed_uuid, uuid1); +} + +/// Тест для проверки MIME типов +#[test] +async fn test_mime_type_detection() { + // Тестируем определение MIME типа по расширению + let get_mime_type = |ext: &str| -> Option<&'static str> { + match ext.to_lowercase().as_str() { + "jpg" | "jpeg" => Some("image/jpeg"), + "png" => Some("image/png"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + "mp3" => Some("audio/mpeg"), + "wav" => Some("audio/wav"), + "mp4" => Some("video/mp4"), + _ => None, + } + }; + + assert_eq!(get_mime_type("jpg"), Some("image/jpeg")); + assert_eq!(get_mime_type("jpeg"), Some("image/jpeg")); + assert_eq!(get_mime_type("png"), Some("image/png")); + assert_eq!(get_mime_type("gif"), Some("image/gif")); + assert_eq!(get_mime_type("webp"), Some("image/webp")); + assert_eq!(get_mime_type("mp3"), Some("audio/mpeg")); + assert_eq!(get_mime_type("wav"), Some("audio/wav")); + assert_eq!(get_mime_type("mp4"), Some("video/mp4")); + assert_eq!(get_mime_type("pdf"), None); + assert_eq!(get_mime_type(""), None); +} + +/// Тест для проверки парсинга путей файлов +#[test] +async fn test_file_path_parsing() { + fn parse_file_path(path: &str) -> (String, u32, String) { + let parts: Vec<&str> = path.split('.').collect(); + if parts.len() != 2 { + return (path.to_string(), 0, "".to_string()); + } + + let base_with_width = parts[0]; + let ext = parts[1]; + + // Ищем ширину в формате _NUMBER + let base_parts: Vec<&str> = base_with_width.split('_').collect(); + if base_parts.len() >= 2 { + if let Ok(width) = base_parts.last().unwrap().parse::() { + let base = base_parts[..base_parts.len() - 1].join("_"); + return (base, width, ext.to_string()); + } + } + + (base_with_width.to_string(), 0, ext.to_string()) + } + + let (base, width, ext) = parse_file_path("image_300.jpg"); + assert_eq!(base, "image"); + assert_eq!(width, 300); + assert_eq!(ext, "jpg"); + + let (base, width, ext) = parse_file_path("document.pdf"); + assert_eq!(base, "document"); + assert_eq!(width, 0); + assert_eq!(ext, "pdf"); + + let (base, width, ext) = parse_file_path("file_with_underscore_but_no_width.jpg"); + assert_eq!(base, "file_with_underscore_but_no_width"); + assert_eq!(width, 0); + assert_eq!(ext, "jpg"); +} + +/// Тест для проверки расчетов квот +#[test] +async fn test_quota_calculations() { + const MAX_QUOTA_BYTES: u64 = 5 * 1024 * 1024 * 1024; // 5 ГБ + const MB: u64 = 1024 * 1024; + const GB: u64 = 1024 * 1024 * 1024; + + // Тестируем различные сценарии + let test_cases = vec![ + (0, 1 * MB, true), // Пустая квота + 1MB = OK + (1 * GB, 1 * MB, true), // 1GB + 1MB = OK + (4 * GB, 1 * GB, true), // 4GB + 1GB = OK + (4 * GB, 2 * GB, false), // 4GB + 2GB = превышение + (5 * GB, 1 * MB, false), // 5GB + 1MB = превышение + ]; + + for (current_quota, file_size, should_allow) in test_cases { + let would_exceed = current_quota + file_size > MAX_QUOTA_BYTES; + assert_eq!( + would_exceed, + !should_allow, + "Квота: {} + файл: {} = {}, должно быть разрешено: {}", + current_quota, + file_size, + current_quota + file_size, + should_allow + ); + } +} + +/// Тест для проверки форматирования размеров файлов +#[test] +async fn test_file_size_formatting() { + fn format_file_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = 1024 * 1024; + const GB: u64 = 1024 * 1024 * 1024; + + match bytes { + 0..KB => format!("{} B", bytes), + KB..MB => format!("{:.1} KB", bytes as f64 / KB as f64), + MB..GB => format!("{:.1} MB", bytes as f64 / MB as f64), + _ => format!("{:.1} GB", bytes as f64 / GB as f64), + } + } + + assert_eq!(format_file_size(512), "512 B"); + assert_eq!(format_file_size(1024), "1.0 KB"); + assert_eq!(format_file_size(1536), "1.5 KB"); + assert_eq!(format_file_size(1024 * 1024), "1.0 MB"); + assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB"); + assert_eq!(format_file_size(5 * 1024 * 1024 * 1024), "5.0 GB"); +} + +/// Тест для проверки обработки ошибок +#[test] +async fn test_error_handling() { + // Тестируем парсинг неверного JSON + let invalid_json = "{ invalid json }"; + let result: Result = serde_json::from_str(invalid_json); + assert!(result.is_err()); + + // Тестируем парсинг неполного JSON + let incomplete_json = r#"{"user_id": "test"#; + let result: Result = serde_json::from_str(incomplete_json); + assert!(result.is_err()); + + // Тестируем неверный UUID + use uuid::Uuid; + let invalid_uuid = "not-a-uuid"; + let result = Uuid::parse_str(invalid_uuid); + assert!(result.is_err()); + + // Тестируем пустой UUID + let empty_uuid = ""; + let result = Uuid::parse_str(empty_uuid); + assert!(result.is_err()); +} + +/// Тест для проверки производительности +#[test] +async fn test_performance() { + use std::time::Instant; + + // Тест UUID генерации + let start = Instant::now(); + let iterations = 10000; + + for _ in 0..iterations { + let _uuid = uuid::Uuid::new_v4(); + } + + let duration = start.elapsed(); + let avg_time = duration.as_nanos() as f64 / iterations as f64; + + println!( + "UUID generation: {} UUIDs in {:?}, avg: {:.2} ns per UUID", + iterations, duration, avg_time + ); + + // Проверяем, что среднее время меньше 1μs + assert!( + avg_time < 1000.0, + "UUID generation too slow: {:.2} ns", + avg_time + ); + + // Тест JSON сериализации + let start = Instant::now(); + + for _ in 0..iterations { + let data = serde_json::json!({ + "user_id": "test-user-123", + "current_quota": 1024, + "max_quota": 5368709120u64 + }); + let _json_string = serde_json::to_string(&data).unwrap(); + } + + let duration = start.elapsed(); + let avg_time = duration.as_micros() as f64 / iterations as f64; + + println!( + "JSON serialization: {} operations in {:?}, avg: {:.2} μs per operation", + iterations, duration, avg_time + ); + + // Проверяем, что среднее время меньше 100μs + assert!( + avg_time < 100.0, + "JSON serialization too slow: {:.2} μs", + avg_time + ); +}