diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a60e3b..472edb4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,10 +44,15 @@ jobs: cargo test --verbose env: DATABASE_URL: ${{ env.TEST_DB }} + SCHEDULE_DISABLE_AUTO_UPDATE: 1 JWT_SECRET: "test-secret-at-least-256-bits-used" - VKID_CLIENT_ID: 0 - VKID_REDIRECT_URI: "vk0://vk.com/blank.html" - REQWEST_USER_AGENT: "Dalvik/2.1.0 (Linux; U; Android 6.0.1; OPPO R9s Build/MMB29M)" + VK_ID_CLIENT_ID: 0 + VK_ID_REDIRECT_URI: "vk0://vk.com/blank.html" + TELEGRAM_BOT_ID: 0 + TELEGRAM_MINI_APP_HOST: example.com + TELEGRAM_TEST_DC: false + YANDEX_CLOUD_API_KEY: "" + YANDEX_CLOUD_FUNC_ID: "" build: name: Build runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d187fe2..b16ed60 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,7 +26,12 @@ jobs: run: cargo test env: DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} + SCHEDULE_DISABLE_AUTO_UPDATE: 1 JWT_SECRET: "test-secret-at-least-256-bits-used" - VKID_CLIENT_ID: 0 - VKID_REDIRECT_URI: "vk0://vk.com/blank.html" - REQWEST_USER_AGENT: "Dalvik/2.1.0 (Linux; U; Android 6.0.1; OPPO R9s Build/MMB29M)" \ No newline at end of file + VK_ID_CLIENT_ID: 0 + VK_ID_REDIRECT_URI: "vk0://vk.com/blank.html" + TELEGRAM_BOT_ID: 0 + TELEGRAM_MINI_APP_HOST: example.com + TELEGRAM_TEST_DC: false + YANDEX_CLOUD_API_KEY: "" + YANDEX_CLOUD_FUNC_ID: "" \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 6919e87..9a3feff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -453,6 +453,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" + [[package]] name = "bcrypt" version = "0.17.0" @@ -690,6 +696,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.0" @@ -843,6 +855,33 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "darling" version = "0.20.10" @@ -888,6 +927,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "deranged" version = "0.4.0" @@ -994,7 +1043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c478574b20020306f98d61c8ca3322d762e1ff08117422ac6106438605ea516" dependencies = [ "block-buffer 0.11.0-rc.4", - "const-oid", + "const-oid 0.10.0", "crypto-common 0.2.0-rc.2", ] @@ -1029,6 +1078,30 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -1089,6 +1162,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "findshlibs" version = "0.10.2" @@ -1408,6 +1487,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcaaec4551594c969335c98c903c1397853d4198408ea609190f420500f6be71" + [[package]] name = "hostname" version = "0.1.5" @@ -2286,6 +2371,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2386,7 +2481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.100", @@ -2872,6 +2967,7 @@ dependencies = [ "chrono", "criterion", "derive_more", + "log", "regex", "sentry", "serde", @@ -2887,20 +2983,24 @@ dependencies = [ "actix-macros 0.1.0", "actix-test", "actix-web", + "base64", "bcrypt", "chrono", "derive_more", "diesel", "diesel-derive-enum", "dotenvy", + "ed25519-dalek", "env_logger", "firebase-messaging-rs", "futures-util", "hex", + "hex-literal", "jsonwebtoken", + "log", "mime", "objectid", - "rand 0.9.0", + "percent-encoding", "reqwest", "schedule-parser", "sentry", @@ -2910,6 +3010,7 @@ dependencies = [ "serde_with", "sha1 0.11.0-pre.5", "tokio", + "ua_generator", "utoipa", "utoipa-actix-web", "utoipa-rapidoc", @@ -3139,6 +3240,15 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3203,6 +3313,17 @@ dependencies = [ "digest 0.11.0-pre.10", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3218,6 +3339,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -3261,6 +3391,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -3525,6 +3665,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" +dependencies = [ + "indexmap 2.8.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" + [[package]] name = "tonic" version = "0.12.3" @@ -3671,6 +3852,20 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "ua_generator" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fffa7e1ef86f4ed29df5ecbac2f47160cdfbc3296c25b609cd83835ec3b7151" +dependencies = [ + "dotenvy", + "fastrand", + "serde", + "serde_json", + "toml", + "ureq", +] + [[package]] name = "uname" version = "0.1.1" @@ -3717,10 +3912,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ "base64", + "brotli-decompressor", + "encoding_rs", + "flate2", "log", "native-tls", "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", "url", + "webpki-roots 0.26.11", ] [[package]] @@ -3972,6 +4175,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.0", +] + +[[package]] +name = "webpki-roots" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4193,6 +4414,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" +dependencies = [ + "memchr", +] + [[package]] name = "winutil" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index ef0bf8d..38e020f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,11 +35,16 @@ serde_json = "1.0.140" serde_with = "3.12.0" sha1 = "0.11.0-pre.5" tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] } -rand = "0.9.0" utoipa = { version = "5", features = ["actix_extras", "chrono"] } utoipa-rapidoc = { version = "6.0.0", features = ["actix-web"] } utoipa-actix-web = "0.1" uuid = { version = "1.16.0", features = ["v4"] } +ed25519-dalek = "2.1.1" +hex-literal = "1.0.0" +log = "0.4.26" +base64 = "0.22.1" +percent-encoding = "2.3.1" +ua_generator = "0.5.16" [dev-dependencies] actix-test = { path = "actix-test" } diff --git a/actix-macros/src/lib.rs b/actix-macros/src/lib.rs index 2ca131c..1ca5874 100644 --- a/actix-macros/src/lib.rs +++ b/actix-macros/src/lib.rs @@ -62,7 +62,7 @@ mod shared { } } -mod response_error_message { +mod middleware_error { use proc_macro::TokenStream; use quote::quote; @@ -81,28 +81,7 @@ mod response_error_message { fn error_response(&self) -> ::actix_web::HttpResponse { ::actix_web::HttpResponse::build(self.status_code()) - .json(crate::utility::error::ResponseErrorMessage::new(self.clone())) - } - } - }) - } -} - -mod status_code { - use proc_macro::TokenStream; - use quote::quote; - - pub fn fmt(ast: &syn::DeriveInput) -> TokenStream { - let name = &ast.ident; - - let status_code_arms = super::shared::get_arms(ast); - - TokenStream::from(quote! { - impl crate::routes::schema::PartialStatusCode for #name { - fn status_code(&self) -> ::actix_web::http::StatusCode { - match self { - #(#status_code_arms)* - } + .json(crate::utility::error::MiddlewareError::new(self.clone())) } } }) @@ -130,7 +109,7 @@ mod responder_json { } } -mod into_response_error { +mod ok_response { use proc_macro::TokenStream; use quote::quote; @@ -138,46 +117,37 @@ mod into_response_error { let name = &ast.ident; TokenStream::from(quote! { - impl ::core::convert::Into> for #name { - fn into(self) -> crate::routes::schema::ResponseError<#name> { - crate::routes::schema::ResponseError { - code: self, - message: ::core::option::Option::None, - } - } - } - - impl crate::routes::schema::IntoResponseAsError for #name - where - T: ::serde::ser::Serialize + ::utoipa::PartialSchema {} - }) - } - - pub fn fmt_named(ast: &syn::DeriveInput) -> TokenStream { - let name = &ast.ident; - - TokenStream::from(quote! { - impl ::core::convert::Into> for #name { - fn into(self) -> crate::routes::schema::ResponseError<#name> { - crate::routes::schema::ResponseError { - message: ::core::option::Option::Some(format!("{}", self)), - code: self, - } - } - } - - impl crate::routes::schema::IntoResponseAsError for #name - where - T: ::serde::ser::Serialize + ::utoipa::PartialSchema {} + impl crate::routes::schema::PartialOkResponse for #name {} }) } } -#[proc_macro_derive(ResponseErrorMessage, attributes(status_code))] -pub fn rem_derive(input: TokenStream) -> TokenStream { +mod err_response { + use proc_macro::TokenStream; + use quote::quote; + + pub fn fmt(ast: &syn::DeriveInput) -> TokenStream { + let name = &ast.ident; + + let status_code_arms = super::shared::get_arms(ast); + + TokenStream::from(quote! { + impl crate::routes::schema::PartialErrResponse for #name { + fn status_code(&self) -> ::actix_web::http::StatusCode { + match self { + #(#status_code_arms)* + } + } + } + }) + } +} + +#[proc_macro_derive(MiddlewareError, attributes(status_code))] +pub fn moddleware_error_derive(input: TokenStream) -> TokenStream { let ast = syn::parse(input).unwrap(); - response_error_message::fmt(&ast) + middleware_error::fmt(&ast) } #[proc_macro_derive(ResponderJson)] @@ -187,23 +157,16 @@ pub fn responser_json_derive(input: TokenStream) -> TokenStream { responder_json::fmt(&ast) } -#[proc_macro_derive(IntoResponseError)] -pub fn into_response_error_derive(input: TokenStream) -> TokenStream { +#[proc_macro_derive(OkResponse)] +pub fn ok_response_derive(input: TokenStream) -> TokenStream { let ast = syn::parse(input).unwrap(); - into_response_error::fmt(&ast) + ok_response::fmt(&ast) } -#[proc_macro_derive(IntoResponseErrorNamed)] -pub fn into_response_error_named_derive(input: TokenStream) -> TokenStream { +#[proc_macro_derive(ErrResponse, attributes(status_code))] +pub fn err_response_derive(input: TokenStream) -> TokenStream { let ast = syn::parse(input).unwrap(); - into_response_error::fmt_named(&ast) -} - -#[proc_macro_derive(StatusCode, attributes(status_code))] -pub fn status_code_derive(input: TokenStream) -> TokenStream { - let ast = syn::parse(input).unwrap(); - - status_code::fmt(&ast) + err_response::fmt(&ast) } diff --git a/migrations/2025-05-28-185850_add_tg_id_to_users/down.sql b/migrations/2025-05-28-185850_add_tg_id_to_users/down.sql new file mode 100644 index 0000000..8253709 --- /dev/null +++ b/migrations/2025-05-28-185850_add_tg_id_to_users/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP CONSTRAINT users_telegram_id_key; +ALTER TABLE users DROP COLUMN telegram_id; \ No newline at end of file diff --git a/migrations/2025-05-28-185850_add_tg_id_to_users/up.sql b/migrations/2025-05-28-185850_add_tg_id_to_users/up.sql new file mode 100644 index 0000000..75ea0de --- /dev/null +++ b/migrations/2025-05-28-185850_add_tg_id_to_users/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD telegram_id int8 NULL; +ALTER TABLE users ADD CONSTRAINT users_telegram_id_key UNIQUE (telegram_id); \ No newline at end of file diff --git a/migrations/2025-05-28-191056_make_users_password_nullable/down.sql b/migrations/2025-05-28-191056_make_users_password_nullable/down.sql new file mode 100644 index 0000000..8d3bb89 --- /dev/null +++ b/migrations/2025-05-28-191056_make_users_password_nullable/down.sql @@ -0,0 +1,2 @@ +UPDATE users SET "password" = '' WHERE "password" IS NULL; +ALTER TABLE users ALTER COLUMN "password" SET NOT NULL; \ No newline at end of file diff --git a/migrations/2025-05-28-191056_make_users_password_nullable/up.sql b/migrations/2025-05-28-191056_make_users_password_nullable/up.sql new file mode 100644 index 0000000..7239579 --- /dev/null +++ b/migrations/2025-05-28-191056_make_users_password_nullable/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ALTER COLUMN "password" DROP NOT NULL; diff --git a/migrations/2025-05-28-200403_rename_users_app_version_and_make_it_nullable/down.sql b/migrations/2025-05-28-200403_rename_users_app_version_and_make_it_nullable/down.sql new file mode 100644 index 0000000..0945853 --- /dev/null +++ b/migrations/2025-05-28-200403_rename_users_app_version_and_make_it_nullable/down.sql @@ -0,0 +1,3 @@ +UPDATE users SET "android_version" = '' WHERE "android_version" IS NULL; +ALTER TABLE users ALTER COLUMN "android_version" SET NOT NULL; +ALTER TABLE users RENAME COLUMN android_version TO "version"; \ No newline at end of file diff --git a/migrations/2025-05-28-200403_rename_users_app_version_and_make_it_nullable/up.sql b/migrations/2025-05-28-200403_rename_users_app_version_and_make_it_nullable/up.sql new file mode 100644 index 0000000..ce0c331 --- /dev/null +++ b/migrations/2025-05-28-200403_rename_users_app_version_and_make_it_nullable/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users RENAME COLUMN "version" TO android_version; +ALTER TABLE users ALTER COLUMN android_version DROP NOT NULL; \ No newline at end of file diff --git a/migrations/2025-05-29-140941_make_users_group_nullable/down.sql b/migrations/2025-05-29-140941_make_users_group_nullable/down.sql new file mode 100644 index 0000000..47d6ea3 --- /dev/null +++ b/migrations/2025-05-29-140941_make_users_group_nullable/down.sql @@ -0,0 +1,2 @@ +UPDATE users SET "group" = '' WHERE "group" IS NULL; +ALTER TABLE users ALTER COLUMN "group" SET NOT NULL; \ No newline at end of file diff --git a/migrations/2025-05-29-140941_make_users_group_nullable/up.sql b/migrations/2025-05-29-140941_make_users_group_nullable/up.sql new file mode 100644 index 0000000..21f7836 --- /dev/null +++ b/migrations/2025-05-29-140941_make_users_group_nullable/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ALTER COLUMN "group" DROP NOT NULL; diff --git a/migrations/2025-05-29-142329_make_users_access_token_nullable/down.sql b/migrations/2025-05-29-142329_make_users_access_token_nullable/down.sql new file mode 100644 index 0000000..5695b0d --- /dev/null +++ b/migrations/2025-05-29-142329_make_users_access_token_nullable/down.sql @@ -0,0 +1,2 @@ +UPDATE users SET "access_token" = '' WHERE "access_token" IS NULL; +ALTER TABLE users ALTER COLUMN "access_token" SET NOT NULL; \ No newline at end of file diff --git a/migrations/2025-05-29-142329_make_users_access_token_nullable/up.sql b/migrations/2025-05-29-142329_make_users_access_token_nullable/up.sql new file mode 100644 index 0000000..8b8f11d --- /dev/null +++ b/migrations/2025-05-29-142329_make_users_access_token_nullable/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ALTER COLUMN "access_token" DROP NOT NULL; diff --git a/src/app_state.rs b/src/app_state.rs deleted file mode 100644 index ecdde37..0000000 --- a/src/app_state.rs +++ /dev/null @@ -1,88 +0,0 @@ -use schedule_parser::schema::ParseResult; -use crate::utility::hasher::DigestHasher; -use crate::xls_downloader::basic_impl::BasicXlsDownloader; -use actix_web::web; -use chrono::{DateTime, Utc}; -use diesel::{Connection, PgConnection}; -use firebase_messaging_rs::FCMClient; -use sha1::{Digest, Sha1}; -use std::env; -use std::hash::Hash; -use std::sync::Mutex; - -#[derive(Clone)] -pub struct Schedule { - pub etag: String, - pub fetched_at: DateTime, - pub updated_at: DateTime, - pub parsed_at: DateTime, - pub data: ParseResult, -} - -#[derive(Clone)] -pub struct VkId { - pub client_id: i32, - pub redirect_url: String, -} - -impl VkId { - pub fn new() -> Self { - Self { - client_id: env::var("VKID_CLIENT_ID") - .expect("VKID_CLIENT_ID must be set") - .parse() - .expect("VKID_CLIENT_ID must be integer"), - redirect_url: env::var("VKID_REDIRECT_URI").expect("VKID_REDIRECT_URI must be set"), - } - } -} - -impl Schedule { - pub fn hash(&self) -> String { - let mut hasher = DigestHasher::from(Sha1::new()); - - self.etag.hash(&mut hasher); - - self.data.teachers.iter().for_each(|e| e.hash(&mut hasher)); - self.data.groups.iter().for_each(|e| e.hash(&mut hasher)); - - hasher.finalize() - } -} - -/// Common data provided to endpoints. -pub struct AppState { - pub downloader: Mutex, - pub schedule: Mutex>, - pub database: Mutex, - pub vk_id: VkId, - pub fcm_client: Option>, // в рантайме не меняется, так что опционален мьютекс, а не данные в нём. -} - -impl AppState { - pub async fn new() -> Self { - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - - Self { - downloader: Mutex::new(BasicXlsDownloader::new()), - schedule: Mutex::new(None), - database: Mutex::new( - PgConnection::establish(&database_url) - .unwrap_or_else(|_| panic!("Error connecting to {}", database_url)), - ), - vk_id: VkId::new(), - fcm_client: if env::var("GOOGLE_APPLICATION_CREDENTIALS").is_ok() { - Some(Mutex::new( - FCMClient::new().await.expect("FCM client must be created"), - )) - } else { - None - }, - } - } -} - -/// Create a new object web::Data. -pub async fn app_state() -> web::Data { - web::Data::new(AppState::new().await) -} diff --git a/src/database/driver.rs b/src/database/driver.rs index 285dd95..ac83d69 100644 --- a/src/database/driver.rs +++ b/src/database/driver.rs @@ -1,77 +1,82 @@ pub mod users { - use crate::app_state::AppState; use crate::database::models::User; use crate::database::schema::users::dsl::users; use crate::database::schema::users::dsl::*; - use crate::utility::mutex::MutexScope; + use crate::state::AppState; use actix_web::web; use diesel::{ExpressionMethods, QueryResult, insert_into}; use diesel::{QueryDsl, RunQueryDsl}; use diesel::{SaveChangesDsl, SelectableHelper}; + use std::ops::DerefMut; - pub fn get(state: &web::Data, _id: &String) -> QueryResult { - state.database.scope(|conn| { - users - .filter(id.eq(_id)) - .select(User::as_select()) - .first(conn) - }) + pub async fn get(state: &web::Data, _id: &String) -> QueryResult { + users + .filter(id.eq(_id)) + .select(User::as_select()) + .first(state.get_database().await.deref_mut()) } - pub fn get_by_username(state: &web::Data, _username: &String) -> QueryResult { - state.database.scope(|conn| { - users - .filter(username.eq(_username)) - .select(User::as_select()) - .first(conn) - }) + pub async fn get_by_username( + state: &web::Data, + _username: &String, + ) -> QueryResult { + users + .filter(username.eq(_username)) + .select(User::as_select()) + .first(state.get_database().await.deref_mut()) } //noinspection RsTraitObligations - pub fn get_by_vk_id(state: &web::Data, _vk_id: i32) -> QueryResult { - state.database.scope(|conn| { - users - .filter(vk_id.eq(_vk_id)) - .select(User::as_select()) - .first(conn) - }) + pub async fn get_by_vk_id(state: &web::Data, _vk_id: i32) -> QueryResult { + users + .filter(vk_id.eq(_vk_id)) + .select(User::as_select()) + .first(state.get_database().await.deref_mut()) + } + + //noinspection RsTraitObligations + pub async fn get_by_telegram_id( + state: &web::Data, + _telegram_id: i64, + ) -> QueryResult { + users + .filter(telegram_id.eq(_telegram_id)) + .select(User::as_select()) + .first(state.get_database().await.deref_mut()) } //noinspection DuplicatedCode - pub fn contains_by_username(state: &web::Data, _username: &String) -> bool { + pub async fn contains_by_username(state: &web::Data, _username: &String) -> bool { // и как это нахуй сократить блять примеров нихуя нет, нихуя не работает // как меня этот раст заебал уже - state.database.scope(|conn| { - match users - .filter(username.eq(_username)) - .count() - .get_result::(conn) - { - Ok(count) => count > 0, - Err(_) => false, - } - }) + + match users + .filter(username.eq(_username)) + .count() + .get_result::(state.get_database().await.deref_mut()) + { + Ok(count) => count > 0, + Err(_) => false, + } } //noinspection DuplicatedCode //noinspection RsTraitObligations - pub fn contains_by_vk_id(state: &web::Data, _vk_id: i32) -> bool { - state.database.scope(|conn| { - match users - .filter(vk_id.eq(_vk_id)) - .count() - .get_result::(conn) - { - Ok(count) => count > 0, - Err(_) => false, - } - }) + pub async fn contains_by_vk_id(state: &web::Data, _vk_id: i32) -> bool { + match users + .filter(vk_id.eq(_vk_id)) + .count() + .get_result::(state.get_database().await.deref_mut()) + { + Ok(count) => count > 0, + Err(_) => false, + } } - pub fn insert(state: &web::Data, user: &User) -> QueryResult { - state - .database - .scope(|conn| insert_into(users).values(user).execute(conn)) + pub async fn insert(state: &web::Data, user: &User) -> QueryResult { + insert_into(users) + .values(user) + .execute(state.get_database().await.deref_mut()) } /// Function declaration [User::save][UserSave::save]. @@ -113,51 +118,47 @@ pub mod users { /// } /// } /// ``` - fn save(&self, state: &web::Data) -> QueryResult; + async fn save(&self, state: &web::Data) -> QueryResult; } /// Implementation of [UserSave][UserSave] trait. impl UserSave for User { - fn save(&self, state: &web::Data) -> QueryResult { - state.database.scope(|conn| self.save_changes::(conn)) + async fn save(&self, state: &web::Data) -> QueryResult { + self.save_changes::(state.get_database().await.deref_mut()) } } #[cfg(test)] - pub fn delete_by_username(state: &web::Data, _username: &String) -> bool { - state.database.scope(|conn| { - match diesel::delete(users.filter(username.eq(_username))).execute(conn) { - Ok(count) => count > 0, - Err(_) => false, - } - }) + pub async fn delete_by_username(state: &web::Data, _username: &String) -> bool { + match diesel::delete(users.filter(username.eq(_username))) + .execute(state.get_database().await.deref_mut()) + { + Ok(count) => count > 0, + Err(_) => false, + } } #[cfg(test)] - pub fn insert_or_ignore(state: &web::Data, user: &User) -> QueryResult { - state.database.scope(|conn| { - insert_into(users) - .values(user) - .on_conflict_do_nothing() - .execute(conn) - }) + pub async fn insert_or_ignore(state: &web::Data, user: &User) -> QueryResult { + insert_into(users) + .values(user) + .on_conflict_do_nothing() + .execute(state.get_database().await.deref_mut()) } } pub mod fcm { - use crate::app_state::AppState; use crate::database::models::{FCM, User}; - use crate::utility::mutex::MutexScope; + use crate::state::AppState; use actix_web::web; use diesel::QueryDsl; use diesel::RunQueryDsl; use diesel::{BelongingToDsl, QueryResult, SelectableHelper}; + use std::ops::DerefMut; - pub fn from_user(state: &web::Data, user: &User) -> QueryResult { - state.database.scope(|conn| { - FCM::belonging_to(&user) - .select(FCM::as_select()) - .get_result(conn) - }) + pub async fn from_user(state: &web::Data, user: &User) -> QueryResult { + FCM::belonging_to(&user) + .select(FCM::as_select()) + .get_result(state.get_database().await.deref_mut()) } } diff --git a/src/database/mod.rs b/src/database/mod.rs index 2ff4d25..8eaeed3 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,3 +1,3 @@ -pub mod schema; -pub mod models; pub mod driver; +pub mod models; +pub mod schema; diff --git a/src/database/models.rs b/src/database/models.rs index 39295c0..1155735 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -38,22 +38,25 @@ pub struct User { pub username: String, /// BCrypt password hash. - pub password: String, + pub password: Option, /// ID of the linked VK account. pub vk_id: Option, /// JWT access token. - pub access_token: String, + pub access_token: Option, /// Group. - pub group: String, + pub group: Option, /// Role. pub role: UserRole, /// Version of the installed Polytechnic+ application. - pub version: String, + pub android_version: Option, + + /// ID of the linked Telegram account. + pub telegram_id: Option, } #[derive( diff --git a/src/database/schema.rs b/src/database/schema.rs index 5eaed50..b4dba01 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -21,12 +21,13 @@ diesel::table! { users (id) { id -> Text, username -> Text, - password -> Text, + password -> Nullable, vk_id -> Nullable, - access_token -> Text, - group -> Text, + access_token -> Nullable, + group -> Nullable, role -> UserRole, - version -> Text, + android_version -> Nullable, + telegram_id -> Nullable, } } diff --git a/src/extractors/authorized_user.rs b/src/extractors/authorized_user.rs index 7d4f4e8..e0d7741 100644 --- a/src/extractors/authorized_user.rs +++ b/src/extractors/authorized_user.rs @@ -1,9 +1,9 @@ -use crate::app_state::AppState; use crate::database::driver; use crate::database::models::{FCM, User}; -use crate::extractors::base::{FromRequestSync, SyncExtractor}; +use crate::extractors::base::{AsyncExtractor, FromRequestAsync}; +use crate::state::AppState; use crate::utility::jwt; -use actix_macros::ResponseErrorMessage; +use actix_macros::MiddlewareError; use actix_web::body::BoxBody; use actix_web::dev::Payload; use actix_web::http::header; @@ -12,13 +12,13 @@ use derive_more::Display; use serde::{Deserialize, Serialize}; use std::fmt::Debug; -#[derive(Clone, Debug, Serialize, Deserialize, Display, ResponseErrorMessage)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Display, MiddlewareError)] #[status_code = "actix_web::http::StatusCode::UNAUTHORIZED"] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum Error { - /// There is no Authorization header in the request. - #[display("No Authorization header found")] - NoHeader, + /// There is no Authorization header or cookie in the request. + #[display("No Authorization header or cookie found")] + NoHeaderOrCookieFound, /// Unknown authorization type other than Bearer. #[display("Bearer token is required")] @@ -39,31 +39,60 @@ impl Error { } } +fn get_access_token_from_header(req: &HttpRequest) -> Result { + let header_value = req + .headers() + .get(header::AUTHORIZATION) + .ok_or(Error::NoHeaderOrCookieFound)? + .to_str() + .map_err(|_| Error::NoHeaderOrCookieFound)? + .to_string(); + + let parts = header_value + .split_once(' ') + .ok_or(Error::UnknownAuthorizationType)?; + + if parts.0 != "Bearer" { + Err(Error::UnknownAuthorizationType) + } else { + Ok(parts.1.to_string()) + } +} + +fn get_access_token_from_cookies(req: &HttpRequest) -> Result { + let cookie = req + .cookie("access_token") + .ok_or(Error::NoHeaderOrCookieFound)?; + + Ok(cookie.value().to_string()) +} + /// User extractor from request with Bearer access token. -impl FromRequestSync for User { +impl FromRequestAsync for User { type Error = actix_web::Error; - fn from_request_sync(req: &HttpRequest, _: &mut Payload) -> Result { - let authorization = req - .headers() - .get(header::AUTHORIZATION) - .ok_or(Error::NoHeader.into_err())? - .to_str() - .map_err(|_| Error::NoHeader.into_err())? - .to_string(); + async fn from_request_async( + req: &HttpRequest, + _payload: &mut Payload, + ) -> Result { + let access_token = match get_access_token_from_header(req) { + Err(Error::NoHeaderOrCookieFound) => { + get_access_token_from_cookies(req).map_err(|error| error.into_err())? + } + Err(error) => { + return Err(error.into_err()); + } + Ok(access_token) => access_token, + }; - let parts: Vec<&str> = authorization.split(' ').collect(); - - if parts.len() != 2 || parts[0] != "Bearer" { - return Err(Error::UnknownAuthorizationType.into_err()); - } - - let user_id = jwt::verify_and_decode(&parts[1].to_string()) + let user_id = jwt::verify_and_decode(&access_token) .map_err(|_| Error::InvalidAccessToken.into_err())?; let app_state = req.app_data::>().unwrap(); - driver::users::get(&app_state, &user_id).map_err(|_| Error::NoUser.into()) + driver::users::get(app_state, &user_id) + .await + .map_err(|_| Error::NoUser.into()) } } @@ -88,19 +117,22 @@ impl UserExtractor<{ FCM }> { } /// Extractor of user and additional parameters from request with Bearer token. -impl FromRequestSync for UserExtractor<{ FCM }> { +impl FromRequestAsync for UserExtractor<{ FCM }> { type Error = actix_web::Error; - fn from_request_sync(req: &HttpRequest, payload: &mut Payload) -> Result { - let user = SyncExtractor::::from_request(req, payload) - .into_inner()? + async fn from_request_async( + req: &HttpRequest, + payload: &mut Payload, + ) -> Result { + let user = AsyncExtractor::::from_request(req, payload) + .await? .into_inner(); let app_state = req.app_data::>().unwrap(); Ok(Self { fcm: if FCM { - driver::fcm::from_user(&app_state, &user).ok() + driver::fcm::from_user(&app_state, &user).await.ok() } else { None }, diff --git a/src/extractors/base.rs b/src/extractors/base.rs index f487e9d..9207960 100644 --- a/src/extractors/base.rs +++ b/src/extractors/base.rs @@ -57,18 +57,22 @@ pub trait FromRequestAsync: Sized { /// web::Json(user) /// } /// ``` - async fn from_request_async(req: HttpRequest, payload: Payload) -> Result; + async fn from_request_async( + req: &HttpRequest, + payload: &mut Payload, + ) -> Result; } impl FromRequest for AsyncExtractor { type Error = T::Error; type Future = LocalBoxFuture<'static, Result>; - fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { + fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { let req = req.clone(); - let payload = payload.take(); + let mut payload = Payload::None; + Box::pin(async move { - T::from_request_async(req, payload) + T::from_request_async(&req, &mut payload) .await .map(|res| Self(res)) }) @@ -82,6 +86,7 @@ pub struct SyncExtractor(T); impl SyncExtractor { /// Retrieving an object extracted with the extractor. + #[allow(unused)] pub fn into_inner(self) -> T { self.0 } diff --git a/src/extractors/mod.rs b/src/extractors/mod.rs index b4e004d..21acebc 100644 --- a/src/extractors/mod.rs +++ b/src/extractors/mod.rs @@ -1,2 +1,2 @@ pub mod authorized_user; -pub mod base; \ No newline at end of file +pub mod base; diff --git a/src/main.rs b/src/main.rs index 6c17a20..4620572 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,16 @@ -use crate::app_state::{AppState, app_state}; use crate::middlewares::authorization::JWTAuthorization; use crate::middlewares::content_type::ContentTypeBootstrap; +use crate::state::{AppState, new_app_state}; use actix_web::dev::{ServiceFactory, ServiceRequest}; use actix_web::{App, Error, HttpServer}; use dotenvy::dotenv; +use log::info; use std::io; use utoipa_actix_web::AppExt; use utoipa_actix_web::scope::Scope; use utoipa_rapidoc::RapiDoc; -mod app_state; +mod state; mod database; @@ -46,7 +47,6 @@ pub fn get_api_scope< ignore: &["/group-names", "/teacher-names"], }) .service(routes::schedule::schedule) - .service(routes::schedule::update_download_url) .service(routes::schedule::cache_status) .service(routes::schedule::group) .service(routes::schedule::group_names) @@ -58,6 +58,13 @@ pub fn get_api_scope< .service(routes::fcm::update_callback) .service(routes::fcm::set_token); + let flow_scope = utoipa_actix_web::scope("/flow") + .wrap(JWTAuthorization { + ignore: &["/telegram-auth"], + }) + .service(routes::flow::telegram_auth) + .service(routes::flow::telegram_complete); + let vk_id_scope = utoipa_actix_web::scope("/vkid") // .service(routes::vk_id::oauth); @@ -66,13 +73,14 @@ pub fn get_api_scope< .service(users_scope) .service(schedule_scope) .service(fcm_scope) + .service(flow_scope) .service(vk_id_scope) } async fn async_main() -> io::Result<()> { - println!("Starting server..."); + info!("Запуск сервера..."); - let app_state = app_state().await; + let app_state = new_app_state().await.unwrap(); HttpServer::new(move || { let (app, api) = App::new() diff --git a/src/middlewares/authorization.rs b/src/middlewares/authorization.rs index 3e0892c..e8d700c 100644 --- a/src/middlewares/authorization.rs +++ b/src/middlewares/authorization.rs @@ -1,11 +1,12 @@ use crate::database::models::User; use crate::extractors::authorized_user; -use crate::extractors::base::FromRequestSync; +use crate::extractors::base::FromRequestAsync; use actix_web::body::{BoxBody, EitherBody}; use actix_web::dev::{Payload, Service, ServiceRequest, ServiceResponse, Transform, forward_ready}; use actix_web::{Error, HttpRequest, ResponseError}; use futures_util::future::LocalBoxFuture; use std::future::{Ready, ready}; +use std::rc::Rc; /// Middleware guard working with JWT tokens. pub struct JWTAuthorization { @@ -21,7 +22,7 @@ impl Default for JWTAuthorization { impl Transform for JWTAuthorization where - S: Service, Error = Error>, + S: Service, Error = Error> + 'static, S::Future: 'static, B: 'static, { @@ -33,14 +34,14 @@ where fn new_transform(&self, service: S) -> Self::Future { ready(Ok(JWTAuthorizationMiddleware { - service, + service: Rc::new(service), ignore: self.ignore, })) } } pub struct JWTAuthorizationMiddleware { - service: S, + service: Rc, /// List of ignored endpoints. ignore: &'static [&'static str], } @@ -52,12 +53,11 @@ where B: 'static, { /// Checking the validity of the token. - fn check_authorization( - &self, - req: &HttpRequest, - payload: &mut Payload, - ) -> Result<(), authorized_user::Error> { - User::from_request_sync(req, payload) + async fn check_authorization(req: &HttpRequest) -> Result<(), authorized_user::Error> { + let mut payload = Payload::None; + + User::from_request_async(req, &mut payload) + .await .map(|_| ()) .map_err(|e| e.as_error::().unwrap().clone()) } @@ -79,9 +79,9 @@ where } } -impl<'a, S, B> Service for JWTAuthorizationMiddleware +impl Service for JWTAuthorizationMiddleware where - S: Service, Error = Error>, + S: Service, Error = Error> + 'static, S::Future: 'static, B: 'static, { @@ -97,20 +97,19 @@ where return Box::pin(async move { Ok(fut.await?.map_into_left_body()) }); } - let (http_req, mut payload) = req.into_parts(); + let service = Rc::clone(&self.service); - if let Err(err) = self.check_authorization(&http_req, &mut payload) { - return Box::pin(async move { - Ok(ServiceResponse::new( - http_req, + Box::pin(async move { + match Self::check_authorization(req.request()).await { + Ok(_) => { + let fut = service.call(req).await?; + Ok(fut.map_into_left_body()) + } + Err(err) => Ok(ServiceResponse::new( + req.into_parts().0, err.error_response().map_into_right_body(), - )) - }); - } - - let req = ServiceRequest::from_parts(http_req, payload); - let fut = self.service.call(req); - - Box::pin(async move { Ok(fut.await?.map_into_left_body()) }) + )), + } + }) } } diff --git a/src/middlewares/mod.rs b/src/middlewares/mod.rs index 5e87410..5e81776 100644 --- a/src/middlewares/mod.rs +++ b/src/middlewares/mod.rs @@ -1,2 +1,2 @@ pub mod authorization; -pub mod content_type; \ No newline at end of file +pub mod content_type; diff --git a/src/routes/auth/mod.rs b/src/routes/auth/mod.rs index 698dc7d..d68bf01 100644 --- a/src/routes/auth/mod.rs +++ b/src/routes/auth/mod.rs @@ -1,8 +1,8 @@ +mod shared; mod sign_in; mod sign_up; -mod shared; pub use sign_in::*; pub use sign_up::*; -// TODO: change-password \ No newline at end of file +// TODO: change-password diff --git a/src/routes/auth/shared.rs b/src/routes/auth/shared.rs index 193de92..4341dcd 100644 --- a/src/routes/auth/shared.rs +++ b/src/routes/auth/shared.rs @@ -1,5 +1,5 @@ use jsonwebtoken::errors::ErrorKind; -use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize)] diff --git a/src/routes/auth/sign_in.rs b/src/routes/auth/sign_in.rs index 3c4eef2..6260b57 100644 --- a/src/routes/auth/sign_in.rs +++ b/src/routes/auth/sign_in.rs @@ -1,14 +1,12 @@ use self::schema::*; use crate::database::driver; -use crate::database::models::User; +use crate::database::driver::users::UserSave; use crate::routes::auth::shared::parse_vk_id; -use crate::routes::auth::sign_in::schema::SignInData::{Default, Vk}; +use crate::routes::auth::sign_in::schema::SignInData::{Default, VkOAuth}; +use crate::routes::schema::ResponseError; use crate::routes::schema::user::UserResponse; -use crate::routes::schema::{IntoResponseAsError, ResponseError}; -use crate::utility::mutex::MutexScope; use crate::{AppState, utility}; use actix_web::{post, web}; -use diesel::SaveChangesDsl; use web::Json; async fn sign_in_combined( @@ -16,14 +14,18 @@ async fn sign_in_combined( app_state: &web::Data, ) -> Result { let user = match &data { - Default(data) => driver::users::get_by_username(&app_state, &data.username), - Vk(id) => driver::users::get_by_vk_id(&app_state, *id), + Default(data) => driver::users::get_by_username(&app_state, &data.username).await, + VkOAuth(id) => driver::users::get_by_vk_id(&app_state, *id).await, }; match user { Ok(mut user) => { if let Default(data) = data { - match bcrypt::verify(&data.password, &user.password) { + if user.password.is_none() { + return Err(ErrorCode::IncorrectCredentials); + } + + match bcrypt::verify(&data.password, &user.password.as_ref().unwrap()) { Ok(result) => { if !result { return Err(ErrorCode::IncorrectCredentials); @@ -35,12 +37,9 @@ async fn sign_in_combined( } } - user.access_token = utility::jwt::encode(&user.id); + user.access_token = Some(utility::jwt::encode(&user.id)); - app_state.database.scope(|conn| { - user.save_changes::(conn) - .expect("Failed to update user") - }); + user.save(&app_state).await.expect("Failed to update user"); Ok(user.into()) } @@ -71,15 +70,17 @@ pub async fn sign_in_vk( ) -> ServiceResponse { let data = data_json.into_inner(); - match parse_vk_id(&data.access_token, app_state.vk_id.client_id) { - Ok(id) => sign_in_combined(Vk(id), &app_state).await.into(), - Err(_) => ErrorCode::InvalidVkAccessToken.into_response(), + match parse_vk_id(&data.access_token, app_state.get_env().vk_id.client_id) { + Ok(id) => sign_in_combined(VkOAuth(id), &app_state).await, + Err(_) => Err(ErrorCode::InvalidVkAccessToken), } + .into() } mod schema { use crate::routes::schema::user::UserResponse; - use actix_macros::{IntoResponseError, StatusCode}; + use actix_macros::ErrResponse; + use derive_more::Display; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -109,15 +110,17 @@ mod schema { pub type ServiceResponse = crate::routes::schema::Response; - #[derive(Serialize, ToSchema, Clone, IntoResponseError, StatusCode)] - #[serde(rename_all = "SCREAMING_SNAKE_CASE")] + #[derive(Clone, Serialize, Display, ToSchema, ErrResponse)] #[schema(as = SignIn::ErrorCode)] + #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[status_code = "actix_web::http::StatusCode::NOT_ACCEPTABLE"] pub enum ErrorCode { /// Incorrect username or password. + #[display("Incorrect username or password.")] IncorrectCredentials, /// Invalid VK ID token. + #[display("Invalid VK ID token.")] InvalidVkAccessToken, } @@ -129,7 +132,7 @@ mod schema { Default(Request), /// Identifier of the attached account VK. - Vk(i32), + VkOAuth(i32), } } @@ -150,7 +153,7 @@ mod tests { use std::fmt::Write; async fn sign_in_client(data: Request) -> ServiceResponse { - let app = test_app(test_app_state(Default::default()).await, sign_in).await; + let app = test_app(test_app_state().await, sign_in).await; let req = test::TestRequest::with_uri("/sign-in") .method(Method::POST) @@ -184,14 +187,16 @@ mod tests { &User { id: id.clone(), username, - password: bcrypt::hash("example".to_string(), bcrypt::DEFAULT_COST).unwrap(), + password: Some(bcrypt::hash("example".to_string(), bcrypt::DEFAULT_COST).unwrap()), vk_id: None, - access_token: utility::jwt::encode(&id), - group: "ИС-214/23".to_string(), + telegram_id: None, + access_token: Some(utility::jwt::encode(&id)), + group: Some("ИС-214/23".to_string()), role: UserRole::Student, - version: "1.0.0".to_string(), + android_version: None, }, ) + .await .unwrap(); } diff --git a/src/routes/auth/sign_up.rs b/src/routes/auth/sign_up.rs index 15cccc5..6245709 100644 --- a/src/routes/auth/sign_up.rs +++ b/src/routes/auth/sign_up.rs @@ -2,11 +2,10 @@ use self::schema::*; use crate::AppState; use crate::database::driver; use crate::database::models::UserRole; -use crate::routes::auth::shared::{Error, parse_vk_id}; +use crate::routes::auth::shared::parse_vk_id; +use crate::routes::schema::ResponseError; use crate::routes::schema::user::UserResponse; -use crate::routes::schema::{IntoResponseAsError, ResponseError}; use actix_web::{post, web}; -use rand::{Rng, rng}; use web::Json; async fn sign_up_combined( @@ -18,29 +17,30 @@ async fn sign_up_combined( return Err(ErrorCode::DisallowedRole); } - // If specified group doesn't exist in schedule. - let schedule_opt = app_state.schedule.lock().unwrap(); - - if let Some(schedule) = &*schedule_opt { - if !schedule.data.groups.contains_key(&data.group) { - return Err(ErrorCode::InvalidGroupName); - } + if !app_state + .get_schedule_snapshot() + .await + .data + .groups + .contains_key(&data.group) + { + return Err(ErrorCode::InvalidGroupName); } // If user with specified username already exists. - if driver::users::contains_by_username(&app_state, &data.username) { + if driver::users::contains_by_username(&app_state, &data.username).await { return Err(ErrorCode::UsernameAlreadyExists); } // If user with specified VKID already exists. if let Some(id) = data.vk_id { - if driver::users::contains_by_vk_id(&app_state, id) { + if driver::users::contains_by_vk_id(&app_state, id).await { return Err(ErrorCode::VkAlreadyExists); } } let user = data.into(); - driver::users::insert(&app_state, &user).unwrap(); + driver::users::insert(&app_state, &user).await.unwrap(); Ok(UserResponse::from(&user)).into() } @@ -56,7 +56,7 @@ pub async fn sign_up(data_json: Json, app_state: web::Data) - sign_up_combined( SignUpData { username: data.username, - password: data.password, + password: Some(data.password), vk_id: None, group: data.group, role: data.role, @@ -79,40 +79,32 @@ pub async fn sign_up_vk( ) -> ServiceResponse { let data = data_json.into_inner(); - match parse_vk_id(&data.access_token, app_state.vk_id.client_id) { - Ok(id) => sign_up_combined( - SignUpData { - username: data.username, - password: rng() - .sample_iter(&rand::distr::Alphanumeric) - .take(16) - .map(char::from) - .collect(), - vk_id: Some(id), - group: data.group, - role: data.role, - version: data.version, - }, - &app_state, - ) - .await - .into(), - Err(err) => { - if err != Error::Expired { - eprintln!("Failed to parse vk id token!"); - eprintln!("{:?}", err); - } - - ErrorCode::InvalidVkAccessToken.into_response() + match parse_vk_id(&data.access_token, app_state.get_env().vk_id.client_id) { + Ok(id) => { + sign_up_combined( + SignUpData { + username: data.username, + password: None, + vk_id: Some(id), + group: data.group, + role: data.role, + version: data.version, + }, + &app_state, + ) + .await } + Err(_) => Err(ErrorCode::InvalidVkAccessToken), } + .into() } mod schema { use crate::database::models::{User, UserRole}; use crate::routes::schema::user::UserResponse; use crate::utility; - use actix_macros::{IntoResponseError, StatusCode}; + use actix_macros::ErrResponse; + use derive_more::Display; use objectid::ObjectId; use serde::{Deserialize, Serialize}; @@ -170,24 +162,29 @@ mod schema { pub type ServiceResponse = crate::routes::schema::Response; - #[derive(Clone, Serialize, utoipa::ToSchema, IntoResponseError, StatusCode)] + #[derive(Clone, Serialize, Display, utoipa::ToSchema, ErrResponse)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[schema(as = SignUp::ErrorCode)] #[status_code = "actix_web::http::StatusCode::NOT_ACCEPTABLE"] pub enum ErrorCode { /// Conveyed the role of Admin. + #[display("Conveyed the role of Admin.")] DisallowedRole, /// Unknown name of the group. + #[display("Unknown name of the group.")] InvalidGroupName, /// User with this name is already registered. + #[display("User with this name is already registered.")] UsernameAlreadyExists, /// Invalid VK ID token. + #[display("Invalid VK ID token.")] InvalidVkAccessToken, /// User with such an account VK is already registered. + #[display("User with such an account VK is already registered.")] VkAlreadyExists, } @@ -195,13 +192,14 @@ mod schema { /// Data for registration. pub struct SignUpData { + // TODO: сделать ограничение на минимальную и максимальную длину при регистрации и смене. /// User name. pub username: String, /// Password. /// /// Should be present even if registration occurs using the VK ID token. - pub password: String, + pub password: Option, /// Account identifier VK. pub vk_id: Option, @@ -218,18 +216,23 @@ mod schema { impl Into for SignUpData { fn into(self) -> User { + assert_ne!(self.password.is_some(), self.vk_id.is_some()); + let id = ObjectId::new().unwrap().to_string(); - let access_token = utility::jwt::encode(&id); + let access_token = Some(utility::jwt::encode(&id)); User { id, username: self.username, - password: bcrypt::hash(self.password, bcrypt::DEFAULT_COST).unwrap(), + password: self + .password + .map(|x| bcrypt::hash(x, bcrypt::DEFAULT_COST).unwrap()), vk_id: self.vk_id, + telegram_id: None, access_token, - group: self.group, + group: Some(self.group), role: self.role, - version: self.version, + android_version: Some(self.version), } } } @@ -241,42 +244,28 @@ mod tests { use crate::database::models::UserRole; use crate::routes::auth::sign_up::schema::Request; use crate::routes::auth::sign_up::sign_up; - use crate::test_env::tests::{ - TestAppStateParams, TestScheduleType, static_app_state, test_app_state, test_env, - }; + use crate::test_env::tests::{static_app_state, test_app_state, test_env}; use actix_test::test_app; use actix_web::dev::ServiceResponse; use actix_web::http::Method; use actix_web::http::StatusCode; use actix_web::test; - struct SignUpPartial { - username: String, - group: String, + struct SignUpPartial<'a> { + username: &'a str, + group: &'a str, role: UserRole, - load_schedule: bool, } - async fn sign_up_client(data: SignUpPartial) -> ServiceResponse { - let app = test_app( - test_app_state(TestAppStateParams { - schedule: if data.load_schedule { - TestScheduleType::Local - } else { - TestScheduleType::None - }, - }) - .await, - sign_up, - ) - .await; + async fn sign_up_client(data: SignUpPartial<'_>) -> ServiceResponse { + let app = test_app(test_app_state().await, sign_up).await; let req = test::TestRequest::with_uri("/sign-up") .method(Method::POST) .set_json(Request { - username: data.username.clone(), + username: data.username.to_string(), password: "example".to_string(), - group: data.group.clone(), + group: data.group.to_string(), role: data.role.clone(), version: "1.0.0".to_string(), }) @@ -292,15 +281,14 @@ mod tests { test_env(); let app_state = static_app_state().await; - driver::users::delete_by_username(&app_state, &"test::sign_up_valid".to_string()); + driver::users::delete_by_username(&app_state, &"test::sign_up_valid".to_string()).await; // test let resp = sign_up_client(SignUpPartial { - username: "test::sign_up_valid".to_string(), - group: "ИС-214/23".to_string(), + username: "test::sign_up_valid", + group: "ИС-214/23", role: UserRole::Student, - load_schedule: false, }) .await; @@ -314,23 +302,21 @@ mod tests { test_env(); let app_state = static_app_state().await; - driver::users::delete_by_username(&app_state, &"test::sign_up_multiple".to_string()); + driver::users::delete_by_username(&app_state, &"test::sign_up_multiple".to_string()).await; let create = sign_up_client(SignUpPartial { - username: "test::sign_up_multiple".to_string(), - group: "ИС-214/23".to_string(), + username: "test::sign_up_multiple", + group: "ИС-214/23", role: UserRole::Student, - load_schedule: false, }) .await; assert_eq!(create.status(), StatusCode::OK); let resp = sign_up_client(SignUpPartial { - username: "test::sign_up_multiple".to_string(), - group: "ИС-214/23".to_string(), + username: "test::sign_up_multiple", + group: "ИС-214/23", role: UserRole::Student, - load_schedule: false, }) .await; @@ -343,10 +329,9 @@ mod tests { // test let resp = sign_up_client(SignUpPartial { - username: "test::sign_up_invalid_role".to_string(), - group: "ИС-214/23".to_string(), + username: "test::sign_up_invalid_role", + group: "ИС-214/23", role: UserRole::Admin, - load_schedule: false, }) .await; @@ -359,10 +344,9 @@ mod tests { // test let resp = sign_up_client(SignUpPartial { - username: "test::sign_up_invalid_group".to_string(), - group: "invalid_group".to_string(), + username: "test::sign_up_invalid_group", + group: "invalid_group", role: UserRole::Student, - load_schedule: true, }) .await; diff --git a/src/routes/fcm/mod.rs b/src/routes/fcm/mod.rs index b422206..ac643d7 100644 --- a/src/routes/fcm/mod.rs +++ b/src/routes/fcm/mod.rs @@ -1,5 +1,5 @@ -mod update_callback; mod set_token; +mod update_callback; -pub use update_callback::*; pub use set_token::*; +pub use update_callback::*; diff --git a/src/routes/fcm/set_token.rs b/src/routes/fcm/set_token.rs index d447553..e01bab7 100644 --- a/src/routes/fcm/set_token.rs +++ b/src/routes/fcm/set_token.rs @@ -1,14 +1,13 @@ -use crate::app_state::AppState; use crate::database; use crate::database::models::FCM; use crate::extractors::authorized_user::UserExtractor; -use crate::extractors::base::SyncExtractor; -use crate::utility::mutex::{MutexScope, MutexScopeAsync}; +use crate::extractors::base::AsyncExtractor; +use crate::state::AppState; use actix_web::{HttpResponse, Responder, patch, web}; use diesel::{RunQueryDsl, SaveChangesDsl}; -use firebase_messaging_rs::FCMClient; -use firebase_messaging_rs::topic::{TopicManagementError, TopicManagementSupport}; +use firebase_messaging_rs::topic::TopicManagementSupport; use serde::Deserialize; +use std::ops::DerefMut; #[derive(Debug, Deserialize)] struct Params { @@ -34,11 +33,10 @@ async fn get_fcm( topics: vec![], }; - match app_state.database.scope(|conn| { - diesel::insert_into(database::schema::fcm::table) - .values(&fcm) - .execute(conn) - }) { + match diesel::insert_into(database::schema::fcm::table) + .values(&fcm) + .execute(app_state.get_database().await.deref_mut()) + { Ok(_) => Ok(fcm), Err(e) => Err(e), } @@ -51,7 +49,7 @@ async fn get_fcm( pub async fn set_token( app_state: web::Data, web::Query(params): web::Query, - user_data: SyncExtractor>, + user_data: AsyncExtractor>, ) -> impl Responder { let user_data = user_data.into_inner(); @@ -75,39 +73,21 @@ pub async fn set_token( fcm.topics.push(Some("common".to_string())); } - // Subscribe to default topics. - if let Some(e) = app_state - .fcm_client - .as_ref() - .unwrap() - .async_scope( - async |client: &mut FCMClient| -> Result<(), TopicManagementError> { - let mut tokens: Vec = Vec::new(); - tokens.push(fcm.token.clone()); + fcm.save_changes::(app_state.get_database().await.deref_mut()) + .unwrap(); - for topic in fcm.topics.clone() { - if let Some(topic) = topic { - client.register_tokens_to_topic(topic.clone(), tokens.clone()).await?; - } - } + let fcm_client = app_state.get_fcm_client().await.unwrap(); - Ok(()) - }, - ) - .await - .err() - { - eprintln!("Failed to subscribe token to topic: {:?}", e); - return HttpResponse::Ok(); - } - - // Write updates to db. - if let Some(e) = app_state - .database - .scope(|conn| fcm.save_changes::(conn)) - .err() - { - eprintln!("Failed to update FCM object: {e}"); + for topic in fcm.topics.clone() { + if let Some(topic) = topic { + if let Err(error) = fcm_client + .register_token_to_topic(&*topic, &*fcm.token) + .await + { + eprintln!("Failed to subscribe token to topic: {:?}", error); + return HttpResponse::Ok(); + } + } } HttpResponse::Ok() diff --git a/src/routes/fcm/update_callback.rs b/src/routes/fcm/update_callback.rs index 4d29770..6dad89b 100644 --- a/src/routes/fcm/update_callback.rs +++ b/src/routes/fcm/update_callback.rs @@ -1,9 +1,8 @@ -use crate::app_state::AppState; +use crate::database::driver::users::UserSave; use crate::database::models::User; -use crate::extractors::base::SyncExtractor; -use crate::utility::mutex::MutexScope; +use crate::extractors::base::AsyncExtractor; +use crate::state::AppState; use actix_web::{HttpResponse, Responder, post, web}; -use diesel::SaveChangesDsl; #[utoipa::path(responses( (status = OK), @@ -13,20 +12,13 @@ use diesel::SaveChangesDsl; async fn update_callback( app_state: web::Data, version: web::Path, - user: SyncExtractor, + user: AsyncExtractor, ) -> impl Responder { let mut user = user.into_inner(); - user.version = version.into_inner(); + user.android_version = Some(version.into_inner()); - match app_state - .database - .scope(|conn| user.save_changes::(conn)) - { - Ok(_) => HttpResponse::Ok(), - Err(e) => { - eprintln!("Failed to update user: {}", e); - HttpResponse::InternalServerError() - } - } + user.save(&app_state).await.unwrap(); + + HttpResponse::Ok() } diff --git a/src/routes/flow/mod.rs b/src/routes/flow/mod.rs new file mode 100644 index 0000000..b41cf8e --- /dev/null +++ b/src/routes/flow/mod.rs @@ -0,0 +1,5 @@ +mod telegram_auth; +mod telegram_complete; + +pub use telegram_auth::*; +pub use telegram_complete::*; diff --git a/src/routes/flow/telegram_auth.rs b/src/routes/flow/telegram_auth.rs new file mode 100644 index 0000000..a619da5 --- /dev/null +++ b/src/routes/flow/telegram_auth.rs @@ -0,0 +1,183 @@ +use self::schema::*; +use crate::database::driver; +use crate::database::driver::users::UserSave; +use crate::database::models::{User, UserRole}; +use crate::routes::schema::ResponseError; +use crate::utility::telegram::{WebAppInitDataMap, WebAppUser}; +use crate::{AppState, utility}; +use actix_web::{post, web}; +use chrono::{DateTime, Duration, Utc}; +use objectid::ObjectId; +use std::sync::Arc; +use web::Json; + +#[utoipa::path(responses( + (status = OK, body = Response), + (status = UNAUTHORIZED, body = ResponseError), +))] +#[post("/telegram-auth")] +pub async fn telegram_auth( + data_json: Json, + app_state: web::Data, +) -> ServiceResponse { + let init_data = WebAppInitDataMap::from_str(data_json.into_inner().init_data); + + // for (key, value) in &init_data.data_map { + // println!("key: {} | value: {}", key, value); + // } + + { + let env = &app_state.get_env().telegram; + + if let Err(error) = init_data.verify(env.bot_id, env.test_dc) { + return Err(ErrorCode::InvalidInitData(Arc::new(error))).into(); + } + } + + let auth_date = DateTime::::from_timestamp( + init_data + .data_map + .get("auth_date") + .unwrap() + .parse() + .unwrap(), + 0, + ) + .unwrap(); + + if Utc::now() - auth_date > Duration::minutes(5) { + return Err(ErrorCode::ExpiredInitData).into(); + } + + let web_app_user = + serde_json::from_str::(init_data.data_map.get("user").unwrap()).unwrap(); + + let mut user = { + match driver::users::get_by_telegram_id(&app_state, web_app_user.id).await { + Ok(value) => Ok(value), + Err(_) => { + let new_user = User { + id: ObjectId::new().unwrap().to_string(), + username: format!("telegram_{}", web_app_user.id), // можно оставить, а можно поменять + password: None, // ибо нехуй + vk_id: None, + telegram_id: Some(web_app_user.id), + access_token: None, // установится ниже + group: None, + role: UserRole::Student, // TODO: при реге проверять данные + android_version: None, + }; + + driver::users::insert(&app_state, &new_user) + .await + .map(|_| new_user) + } + } + .expect("Failed to get or add user") + }; + + user.access_token = Some(utility::jwt::encode(&user.id)); + + user.save(&app_state).await.expect("Failed to update user"); + + Ok(Response::new( + &*user.access_token.unwrap(), + user.group.is_some(), + )) + .into() +} + +mod schema { + use crate::routes::schema::PartialOkResponse; + use crate::state::AppState; + use crate::utility::telegram::VerifyError; + use actix_macros::ErrResponse; + use actix_web::body::EitherBody; + use actix_web::cookie::CookieBuilder; + use actix_web::cookie::time::OffsetDateTime; + use actix_web::{HttpRequest, HttpResponse, web}; + use derive_more::Display; + use serde::{Deserialize, Serialize, Serializer}; + use std::ops::Add; + use std::sync::Arc; + use utoipa::ToSchema; + + #[derive(Debug, Deserialize, Serialize, ToSchema)] + #[serde(rename_all = "camelCase")] + #[schema(as = Flow::TelegramAuth::Request)] + pub struct Request { + /// Telegram WebApp init data. + pub init_data: String, + } + + #[derive(Serialize, ToSchema)] + #[serde(rename_all = "camelCase")] + #[schema(as = Flow::TelegramAuth::Response)] + pub struct Response { + #[serde(skip)] + #[schema(ignore)] + access_token: String, + + pub completed: bool, + } + + impl Response { + pub fn new(access_token: &str, completed: bool) -> Self { + Self { + access_token: access_token.to_string(), + completed, + } + } + } + + impl PartialOkResponse for Response { + fn post_process( + &mut self, + request: &HttpRequest, + response: &mut HttpResponse>, + ) -> () { + let access_token = &self.access_token; + + let app_state = request.app_data::>().unwrap(); + let mini_app_host = &*app_state.get_env().telegram.mini_app_host; + + let cookie = CookieBuilder::new("access_token", access_token) + .domain(mini_app_host) + .path("/") + .expires( + OffsetDateTime::now_utc().add(std::time::Duration::from_secs(60 * 60 * 24 * 7)), + ) + .http_only(true) + .secure(true) + .finish(); + + response.add_cookie(&cookie).unwrap(); + } + } + + pub type ServiceResponse = crate::routes::schema::Response; + + #[derive(Clone, ToSchema, Display, ErrResponse)] + #[status_code = "actix_web::http::StatusCode::UNAUTHORIZED"] + #[schema(as = Flow::TelegramAuth::ErrorCode)] + pub enum ErrorCode { + #[display("Invalid init data provided: {_0}")] + #[schema(value_type = String)] + InvalidInitData(Arc), + + #[display("Expired init data provided.")] + ExpiredInitData, + } + + impl Serialize for ErrorCode { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + ErrorCode::InvalidInitData(_) => serializer.serialize_str("INVALID_INIT_DATA"), + ErrorCode::ExpiredInitData => serializer.serialize_str("EXPIRED_INIT_DATA"), + } + } + } +} \ No newline at end of file diff --git a/src/routes/flow/telegram_complete.rs b/src/routes/flow/telegram_complete.rs new file mode 100644 index 0000000..5ce1678 --- /dev/null +++ b/src/routes/flow/telegram_complete.rs @@ -0,0 +1,94 @@ +use self::schema::*; +use crate::AppState; +use crate::database::driver; +use crate::database::driver::users::UserSave; +use crate::database::models::User; +use crate::extractors::base::AsyncExtractor; +use crate::routes::schema::ResponseError; +use actix_web::{post, web}; +use web::Json; + +#[utoipa::path(responses( + (status = OK), + (status = CONFLICT, body = ResponseError), + (status = INTERNAL_SERVER_ERROR, body = ResponseError), + (status = BAD_REQUEST, body = ResponseError) +))] +#[post("/telegram-complete")] +pub async fn telegram_complete( + data: Json, + app_state: web::Data, + user: AsyncExtractor, +) -> ServiceResponse { + let mut user = user.into_inner(); + + // проверка на перезапись уже имеющихся данных + if user.group.is_some() { + return Err(ErrorCode::AlreadyCompleted).into(); + } + + let data = data.into_inner(); + + // замена существующего имени, если оно отличается + if user.username != data.username { + if driver::users::contains_by_username(&app_state, &data.username).await { + return Err(ErrorCode::UsernameAlreadyExists).into(); + } + + user.username = data.username; + } + + // проверка на существование группы + if !app_state + .get_schedule_snapshot() + .await + .data + .groups + .contains_key(&data.group) + { + return Err(ErrorCode::InvalidGroupName).into(); + } + + user.group = Some(data.group); + + user.save(&app_state).await.expect("Failed to update user"); + + Ok(()).into() +} + +mod schema { + use actix_macros::ErrResponse; + use derive_more::Display; + use serde::{Deserialize, Serialize}; + use utoipa::ToSchema; + + #[derive(Debug, Deserialize, Serialize, ToSchema)] + #[schema(as = Flow::TelegramFill::Request)] + pub struct Request { + /// Username. + pub username: String, + + /// Group. + pub group: String, + } + + pub type ServiceResponse = crate::routes::schema::Response<(), ErrorCode>; + + #[derive(Clone, Serialize, Display, ToSchema, ErrResponse)] + #[status_code = "actix_web::http::StatusCode::UNAUTHORIZED"] + #[serde(rename_all = "SCREAMING_SNAKE_CASE")] + #[schema(as = Flow::TelegramFill::ErrorCode)] + pub enum ErrorCode { + #[display("This flow already completed.")] + #[status_code = "actix_web::http::StatusCode::CONFLICT"] + AlreadyCompleted, + + #[display("Username is already exists.")] + #[status_code = "actix_web::http::StatusCode::BAD_REQUEST"] + UsernameAlreadyExists, + + #[display("The required group does not exist.")] + #[status_code = "actix_web::http::StatusCode::BAD_REQUEST"] + InvalidGroupName, + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 05479c6..497b6a7 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod fcm; +pub mod flow; pub mod schedule; mod schema; pub mod users; diff --git a/src/routes/schedule/cache_status.rs b/src/routes/schedule/cache_status.rs index aef1d46..ed18854 100644 --- a/src/routes/schedule/cache_status.rs +++ b/src/routes/schedule/cache_status.rs @@ -7,17 +7,5 @@ use actix_web::{get, web}; ))] #[get("/cache-status")] pub async fn cache_status(app_state: web::Data) -> CacheStatus { - // Prevent thread lock - let has_schedule = app_state - .schedule - .lock() - .as_ref() - .map(|res| res.is_some()) - .unwrap(); - - match has_schedule { - true => CacheStatus::from(&app_state), - false => CacheStatus::default(), - } - .into() + CacheStatus::from(&app_state).await.into() } diff --git a/src/routes/schedule/group.rs b/src/routes/schedule/group.rs index 90a4015..a9d3f0c 100644 --- a/src/routes/schedule/group.rs +++ b/src/routes/schedule/group.rs @@ -1,12 +1,13 @@ use self::schema::*; use crate::AppState; use crate::database::models::User; -use crate::extractors::base::SyncExtractor; -use crate::routes::schema::{IntoResponseAsError, ResponseError}; +use crate::extractors::base::AsyncExtractor; +use crate::routes::schedule::schema::ScheduleEntryResponse; +use crate::routes::schema::ResponseError; use actix_web::{get, web}; #[utoipa::path(responses( - (status = OK, body = Response), + (status = OK, body = ScheduleEntryResponse), ( status = SERVICE_UNAVAILABLE, body = ResponseError, @@ -25,68 +26,42 @@ use actix_web::{get, web}; ), ))] #[get("/group")] -pub async fn group(user: SyncExtractor, app_state: web::Data) -> ServiceResponse { - // Prevent thread lock - let schedule_lock = app_state.schedule.lock().unwrap(); +pub async fn group(user: AsyncExtractor, app_state: web::Data) -> ServiceResponse { + match &user.into_inner().group { + None => Err(ErrorCode::SignUpNotCompleted), - match schedule_lock.as_ref() { - None => ErrorCode::NoSchedule.into_response(), - Some(schedule) => match schedule.data.groups.get(&user.into_inner().group) { - None => ErrorCode::NotFound.into_response(), - Some(entry) => Ok(entry.clone().into()).into(), + Some(group) => match app_state + .get_schedule_snapshot() + .await + .data + .groups + .get(group) + { + None => Err(ErrorCode::NotFound), + + Some(entry) => Ok(entry.clone().into()), }, } + .into() } mod schema { - use schedule_parser::schema::ScheduleEntry; - use actix_macros::{IntoResponseErrorNamed, StatusCode}; - use chrono::{DateTime, NaiveDateTime, Utc}; + use crate::routes::schedule::schema::ScheduleEntryResponse; + use actix_macros::ErrResponse; use derive_more::Display; use serde::Serialize; use utoipa::ToSchema; - pub type ServiceResponse = crate::routes::schema::Response; + pub type ServiceResponse = crate::routes::schema::Response; - #[derive(Serialize, ToSchema)] - #[schema(as = GetGroup::Response)] - #[serde(rename_all = "camelCase")] - pub struct Response { - /// Group schedule. - pub group: ScheduleEntry, - - /// ## Outdated variable. - /// - /// By default, an empty list is returned. - #[deprecated = "Will be removed in future versions"] - pub updated: Vec, - - /// ## Outdated variable. - /// - /// By default, the initial date for unix. - #[deprecated = "Will be removed in future versions"] - pub updated_at: DateTime, - } - - #[allow(deprecated)] - impl From for Response { - fn from(group: ScheduleEntry) -> Self { - Self { - group, - updated: Vec::new(), - updated_at: NaiveDateTime::default().and_utc(), - } - } - } - - #[derive(Clone, Serialize, ToSchema, StatusCode, Display, IntoResponseErrorNamed)] + #[derive(Clone, Serialize, Display, ToSchema, ErrResponse)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[schema(as = GroupSchedule::ErrorCode)] pub enum ErrorCode { - /// Schedules have not yet been parsed. - #[status_code = "actix_web::http::StatusCode::SERVICE_UNAVAILABLE"] - #[display("Schedule not parsed yet.")] - NoSchedule, + /// The user tried to access the API without completing singing up. + #[status_code = "actix_web::http::StatusCode::FORBIDDEN"] + #[display("You have not completed signing up.")] + SignUpNotCompleted, /// Group not found. #[status_code = "actix_web::http::StatusCode::NOT_FOUND"] diff --git a/src/routes/schedule/group_names.rs b/src/routes/schedule/group_names.rs index 0aa803d..50275c0 100644 --- a/src/routes/schedule/group_names.rs +++ b/src/routes/schedule/group_names.rs @@ -1,48 +1,34 @@ use self::schema::*; use crate::AppState; -use crate::routes::schedule::schema::ErrorCode; -use crate::routes::schema::{IntoResponseAsError, ResponseError}; use actix_web::{get, web}; -#[utoipa::path(responses( - (status = OK, body = Response), - (status = SERVICE_UNAVAILABLE, body = ResponseError), -))] +#[utoipa::path(responses((status = OK, body = Response)))] #[get("/group-names")] -pub async fn group_names(app_state: web::Data) -> ServiceResponse { - // Prevent thread lock - let schedule_lock = app_state.schedule.lock().unwrap(); +pub async fn group_names(app_state: web::Data) -> Response { + let mut names: Vec = app_state + .get_schedule_snapshot() + .await + .data + .groups + .keys() + .cloned() + .collect(); - match schedule_lock.as_ref() { - None => ErrorCode::NoSchedule.into_response(), - Some(schedule) => { - let mut names: Vec = schedule.data.groups.keys().cloned().collect(); - names.sort(); - - Ok(names.into()).into() - } - } - .into() + names.sort(); + + Response { names } } mod schema { - use crate::routes::schedule::schema::ErrorCode; + use actix_macros::ResponderJson; use serde::Serialize; use utoipa::ToSchema; - pub type ServiceResponse = crate::routes::schema::Response; - - #[derive(Serialize, ToSchema)] + #[derive(Serialize, ToSchema, ResponderJson)] #[schema(as = GetGroupNames::Response)] pub struct Response { /// List of group names sorted in alphabetical order. #[schema(examples(json!(["ИС-214/23"])))] pub names: Vec, } - - impl From> for Response { - fn from(names: Vec) -> Self { - Self { names } - } - } } diff --git a/src/routes/schedule/mod.rs b/src/routes/schedule/mod.rs index a20d7e8..cd7d11c 100644 --- a/src/routes/schedule/mod.rs +++ b/src/routes/schedule/mod.rs @@ -5,7 +5,6 @@ mod schedule; mod schema; mod teacher; mod teacher_names; -mod update_download_url; pub use cache_status::*; pub use group::*; @@ -13,4 +12,3 @@ pub use group_names::*; pub use schedule::*; pub use teacher::*; pub use teacher_names::*; -pub use update_download_url::*; diff --git a/src/routes/schedule/schedule.rs b/src/routes/schedule/schedule.rs index d97933b..47526c7 100644 --- a/src/routes/schedule/schedule.rs +++ b/src/routes/schedule/schedule.rs @@ -1,25 +1,9 @@ -use self::schema::*; -use crate::app_state::AppState; -use crate::routes::schedule::schema::{ErrorCode, ScheduleView}; -use crate::routes::schema::{IntoResponseAsError, ResponseError}; +use crate::routes::schedule::schema::ScheduleView; +use crate::state::AppState; use actix_web::{get, web}; -#[utoipa::path(responses( - (status = OK, body = ScheduleView), - (status = SERVICE_UNAVAILABLE, body = ResponseError) -))] +#[utoipa::path(responses((status = OK, body = ScheduleView)))] #[get("/")] -pub async fn schedule(app_state: web::Data) -> ServiceResponse { - match ScheduleView::try_from(&app_state) { - Ok(res) => Ok(res).into(), - Err(e) => match e { - ErrorCode::NoSchedule => ErrorCode::NoSchedule.into_response(), - }, - } -} - -mod schema { - use crate::routes::schedule::schema::{ErrorCode, ScheduleView}; - - pub type ServiceResponse = crate::routes::schema::Response; +pub async fn schedule(app_state: web::Data) -> ScheduleView { + ScheduleView::from(&app_state).await } diff --git a/src/routes/schedule/schema.rs b/src/routes/schedule/schema.rs index b279661..463d088 100644 --- a/src/routes/schedule/schema.rs +++ b/src/routes/schedule/schema.rs @@ -1,25 +1,18 @@ -use crate::app_state::{AppState, Schedule}; -use schedule_parser::schema::ScheduleEntry; -use actix_macros::{IntoResponseErrorNamed, ResponderJson, StatusCode}; +use crate::state::{AppState, ScheduleSnapshot}; +use actix_macros::{OkResponse, ResponderJson}; use actix_web::web; -use chrono::{DateTime, Duration, Utc}; -use derive_more::Display; +use schedule_parser::schema::ScheduleEntry; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::ops::Deref; use utoipa::ToSchema; /// Response from schedule server. -#[derive(Serialize, ToSchema)] +#[derive(Serialize, ToSchema, OkResponse, ResponderJson)] #[serde(rename_all = "camelCase")] pub struct ScheduleView { - /// ETag schedules on polytechnic server. - etag: String, - - /// Schedule update date on polytechnic website. - uploaded_at: DateTime, - - /// Date last downloaded from the Polytechnic server. - downloaded_at: DateTime, + /// Url to xls file. + url: String, /// Groups schedule. groups: HashMap, @@ -28,80 +21,55 @@ pub struct ScheduleView { teachers: HashMap, } -#[derive(Clone, Serialize, ToSchema, StatusCode, Display, IntoResponseErrorNamed)] -#[status_code = "actix_web::http::StatusCode::SERVICE_UNAVAILABLE"] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[schema(as = ScheduleShared::ErrorCode)] -pub enum ErrorCode { - /// Schedules not yet parsed. - #[display("Schedule not parsed yet.")] - NoSchedule, +#[derive(Serialize, ToSchema, OkResponse)] +pub struct ScheduleEntryResponse(ScheduleEntry); + +impl From for ScheduleEntryResponse { + fn from(value: ScheduleEntry) -> Self { + Self(value) + } } -impl TryFrom<&web::Data> for ScheduleView { - type Error = ErrorCode; +impl ScheduleView { + pub async fn from(app_state: &web::Data) -> Self { + let schedule = app_state.get_schedule_snapshot().await.clone(); - fn try_from(app_state: &web::Data) -> Result { - if let Some(schedule) = app_state.schedule.lock().unwrap().clone() { - Ok(Self { - etag: schedule.etag, - uploaded_at: schedule.updated_at, - downloaded_at: schedule.parsed_at, - groups: schedule.data.groups, - teachers: schedule.data.teachers, - }) - } else { - Err(ErrorCode::NoSchedule) + Self { + url: schedule.url, + groups: schedule.data.groups, + teachers: schedule.data.teachers, } } } /// Cached schedule status. -#[derive(Serialize, Deserialize, ToSchema, ResponderJson)] +#[derive(Serialize, Deserialize, ToSchema, ResponderJson, OkResponse)] #[serde(rename_all = "camelCase")] pub struct CacheStatus { /// Schedule hash. - pub cache_hash: String, - - /// Whether the schedule reference needs to be updated. - pub cache_update_required: bool, + pub hash: String, /// Last cache update date. - pub last_cache_update: i64, + pub fetched_at: i64, /// Cached schedule update date. /// /// Determined by the polytechnic's server. - pub last_schedule_update: i64, + pub updated_at: i64, } impl CacheStatus { - pub fn default() -> Self { - CacheStatus { - cache_hash: "0000000000000000000000000000000000000000".to_string(), - cache_update_required: true, - last_cache_update: 0, - last_schedule_update: 0, - } + pub async fn from(value: &web::Data) -> Self { + From::<&ScheduleSnapshot>::from(value.get_schedule_snapshot().await.deref()) } } -impl From<&web::Data> for CacheStatus { - fn from(value: &web::Data) -> Self { - let schedule_lock = value.schedule.lock().unwrap(); - let schedule = schedule_lock.as_ref().unwrap(); - - CacheStatus::from(schedule) - } -} - -impl From<&Schedule> for CacheStatus { - fn from(value: &Schedule) -> Self { +impl From<&ScheduleSnapshot> for CacheStatus { + fn from(value: &ScheduleSnapshot) -> Self { Self { - cache_hash: value.hash(), - cache_update_required: (Utc::now() - value.fetched_at) > Duration::minutes(5), - last_cache_update: value.fetched_at.timestamp(), - last_schedule_update: value.updated_at.timestamp(), + hash: value.hash(), + fetched_at: value.fetched_at.timestamp(), + updated_at: value.updated_at.timestamp(), } } } diff --git a/src/routes/schedule/teacher.rs b/src/routes/schedule/teacher.rs index b8fb7a5..36fd9a4 100644 --- a/src/routes/schedule/teacher.rs +++ b/src/routes/schedule/teacher.rs @@ -1,18 +1,11 @@ use self::schema::*; -use crate::routes::schema::{IntoResponseAsError, ResponseError}; use crate::AppState; +use crate::routes::schema::ResponseError; use actix_web::{get, web}; +use schedule_parser::schema::ScheduleEntry; #[utoipa::path(responses( - (status = OK, body = Response), - ( - status = SERVICE_UNAVAILABLE, - body = ResponseError, - example = json!({ - "code": "NO_SCHEDULE", - "message": "Schedule not parsed yet." - }) - ), + (status = OK, body = ScheduleEntry), ( status = NOT_FOUND, body = ResponseError, @@ -23,72 +16,34 @@ use actix_web::{get, web}; ), ))] #[get("/teacher/{name}")] -pub async fn teacher( - name: web::Path, - app_state: web::Data, -) -> ServiceResponse { - // Prevent thread lock - let schedule_lock = app_state.schedule.lock().unwrap(); +pub async fn teacher(name: web::Path, app_state: web::Data) -> ServiceResponse { + match app_state + .get_schedule_snapshot() + .await + .data + .teachers + .get(&name.into_inner()) + { + None => Err(ErrorCode::NotFound), - match schedule_lock.as_ref() { - None => ErrorCode::NoSchedule.into_response(), - Some(schedule) => match schedule.data.teachers.get(&name.into_inner()) { - None => ErrorCode::NotFound.into_response(), - Some(entry) => Ok(entry.clone().into()).into(), - }, + Some(entry) => Ok(entry.clone().into()), } + .into() } mod schema { - use schedule_parser::schema::ScheduleEntry; - use actix_macros::{IntoResponseErrorNamed, StatusCode}; - use chrono::{DateTime, NaiveDateTime, Utc}; + use crate::routes::schedule::schema::ScheduleEntryResponse; + use actix_macros::ErrResponse; use derive_more::Display; use serde::Serialize; use utoipa::ToSchema; - pub type ServiceResponse = crate::routes::schema::Response; + pub type ServiceResponse = crate::routes::schema::Response; - #[derive(Serialize, ToSchema)] - #[schema(as = GetTeacher::Response)] - #[serde(rename_all = "camelCase")] - pub struct Response { - /// Teacher's schedule. - pub teacher: ScheduleEntry, - - /// ## Deprecated variable. - /// - /// By default, an empty list is returned. - #[deprecated = "Will be removed in future versions"] - pub updated: Vec, - - /// ## Deprecated variable. - /// - /// Defaults to the Unix start date. - #[deprecated = "Will be removed in future versions"] - pub updated_at: DateTime, - } - - #[allow(deprecated)] - impl From for Response { - fn from(teacher: ScheduleEntry) -> Self { - Self { - teacher, - updated: Vec::new(), - updated_at: NaiveDateTime::default().and_utc(), - } - } - } - - #[derive(Clone, Serialize, ToSchema, StatusCode, Display, IntoResponseErrorNamed)] + #[derive(Clone, Serialize, Display, ToSchema, ErrResponse)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[schema(as = TeacherSchedule::ErrorCode)] pub enum ErrorCode { - /// Schedules have not yet been parsed. - #[status_code = "actix_web::http::StatusCode::SERVICE_UNAVAILABLE"] - #[display("Schedule not parsed yet.")] - NoSchedule, - /// Teacher not found. #[status_code = "actix_web::http::StatusCode::NOT_FOUND"] #[display("Required teacher not found.")] diff --git a/src/routes/schedule/teacher_names.rs b/src/routes/schedule/teacher_names.rs index e88d238..9a4e335 100644 --- a/src/routes/schedule/teacher_names.rs +++ b/src/routes/schedule/teacher_names.rs @@ -1,48 +1,34 @@ use self::schema::*; use crate::AppState; -use crate::routes::schedule::schema::ErrorCode; -use crate::routes::schema::{IntoResponseAsError, ResponseError}; use actix_web::{get, web}; -#[utoipa::path(responses( - (status = OK, body = Response), - (status = SERVICE_UNAVAILABLE, body = ResponseError), -))] +#[utoipa::path(responses((status = OK, body = Response)))] #[get("/teacher-names")] -pub async fn teacher_names(app_state: web::Data) -> ServiceResponse { - // Prevent thread lock - let schedule_lock = app_state.schedule.lock().unwrap(); +pub async fn teacher_names(app_state: web::Data) -> Response { + let mut names: Vec = app_state + .get_schedule_snapshot() + .await + .data + .teachers + .keys() + .cloned() + .collect(); - match schedule_lock.as_ref() { - None => ErrorCode::NoSchedule.into_response(), - Some(schedule) => { - let mut names: Vec = schedule.data.teachers.keys().cloned().collect(); - names.sort(); + names.sort(); - Ok(names.into()).into() - } - } - .into() + Response { names } } mod schema { - use crate::routes::schedule::schema::ErrorCode; + use actix_macros::ResponderJson; use serde::Serialize; use utoipa::ToSchema; - pub type ServiceResponse = crate::routes::schema::Response; - - #[derive(Serialize, ToSchema)] + #[derive(Serialize, ToSchema, ResponderJson)] #[schema(as = GetTeacherNames::Response)] pub struct Response { /// List of teacher names sorted alphabetically. #[schema(examples(json!(["Хомченко Н.Е."])))] pub names: Vec, } - - impl From> for Response { - fn from(names: Vec) -> Self { - Self { names } - } - } } diff --git a/src/routes/schedule/update_download_url.rs b/src/routes/schedule/update_download_url.rs deleted file mode 100644 index f5e5504..0000000 --- a/src/routes/schedule/update_download_url.rs +++ /dev/null @@ -1,140 +0,0 @@ -use self::schema::*; -use crate::AppState; -use crate::app_state::Schedule; -use schedule_parser::parse_xls; -use crate::routes::schedule::schema::CacheStatus; -use crate::routes::schema::{IntoResponseAsError, ResponseError}; -use crate::xls_downloader::interface::{FetchError, XLSDownloader}; -use actix_web::web::Json; -use actix_web::{patch, web}; -use chrono::Utc; - -#[utoipa::path(responses( - (status = OK, body = CacheStatus), - (status = NOT_ACCEPTABLE, body = ResponseError), -))] -#[patch("/update-download-url")] -pub async fn update_download_url( - data: Json, - app_state: web::Data, -) -> ServiceResponse { - if !data.url.starts_with("https://politehnikum-eng.ru/") { - return ErrorCode::NonWhitelistedHost.into_response(); - } - - let mut downloader = app_state.downloader.lock().unwrap(); - - if let Some(url) = &downloader.url { - if url.eq(&data.url) { - return Ok(CacheStatus::from(&app_state)).into(); - } - } - - match downloader.set_url(data.url.clone()).await { - Ok(fetch_result) => { - let mut schedule = app_state.schedule.lock().unwrap(); - - if schedule.is_some() - && fetch_result.uploaded_at < schedule.as_ref().unwrap().updated_at - { - return ErrorCode::OutdatedSchedule.into_response(); - } - - match downloader.fetch(false).await { - Ok(download_result) => match parse_xls(&download_result.data.unwrap()) { - Ok(data) => { - *schedule = Some(Schedule { - etag: download_result.etag, - fetched_at: download_result.requested_at, - updated_at: download_result.uploaded_at, - parsed_at: Utc::now(), - data, - }); - - Ok(CacheStatus::from(schedule.as_ref().unwrap())).into() - } - Err(error) => { - sentry::capture_error(&error); - - ErrorCode::InvalidSchedule(error).into_response() - } - }, - Err(error) => { - if let FetchError::Unknown(error) = &error { - sentry::capture_error(&error); - } - - ErrorCode::DownloadFailed(error).into_response() - } - } - } - Err(error) => { - if let FetchError::Unknown(error) = &error { - sentry::capture_error(&error); - } - - ErrorCode::FetchFailed(error).into_response() - } - } -} - -mod schema { - use schedule_parser::schema::ParseError; - use crate::routes::schedule::schema::CacheStatus; - use actix_macros::{IntoResponseErrorNamed, StatusCode}; - use derive_more::Display; - use serde::{Deserialize, Serialize, Serializer}; - use utoipa::ToSchema; - use crate::xls_downloader::interface::FetchError; - - pub type ServiceResponse = crate::routes::schema::Response; - - #[derive(Serialize, Deserialize, ToSchema)] - pub struct Request { - /// Schedule link. - pub url: String, - } - - #[derive(Clone, ToSchema, StatusCode, Display, IntoResponseErrorNamed)] - #[status_code = "actix_web::http::StatusCode::NOT_ACCEPTABLE"] - #[schema(as = SetDownloadUrl::ErrorCode)] - pub enum ErrorCode { - /// Transferred link with host different from politehnikum-eng.ru. - #[display("URL with unknown host provided. Provide url with 'politehnikum-eng.ru' host.")] - NonWhitelistedHost, - - /// Failed to retrieve file metadata. - #[display("Unable to retrieve metadata from the specified URL: {_0}")] - FetchFailed(FetchError), - - /// Failed to download the file. - #[display("Unable to retrieve data from the specified URL: {_0}")] - DownloadFailed(FetchError), - - /// The link leads to an outdated schedule. - /// - /// An outdated schedule refers to a schedule that was published earlier - /// than is currently available. - #[display("The schedule is older than it already is.")] - OutdatedSchedule, - - /// Failed to parse the schedule. - #[display("{_0}")] - InvalidSchedule(ParseError), - } - - impl Serialize for ErrorCode { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - match self { - ErrorCode::NonWhitelistedHost => serializer.serialize_str("NON_WHITELISTED_HOST"), - ErrorCode::FetchFailed(_) => serializer.serialize_str("FETCH_FAILED"), - ErrorCode::DownloadFailed(_) => serializer.serialize_str("DOWNLOAD_FAILED"), - ErrorCode::OutdatedSchedule => serializer.serialize_str("OUTDATED_SCHEDULE"), - ErrorCode::InvalidSchedule(_) => serializer.serialize_str("INVALID_SCHEDULE"), - } - } - } -} diff --git a/src/routes/schema.rs b/src/routes/schema.rs index 1854720..f6b4a7e 100644 --- a/src/routes/schema.rs +++ b/src/routes/schema.rs @@ -4,22 +4,19 @@ use actix_web::http::StatusCode; use actix_web::{HttpRequest, HttpResponse, Responder}; use serde::{Serialize, Serializer}; use std::convert::Into; +use std::fmt::Display; use utoipa::PartialSchema; pub struct Response(pub Result) where - T: Serialize + PartialSchema, - E: Serialize + PartialSchema + Clone + PartialStatusCode; - -pub trait PartialStatusCode { - fn status_code(&self) -> StatusCode; -} + T: Serialize + PartialSchema + PartialOkResponse, + E: Serialize + PartialSchema + Display + PartialErrResponse; /// Transform Response into Result impl Into> for Response where - T: Serialize + PartialSchema, - E: Serialize + PartialSchema + Clone + PartialStatusCode, + T: Serialize + PartialSchema + PartialOkResponse, + E: Serialize + PartialSchema + Display + PartialErrResponse, { fn into(self) -> Result { self.0 @@ -29,8 +26,8 @@ where /// Transform T into Response impl From> for Response where - T: Serialize + PartialSchema, - E: Serialize + PartialSchema + Clone + PartialStatusCode, + T: Serialize + PartialSchema + PartialOkResponse, + E: Serialize + PartialSchema + Display + PartialErrResponse, { fn from(value: Result) -> Self { Response(value) @@ -40,17 +37,16 @@ where /// Serialize Response impl Serialize for Response where - T: Serialize + PartialSchema, - E: Serialize + PartialSchema + Clone + PartialStatusCode + Into>, + T: Serialize + PartialSchema + PartialOkResponse, + E: Serialize + PartialSchema + Display + PartialErrResponse + Clone + Into>, { fn serialize(&self, serializer: S) -> Result where S: Serializer, { match &self.0 { - Ok(ok) => serializer.serialize_some::(&ok), - Err(err) => serializer - .serialize_some::>(&ResponseError::::from(err.clone().into())), + Ok(ok) => serializer.serialize_some(&ok), + Err(err) => serializer.serialize_some(&ResponseError::::from(err.clone().into())), } } } @@ -58,12 +54,12 @@ where /// Transform Response to HttpResponse impl Responder for Response where - T: Serialize + PartialSchema, - E: Serialize + PartialSchema + Clone + PartialStatusCode + Into>, + T: Serialize + PartialSchema + PartialOkResponse, + E: Serialize + PartialSchema + Display + PartialErrResponse + Clone + Into>, { type Body = EitherBody; - fn respond_to(self, _: &HttpRequest) -> HttpResponse { + fn respond_to(mut self, request: &HttpRequest) -> HttpResponse { match serde_json::to_string(&self) { Ok(body) => { let code = match &self.0 { @@ -71,13 +67,19 @@ where Err(e) => e.status_code(), }; - match HttpResponse::build(code) + let mut response = match HttpResponse::build(code) .content_type(mime::APPLICATION_JSON) .message_body(body) { Ok(res) => res.map_into_left_body(), Err(err) => HttpResponse::from_error(err).map_into_right_body(), + }; + + if let Ok(ok) = &mut self.0 { + ok.post_process(request, &mut response); } + + response } Err(err) => { @@ -87,61 +89,80 @@ where } } -/// ResponseError -/// -/// Field `message` is optional for backwards compatibility with Android App, that produces error if new fields will be added to JSON response. -#[derive(Serialize, utoipa::ToSchema)] -pub struct ResponseError { - pub code: T, - - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, +/// Трейт для всех положительных ответов от сервера +pub trait PartialOkResponse { + fn post_process( + &mut self, + _request: &HttpRequest, + _response: &mut HttpResponse>, + ) -> () { + } } -pub trait IntoResponseAsError +impl PartialOkResponse for () {} + +/// Трейт для всех отрицательных ответов от сервера +pub trait PartialErrResponse { + fn status_code(&self) -> StatusCode; +} + +/// ResponseError +#[derive(Serialize, utoipa::ToSchema)] +pub struct ResponseError { + pub code: T, + pub message: String, +} + +impl From for ResponseError where - T: Serialize + PartialSchema, - Self: Serialize + PartialSchema + Clone + PartialStatusCode + Into>, + T: Serialize + PartialSchema + Display + Clone, { - fn into_response(self) -> Response { - Response(Err(self)) + fn from(code: T) -> Self { + Self { + message: format!("{}", code), + code, + } } } pub mod user { use crate::database::models::{User, UserRole}; - use actix_macros::ResponderJson; + use actix_macros::{OkResponse, ResponderJson}; use serde::Serialize; //noinspection SpellCheckingInspection /// Используется для скрытия чувствительных полей, таких как хеш пароля или FCM - #[derive(Serialize, utoipa::ToSchema, ResponderJson)] + #[derive(Serialize, utoipa::ToSchema, ResponderJson, OkResponse)] #[serde(rename_all = "camelCase")] pub struct UserResponse { /// UUID #[schema(examples("67dcc9a9507b0000772744a2"))] - id: String, + pub id: String, /// Имя пользователя #[schema(examples("n08i40k"))] - username: String, + pub username: String, /// Группа #[schema(examples("ИС-214/23"))] - group: String, + pub group: Option, /// Роль - role: UserRole, + pub role: UserRole, /// Идентификатор привязанного аккаунта VK #[schema(examples(498094647, json!(null)))] - vk_id: Option, + pub vk_id: Option, + + /// Идентификатор привязанного аккаунта Telegram + #[schema(examples(996004735, json!(null)))] + pub telegram_id: Option, /// JWT токен доступа #[schema(examples( "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjY3ZGNjOWE5NTA3YjAwMDA3NzI3NDRhMiIsImlhdCI6IjE3NDMxMDgwOTkiLCJleHAiOiIxODY5MjUyMDk5In0.rMgXRb3JbT9AvLK4eiY9HMB5LxgUudkpQyoWKOypZFY" ))] - access_token: String, + pub access_token: Option, } /// Create UserResponse from User ref. @@ -153,6 +174,7 @@ pub mod user { group: user.group.clone(), role: user.role.clone(), vk_id: user.vk_id.clone(), + telegram_id: user.telegram_id.clone(), access_token: user.access_token.clone(), } } @@ -167,6 +189,7 @@ pub mod user { group: user.group, role: user.role, vk_id: user.vk_id, + telegram_id: user.telegram_id, access_token: user.access_token, } } diff --git a/src/routes/users/change_group.rs b/src/routes/users/change_group.rs index f114d69..31a7008 100644 --- a/src/routes/users/change_group.rs +++ b/src/routes/users/change_group.rs @@ -1,85 +1,62 @@ use self::schema::*; -use crate::app_state::AppState; use crate::database::driver::users::UserSave; use crate::database::models::User; -use crate::extractors::base::SyncExtractor; -use crate::routes::schema::IntoResponseAsError; -use crate::utility::mutex::MutexScope; +use crate::extractors::base::AsyncExtractor; +use crate::state::AppState; use actix_web::{post, web}; #[utoipa::path(responses((status = OK)))] #[post("/change-group")] pub async fn change_group( app_state: web::Data, - user: SyncExtractor, + user: AsyncExtractor, data: web::Json, ) -> ServiceResponse { let mut user = user.into_inner(); - if user.group == data.group { - return ErrorCode::SameGroup.into_response(); + if user.group.is_some_and(|group| group == data.group) { + return Ok(()).into(); } - if let Some(e) = app_state.schedule.scope(|schedule| match schedule { - Some(schedule) => { - if schedule.data.groups.contains_key(&data.group) { - None - } else { - Some(ErrorCode::NotFound) - } - } - None => Some(ErrorCode::NoSchedule), - }) { - return e.into_response(); + if !app_state + .get_schedule_snapshot() + .await + .data + .groups + .contains_key(&data.group) + { + return Err(ErrorCode::NotFound).into(); } - - user.group = data.into_inner().group; - - if let Some(e) = user.save(&app_state).err() { - eprintln!("Failed to update user: {e}"); - return ErrorCode::InternalServerError.into_response(); - } - + + user.group = Some(data.into_inner().group); + user.save(&app_state).await.unwrap(); + Ok(()).into() } mod schema { - use actix_macros::{IntoResponseErrorNamed, StatusCode}; + use actix_macros::ErrResponse; use derive_more::Display; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; pub type ServiceResponse = crate::routes::schema::Response<(), ErrorCode>; - #[derive(Serialize, Deserialize, ToSchema)] + #[derive(Deserialize, ToSchema)] #[schema(as = ChangeGroup::Request)] pub struct Request { - /// Group name. + // Group. pub group: String, } - #[derive(Clone, Serialize, ToSchema, StatusCode, Display, IntoResponseErrorNamed)] + #[derive(Clone, Serialize, Display, ToSchema, ErrResponse)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[schema(as = ChangeGroup::ErrorCode)] #[status_code = "actix_web::http::StatusCode::CONFLICT"] pub enum ErrorCode { - /// Schedules have not yet been received. - #[display("Schedule not parsed yet.")] - #[status_code = "actix_web::http::StatusCode::SERVICE_UNAVAILABLE"] - NoSchedule, - - /// Passed the same group name that is currently there. - #[display("Passed the same group name as it is at the moment.")] - SameGroup, - /// The required group does not exist. #[display("The required group does not exist.")] #[status_code = "actix_web::http::StatusCode::NOT_FOUND"] NotFound, - - /// Server-side error. - #[display("Internal server error.")] - #[status_code = "actix_web::http::StatusCode::INTERNAL_SERVER_ERROR"] - InternalServerError, } } diff --git a/src/routes/users/change_username.rs b/src/routes/users/change_username.rs index adb217d..c35c21b 100644 --- a/src/routes/users/change_username.rs +++ b/src/routes/users/change_username.rs @@ -1,41 +1,39 @@ use self::schema::*; -use crate::app_state::AppState; use crate::database::driver; use crate::database::driver::users::UserSave; use crate::database::models::User; -use crate::extractors::base::SyncExtractor; -use crate::routes::schema::IntoResponseAsError; +use crate::extractors::base::AsyncExtractor; +use crate::state::AppState; use actix_web::{post, web}; #[utoipa::path(responses((status = OK)))] #[post("/change-username")] pub async fn change_username( app_state: web::Data, - user: SyncExtractor, + user: AsyncExtractor, data: web::Json, ) -> ServiceResponse { let mut user = user.into_inner(); if user.username == data.username { - return ErrorCode::SameUsername.into_response(); + return Ok(()).into(); } - if driver::users::get_by_username(&app_state, &data.username).is_ok() { - return ErrorCode::AlreadyExists.into_response(); + if driver::users::get_by_username(&app_state, &data.username) + .await + .is_ok() + { + return Err(ErrorCode::AlreadyExists).into(); } user.username = data.into_inner().username; - - if let Some(e) = user.save(&app_state).err() { - eprintln!("Failed to update user: {e}"); - return ErrorCode::InternalServerError.into_response(); - } + user.save(&app_state).await.unwrap(); Ok(()).into() } mod schema { - use actix_macros::{IntoResponseErrorNamed, StatusCode}; + use actix_macros::ErrResponse; use derive_more::Display; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -49,22 +47,13 @@ mod schema { pub username: String, } - #[derive(Clone, Serialize, ToSchema, StatusCode, Display, IntoResponseErrorNamed)] + #[derive(Clone, Serialize, Display, ToSchema, ErrResponse)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[schema(as = ChangeUsername::ErrorCode)] #[status_code = "actix_web::http::StatusCode::CONFLICT"] pub enum ErrorCode { - /// The same name that is currently present is passed. - #[display("Passed the same name as it is at the moment.")] - SameUsername, - /// A user with this name already exists. #[display("A user with this name already exists.")] AlreadyExists, - - /// Server-side error. - #[display("Internal server error.")] - #[status_code = "actix_web::http::StatusCode::INTERNAL_SERVER_ERROR"] - InternalServerError, } } diff --git a/src/routes/users/me.rs b/src/routes/users/me.rs index 179702e..261c837 100644 --- a/src/routes/users/me.rs +++ b/src/routes/users/me.rs @@ -1,10 +1,10 @@ use crate::database::models::User; -use crate::extractors::base::SyncExtractor; -use actix_web::get; +use crate::extractors::base::AsyncExtractor; use crate::routes::schema::user::UserResponse; +use actix_web::get; #[utoipa::path(responses((status = OK, body = UserResponse)))] #[get("/me")] -pub async fn me(user: SyncExtractor) -> UserResponse { +pub async fn me(user: AsyncExtractor) -> UserResponse { user.into_inner().into() } diff --git a/src/routes/vk_id/oauth.rs b/src/routes/vk_id/oauth.rs index ed64929..ed17af8 100644 --- a/src/routes/vk_id/oauth.rs +++ b/src/routes/vk_id/oauth.rs @@ -1,6 +1,6 @@ use self::schema::*; -use crate::app_state::AppState; -use crate::routes::schema::{IntoResponseAsError, ResponseError}; +use crate::routes::schema::ResponseError; +use crate::state::AppState; use actix_web::{post, web}; use serde::Deserialize; use std::collections::HashMap; @@ -35,7 +35,7 @@ async fn oauth(data: web::Json, app_state: web::Data) -> Serv let data = data.into_inner(); let state = Uuid::new_v4().simple().to_string(); - let vk_id = &app_state.vk_id; + let vk_id = &app_state.get_env().vk_id; let client_id = vk_id.client_id.clone().to_string(); let mut params = HashMap::new(); @@ -56,27 +56,27 @@ async fn oauth(data: web::Json, app_state: web::Data) -> Serv { Ok(res) => { if !res.status().is_success() { - return ErrorCode::VkIdError.into_response(); + return Err(ErrorCode::VkIdError).into(); } match res.json::().await { - Ok(auth_data) => - Ok(Response { - access_token: auth_data.id_token, - }).into(), + Ok(auth_data) => Ok(Response { + access_token: auth_data.id_token, + }), Err(error) => { sentry::capture_error(&error); - - ErrorCode::VkIdError.into_response() + + Err(ErrorCode::VkIdError) } } } - Err(_) => ErrorCode::VkIdError.into_response(), + Err(_) => Err(ErrorCode::VkIdError), } + .into() } mod schema { - use actix_macros::{IntoResponseErrorNamed, StatusCode}; + use actix_macros::{ErrResponse, OkResponse}; use derive_more::Display; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -97,7 +97,7 @@ mod schema { pub device_id: String, } - #[derive(Serialize, ToSchema)] + #[derive(Serialize, ToSchema, OkResponse)] #[serde(rename_all = "camelCase")] #[schema(as = VkIdOAuth::Response)] pub struct Response { @@ -105,7 +105,7 @@ mod schema { pub access_token: String, } - #[derive(Clone, Serialize, ToSchema, IntoResponseErrorNamed, StatusCode, Display)] + #[derive(Clone, Serialize, Display, ToSchema, ErrResponse)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[schema(as = VkIdOAuth::ErrorCode)] #[status_code = "actix_web::http::StatusCode::NOT_ACCEPTABLE"] diff --git a/src/state/env/mod.rs b/src/state/env/mod.rs new file mode 100644 index 0000000..25245dd --- /dev/null +++ b/src/state/env/mod.rs @@ -0,0 +1,17 @@ +pub mod schedule; +pub mod telegram; +pub mod vk_id; +pub mod yandex_cloud; + +pub use self::schedule::ScheduleEnvData; +pub use self::telegram::TelegramEnvData; +pub use self::vk_id::VkIdEnvData; +pub use self::yandex_cloud::YandexCloudEnvData; + +#[derive(Default)] +pub struct AppEnv { + pub schedule: ScheduleEnvData, + pub telegram: TelegramEnvData, + pub vk_id: VkIdEnvData, + pub yandex_cloud: YandexCloudEnvData, +} diff --git a/src/state/env/schedule.rs b/src/state/env/schedule.rs new file mode 100644 index 0000000..8ccc48e --- /dev/null +++ b/src/state/env/schedule.rs @@ -0,0 +1,17 @@ +use std::env; + +#[derive(Clone)] +pub struct ScheduleEnvData { + pub url: Option, + pub auto_update: bool, +} + +impl Default for ScheduleEnvData { + fn default() -> Self { + Self { + url: env::var("SCHEDULE_INIT_URL").ok(), + auto_update: !env::var("SCHEDULE_DISABLE_AUTO_UPDATE") + .is_ok_and(|v| v.eq("1") || v.eq("true")), + } + } +} diff --git a/src/state/env/telegram.rs b/src/state/env/telegram.rs new file mode 100644 index 0000000..d1fc2b7 --- /dev/null +++ b/src/state/env/telegram.rs @@ -0,0 +1,28 @@ +use std::env; + +#[derive(Clone)] +pub struct TelegramEnvData { + pub bot_id: i64, + pub mini_app_host: String, + pub test_dc: bool, +} + +impl Default for TelegramEnvData { + fn default() -> Self { + let _self = Self { + bot_id: env::var("TELEGRAM_BOT_ID") + .expect("TELEGRAM_BOT_ID must be set") + .parse() + .expect("TELEGRAM_BOT_ID must be integer"), + mini_app_host: env::var("TELEGRAM_MINI_APP_HOST") + .expect("TELEGRAM_MINI_APP_HOST must be set"), + test_dc: env::var("TELEGRAM_TEST_DC").is_ok_and(|v| v.eq("1") || v.eq("true")), + }; + + if _self.test_dc { + log::warn!("Using test data-center of telegram!"); + } + + _self + } +} diff --git a/src/state/env/vk_id.rs b/src/state/env/vk_id.rs new file mode 100644 index 0000000..6e4954b --- /dev/null +++ b/src/state/env/vk_id.rs @@ -0,0 +1,19 @@ +use std::env; + +#[derive(Clone)] +pub struct VkIdEnvData { + pub client_id: i32, + pub redirect_url: String, +} + +impl Default for VkIdEnvData { + fn default() -> Self { + Self { + client_id: env::var("VK_ID_CLIENT_ID") + .expect("VK_ID_CLIENT_ID must be set") + .parse() + .expect("VK_ID_CLIENT_ID must be integer"), + redirect_url: env::var("VK_ID_REDIRECT_URI").expect("VK_ID_REDIRECT_URI must be set"), + } + } +} diff --git a/src/state/env/yandex_cloud.rs b/src/state/env/yandex_cloud.rs new file mode 100644 index 0000000..55e0e29 --- /dev/null +++ b/src/state/env/yandex_cloud.rs @@ -0,0 +1,16 @@ +use std::env; + +#[derive(Clone)] +pub struct YandexCloudEnvData { + pub api_key: String, + pub func_id: String, +} + +impl Default for YandexCloudEnvData { + fn default() -> Self { + Self { + api_key: env::var("YANDEX_CLOUD_API_KEY").expect("YANDEX_CLOUD_API_KEY must be set"), + func_id: env::var("YANDEX_CLOUD_FUNC_ID").expect("YANDEX_CLOUD_FUNC_ID must be set"), + } + } +} diff --git a/src/state/fcm_client.rs b/src/state/fcm_client.rs new file mode 100644 index 0000000..31f2bf9 --- /dev/null +++ b/src/state/fcm_client.rs @@ -0,0 +1,15 @@ +use firebase_messaging_rs::FCMClient; +use std::env; +use tokio::sync::Mutex; + +#[derive(Clone)] +pub struct FCMClientData; + +impl FCMClientData { + pub async fn new() -> Option> { + match env::var("GOOGLE_APPLICATION_CREDENTIALS") { + Ok(_) => Some(Mutex::new(FCMClient::new().await.unwrap())), + Err(_) => None, + } + } +} diff --git a/src/state/mod.rs b/src/state/mod.rs new file mode 100644 index 0000000..7e1fee2 --- /dev/null +++ b/src/state/mod.rs @@ -0,0 +1,88 @@ +mod env; +mod fcm_client; +mod schedule; + +use crate::state::fcm_client::FCMClientData; +use crate::xls_downloader::basic_impl::BasicXlsDownloader; +use actix_web::web; +use diesel::{Connection, PgConnection}; +use firebase_messaging_rs::FCMClient; +use std::ops::DerefMut; +use tokio::sync::{MappedMutexGuard, Mutex, MutexGuard}; + +pub use self::schedule::{Schedule, ScheduleSnapshot}; +pub use crate::state::env::AppEnv; + +/// Common data provided to endpoints. +pub struct AppState { + database: Mutex, + downloader: Mutex, + schedule: Mutex, + env: AppEnv, + fcm_client: Option>, +} + +impl AppState { + pub async fn new() -> Result { + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + + let mut _self = Self { + downloader: Mutex::new(BasicXlsDownloader::new()), + + schedule: Mutex::new(Schedule::default()), + database: Mutex::new( + PgConnection::establish(&database_url) + .unwrap_or_else(|_| panic!("Error connecting to {}", database_url)), + ), + env: AppEnv::default(), + fcm_client: FCMClientData::new().await, + }; + + if _self.env.schedule.auto_update { + _self + .get_schedule() + .await + .init(_self.get_downloader().await.deref_mut(), &_self.env) + .await?; + } + + Ok(_self) + } + + pub async fn get_downloader(&'_ self) -> MutexGuard<'_, BasicXlsDownloader> { + self.downloader.lock().await + } + + pub async fn get_schedule(&'_ self) -> MutexGuard<'_, Schedule> { + self.schedule.lock().await + } + + pub async fn get_schedule_snapshot(&'_ self) -> MappedMutexGuard<'_, ScheduleSnapshot> { + let snapshot = + MutexGuard::<'_, Schedule>::map(self.schedule.lock().await, |schedule| unsafe { + schedule.snapshot.assume_init_mut() + }); + + snapshot + } + + pub async fn get_database(&'_ self) -> MutexGuard<'_, PgConnection> { + self.database.lock().await + } + + pub fn get_env(&self) -> &AppEnv { + &self.env + } + + pub async fn get_fcm_client(&'_ self) -> Option> { + match &self.fcm_client { + Some(client) => Some(client.lock().await), + None => None, + } + } +} + +/// Create a new object web::Data. +pub async fn new_app_state() -> Result, self::schedule::Error> { + Ok(web::Data::new(AppState::new().await?)) +} diff --git a/src/state/schedule.rs b/src/state/schedule.rs new file mode 100644 index 0000000..8a74759 --- /dev/null +++ b/src/state/schedule.rs @@ -0,0 +1,290 @@ +use crate::state::env::AppEnv; +use crate::utility::hasher::DigestHasher; +use chrono::{DateTime, Utc}; +use derive_more::{Display, Error}; +use schedule_parser::parse_xls; +use schedule_parser::schema::{ParseError, ParseResult}; +use sha1::{Digest, Sha1}; +use std::hash::Hash; +use std::mem::MaybeUninit; + +use crate::xls_downloader::basic_impl::BasicXlsDownloader; +use crate::xls_downloader::interface::{FetchError, XLSDownloader}; + +/// Represents errors that can occur during schedule-related operations. +#[derive(Debug, Display, Error)] +pub enum Error { + /// An error occurred while querying the Yandex Cloud API for a URL. + /// + /// This may result from network failures, invalid API credentials, or issues with the Yandex Cloud Function invocation. + /// See [`QueryUrlError`] for more details about specific causes. + QueryUrlFailed(QueryUrlError), + + /// The schedule snapshot creation process failed. + /// + /// This can happen due to URL conflicts (same URL already in use), failed network requests, + /// download errors, or invalid XLS file content. See [`SnapshotCreationError`] for details. + SnapshotCreationFailed(SnapshotCreationError), +} +/// Errors that may occur when querying the Yandex Cloud API to retrieve a URL. +#[derive(Debug, Display, Error)] +pub enum QueryUrlError { + /// Occurs when the request to the Yandex Cloud API fails. + /// + /// This may be due to network issues, invalid API key, incorrect function ID, or other + /// problems with the Yandex Cloud Function invocation. + #[display("An error occurred during the request to the Yandex Cloud API: {_0}")] + RequestFailed(reqwest::Error), +} + +/// Errors that may occur during the creation of a schedule snapshot. +#[derive(Debug, Display, Error)] +pub enum SnapshotCreationError { + /// The URL is the same as the one already being used (no update needed). + #[display("The URL is the same as the one already being used.")] + SameUrl, + + /// The URL query for the XLS file failed to execute, either due to network issues or invalid API parameters. + #[display("Failed to fetch URL: {_0}")] + FetchFailed(FetchError), + + /// Downloading the XLS file content failed after successfully obtaining the URL. + #[display("Download failed: {_0}")] + DownloadFailed(FetchError), + + /// The XLS file could not be parsed into a valid schedule format. + #[display("Schedule data is invalid: {_0}")] + InvalidSchedule(ParseError), +} + +/// Represents a snapshot of the schedule parsed from an XLS file. +#[derive(Clone)] +pub struct ScheduleSnapshot { + /// Timestamp when the Polytechnic website was queried for the schedule. + pub fetched_at: DateTime, + + /// Timestamp indicating when the schedule was last updated on the Polytechnic website. + /// + /// + /// This value is determined by the website's content and does not depend on the application. + /// + pub updated_at: DateTime, + + /// URL pointing to the XLS file containing the source schedule data. + pub url: String, + + /// Parsed schedule data in the application's internal representation. + pub data: ParseResult, +} + +impl ScheduleSnapshot { + /// Converting the schedule data into a hash. + /// ### Important! + /// The hash does not depend on the dates. + /// If the application is restarted, but the file with source schedule will remain unchanged, then the hash will not change. + pub fn hash(&self) -> String { + let mut hasher = DigestHasher::from(Sha1::new()); + + self.data.teachers.iter().for_each(|e| e.hash(&mut hasher)); + self.data.groups.iter().for_each(|e| e.hash(&mut hasher)); + + hasher.finalize() + } + + /// Simply updates the value of [`ScheduleSnapshot::fetched_at`]. + /// Used for auto-updates. + pub fn update(&mut self) { + self.fetched_at = Utc::now(); + } + + /// Constructs a new `ScheduleSnapshot` by downloading and parsing schedule data from the specified URL. + /// + /// This method first checks if the provided URL is the same as the one already configured in the downloader. + /// If different, it updates the downloader's URL, fetches the XLS content, parses it, and creates a snapshot. + /// Errors are returned for URL conflicts, network issues, download failures, or invalid data. + /// + /// # Arguments + /// + /// * `downloader`: A mutable reference to an `XLSDownloader` implementation used to fetch and parse the schedule data. + /// * `url`: The source URL pointing to the XLS file containing schedule data. + /// + /// returns: Result + pub async fn new( + downloader: &mut BasicXlsDownloader, + url: String, + ) -> Result { + if downloader.url.as_ref().is_some_and(|_url| _url.eq(&url)) { + return Err(SnapshotCreationError::SameUrl); + } + + let head_result = downloader.set_url(&*url).await.map_err(|error| { + if let FetchError::Unknown(error) = &error { + sentry::capture_error(&error); + } + + SnapshotCreationError::FetchFailed(error) + })?; + + let xls_data = downloader + .fetch(false) + .await + .map_err(|error| { + if let FetchError::Unknown(error) = &error { + sentry::capture_error(&error); + } + + SnapshotCreationError::DownloadFailed(error) + })? + .data + .unwrap(); + + let parse_result = parse_xls(&xls_data).map_err(|error| { + sentry::capture_error(&error); + + SnapshotCreationError::InvalidSchedule(error) + })?; + + Ok(ScheduleSnapshot { + fetched_at: head_result.requested_at, + updated_at: head_result.uploaded_at, + url, + data: parse_result, + }) + } +} + +pub struct Schedule { + pub snapshot: MaybeUninit, +} + +impl Default for Schedule { + fn default() -> Self { + Self { + snapshot: MaybeUninit::uninit(), + } + } +} + +impl Schedule { + /// Queries the Yandex Cloud Function (FaaS) to obtain a URL for the schedule file. + /// + /// This sends a POST request to the specified Yandex Cloud Function endpoint, + /// using the provided API key for authentication. The returned URI is combined + /// with the "https://politehnikum-eng.ru" base domain to form the complete URL. + /// + /// # Arguments + /// + /// * `api_key` - Authentication token for Yandex Cloud API + /// * `func_id` - ID of the target Yandex Cloud Function to invoke + /// + /// # Returns + /// + /// Result containing: + /// - `Ok(String)` - Complete URL constructed from the Function's response + /// - `Err(QueryUrlError)` - If the request or response processing fails + async fn query_url(api_key: &str, func_id: &str) -> Result { + let client = reqwest::Client::new(); + + let uri = client + .post(format!( + "https://functions.yandexcloud.net/{}?integration=raw", + func_id + )) + .header("Authorization", format!("Api-Key {}", api_key)) + .send() + .await + .map_err(|error| QueryUrlError::RequestFailed(error))? + .text() + .await + .map_err(|error| QueryUrlError::RequestFailed(error))?; + + Ok(format!("https://politehnikum-eng.ru{}", uri.trim())) + } + + /// Initializes the schedule by fetching the URL from the environment or Yandex Cloud Function (FaaS) + /// and creating a [`ScheduleSnapshot`] with the downloaded data. + /// + /// # Arguments + /// + /// * `downloader`: Mutable reference to an `XLSDownloader` implementation used to fetch and parse the schedule + /// * `app_env`: Reference to the application environment containing either a predefined URL or Yandex Cloud credentials + /// + /// # Returns + /// + /// Returns `Ok(())` if the snapshot was successfully initialized, or an `Error` if: + /// - URL query to Yandex Cloud failed ([`QueryUrlError`]) + /// - Schedule snapshot creation failed ([`SnapshotCreationError`]) + pub async fn init( + &mut self, + downloader: &mut BasicXlsDownloader, + app_env: &AppEnv, + ) -> Result<(), Error> { + let url = if let Some(url) = &app_env.schedule.url { + log::info!("The default link {} will be used", url); + url.clone() + } else { + log::info!("Obtaining a link using FaaS..."); + Self::query_url( + &*app_env.yandex_cloud.api_key, + &*app_env.yandex_cloud.func_id, + ) + .await + .map_err(|error| Error::QueryUrlFailed(error))? + }; + + log::info!("For the initial setup, a link {} will be used", url); + + let snapshot = ScheduleSnapshot::new(downloader, url) + .await + .map_err(|error| Error::SnapshotCreationFailed(error))?; + + log::info!("Schedule snapshot successfully created!"); + + self.snapshot.write(snapshot); + + Ok(()) + } + + /// Updates the schedule snapshot by querying the latest URL from FaaS and checking for changes. + /// If the URL hasn't changed, only updates the [`fetched_at`] timestamp. If changed, downloads + /// and parses the new schedule data. + /// + /// # Arguments + /// + /// * `downloader`: XLS file downloader used to fetch and parse the schedule data + /// * `app_env`: Application environment containing Yandex Cloud configuration and auto-update settings + /// + /// returns: `Result<(), Error>` - Returns error if URL query fails or schedule parsing encounters issues + /// + /// # Safety + /// + /// Uses `unsafe` to access the initialized snapshot, guaranteed valid by prior `init()` call + #[allow(unused)] // TODO: сделать авто апдейт + pub async fn update( + &mut self, + downloader: &mut BasicXlsDownloader, + app_env: &AppEnv, + ) -> Result<(), Error> { + assert!(app_env.schedule.auto_update); + + let url = Self::query_url( + &*app_env.yandex_cloud.api_key, + &*app_env.yandex_cloud.func_id, + ) + .await + .map_err(|error| Error::QueryUrlFailed(error))?; + + let snapshot = match ScheduleSnapshot::new(downloader, url).await { + Ok(snapshot) => snapshot, + Err(SnapshotCreationError::SameUrl) => { + unsafe { self.snapshot.assume_init_mut() }.update(); + return Ok(()); + } + Err(error) => return Err(Error::SnapshotCreationFailed(error)), + }; + + self.snapshot.write(snapshot); + + Ok(()) + } +} diff --git a/src/test_env.rs b/src/test_env.rs index caa1104..0f82e24 100644 --- a/src/test_env.rs +++ b/src/test_env.rs @@ -1,47 +1,25 @@ #[cfg(test)] pub(crate) mod tests { - use crate::app_state::{AppState, Schedule, app_state}; - use schedule_parser::test_utils::test_result; - use crate::utility::mutex::MutexScope; + use crate::state::{AppState, ScheduleSnapshot, new_app_state}; use actix_web::web; + use log::info; + use schedule_parser::test_utils::test_result; use std::default::Default; use tokio::sync::OnceCell; pub fn test_env() { + info!("Loading test environment file..."); dotenvy::from_path(".env.test").expect("Failed to load test environment file"); } - pub enum TestScheduleType { - None, - Local, - } + pub async fn test_app_state() -> web::Data { + let state = new_app_state().await.unwrap(); - pub struct TestAppStateParams { - pub schedule: TestScheduleType, - } - - impl Default for TestAppStateParams { - fn default() -> Self { - Self { - schedule: TestScheduleType::None, - } - } - } - - pub async fn test_app_state(params: TestAppStateParams) -> web::Data { - let state = app_state().await; - - state.schedule.scope(|schedule| { - *schedule = match params.schedule { - TestScheduleType::None => None, - TestScheduleType::Local => Some(Schedule { - etag: "".to_string(), - fetched_at: Default::default(), - updated_at: Default::default(), - parsed_at: Default::default(), - data: test_result().unwrap(), - }), - } + state.get_schedule().await.snapshot.write(ScheduleSnapshot { + fetched_at: Default::default(), + updated_at: Default::default(), + url: "".to_string(), + data: test_result().unwrap(), }); state.clone() @@ -50,9 +28,6 @@ pub(crate) mod tests { pub async fn static_app_state() -> web::Data { static STATE: OnceCell> = OnceCell::const_new(); - STATE - .get_or_init(|| test_app_state(Default::default())) - .await - .clone() + STATE.get_or_init(|| test_app_state()).await.clone() } } diff --git a/src/utility/error.rs b/src/utility/error.rs index d1a7535..f1d755f 100644 --- a/src/utility/error.rs +++ b/src/utility/error.rs @@ -1,19 +1,19 @@ -use std::fmt::{Write}; -use std::fmt::Display; use serde::{Deserialize, Serialize}; +use std::fmt::Display; +use std::fmt::Write; /// Server response to errors within Middleware. #[derive(Serialize, Deserialize)] -pub struct ResponseErrorMessage { +pub struct MiddlewareError { code: T, message: String, } -impl ResponseErrorMessage { +impl MiddlewareError { pub fn new(code: T) -> Self { let mut message = String::new(); write!(&mut message, "{}", code).unwrap(); Self { code, message } } -} \ No newline at end of file +} diff --git a/src/utility/mod.rs b/src/utility/mod.rs index ca71730..02105fa 100644 --- a/src/utility/mod.rs +++ b/src/utility/mod.rs @@ -1,4 +1,4 @@ -pub mod jwt; pub mod error; pub mod hasher; -pub mod mutex; \ No newline at end of file +pub mod jwt; +pub mod telegram; diff --git a/src/utility/mutex.rs b/src/utility/mutex.rs deleted file mode 100644 index 126cb3c..0000000 --- a/src/utility/mutex.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::ops::DerefMut; -use std::sync::Mutex; - -pub trait MutexScope -where - ScopeFn: FnOnce(&mut T) -> ScopeFnOutput, -{ - /// Replaces manually creating a mutex lock to perform operations on the data it manages. - /// - /// # Arguments - /// - /// * `f`: Function (mostly lambda) to which a reference to the mutable object stored in the mutex will be passed. - /// - /// returns: Return value of `f` function. - /// - /// # Examples - /// - /// ``` - /// let mtx: Mutex = Mutex::new(10); - /// - /// let res = mtx.scope(|x| { *x = *x * 2; *x }); - /// assert_eq!(res, *mtx.lock().unwrap()); - /// ``` - fn scope(&self, f: ScopeFn) -> ScopeFnOutput; -} - -impl MutexScope for Mutex -where - ScopeFn: FnOnce(&mut T) -> ScopeFnOutput, -{ - fn scope(&self, f: ScopeFn) -> ScopeFnOutput { - let mut lock = self.lock().unwrap(); - let inner = lock.deref_mut(); - - f(inner) - } -} - -pub trait MutexScopeAsync { - /// ## Asynchronous variant of [MutexScope::scope][MutexScope::scope]. - /// - /// Replaces manually creating a mutex lock to perform operations on the data it manages. - /// - /// # Arguments - /// - /// * `f`: Asynchronous function (mostly lambda) to which a reference to the mutable object stored in the mutex will be passed. - /// - /// returns: Return value of `f` function. - /// - /// # Examples - /// - /// ``` - /// let mtx: Mutex = Mutex::new(10); - /// - /// let res = mtx.async_scope(async |x| { *x = *x * 2; *x }).await; - /// assert_eq!(res, *mtx.lock().unwrap()); - /// ``` - async fn async_scope<'a, F, FnFut, FnOut>(&'a self, f: F) -> FnOut - where - FnFut: Future, - F: FnOnce(&'a mut T) -> FnFut, - T: 'a; -} - -impl MutexScopeAsync for Mutex { - async fn async_scope<'a, F, FnFut, FnOut>(&'a self, f: F) -> FnOut - where - FnFut: Future, - F: FnOnce(&'a mut T) -> FnFut, - T: 'a, - { - let mut guard = self.lock().unwrap(); - - let ptr: &'a mut T = unsafe { &mut *(guard.deref_mut() as *mut _) }; - f(ptr).await - } -} diff --git a/src/utility/telegram.rs b/src/utility/telegram.rs new file mode 100644 index 0000000..3e9fa03 --- /dev/null +++ b/src/utility/telegram.rs @@ -0,0 +1,91 @@ +use base64::Engine; +use derive_more::{Display, Error}; +use ed25519_dalek::Verifier; +use hex_literal::hex; +use serde::Deserialize; +use std::collections::HashMap; + +pub struct WebAppInitDataMap { + pub data_map: HashMap, +} + +#[derive(Deserialize)] +pub struct WebAppUser { + pub id: i64, +} + +#[derive(Clone, Debug, Display, Error)] +pub enum VerifyError { + #[display("No signature found.")] + NoSignature, + + #[display("The provided signature was corrupted.")] + BadSignature, + + #[display("The expected signature does not match the actual one.")] + IntegrityCheckFailed, +} + +impl WebAppInitDataMap { + pub fn from_str(data: String) -> Self { + let mut this = Self { + data_map: HashMap::new(), + }; + + data.split('&') + .map(|kv| kv.split_once('=').unwrap_or_else(|| (kv, ""))) + .for_each(|(key, value)| { + this.data_map.insert(key.to_string(), value.to_string()); + }); + + if let Some(user) = this.data_map.get_mut("user") { + *user = percent_encoding::percent_decode_str(&*user) + .decode_utf8_lossy() + .to_string(); + } + + this + } + + pub fn verify(&self, bot_id: i64, test_dc: bool) -> Result<(), VerifyError> { + //noinspection ALL + const TELEGRAM_PUBLIC_KEY: [[u8; 32]; 2] = [ + hex!("e7bf03a2fa4602af4580703d88dda5bb59f32ed8b02a56c187fe7d34caed242d"), + hex!("40055058a4ee38156a06562e52eece92a771bcd8346a8c4615cb7376eddf72ec"), + ]; + + let verifying_key = ed25519_dalek::VerifyingKey::from_bytes( + &TELEGRAM_PUBLIC_KEY[if test_dc { 1 } else { 0 }], + ) + .unwrap(); + + let signature = { + let raw = self + .data_map + .get("signature") + .ok_or(VerifyError::NoSignature)?; + + let bytes = base64::prelude::BASE64_URL_SAFE_NO_PAD + .decode(raw) + .map_err(|_| VerifyError::BadSignature)?; + + ed25519_dalek::Signature::from_slice(bytes.as_slice()) + .map_err(|_| VerifyError::BadSignature)? + }; + + let data_check_string = format!("{}:WebAppData\n{}", bot_id, { + let mut vec = self + .data_map + .iter() + .filter(|(key, _)| !["hash", "signature"].iter().any(|variant| variant == key)) + .map(|(key, value)| format!("{}={}", key, value)) + .collect::>(); + vec.sort(); + vec.join("\n") + }); + + verifying_key + .verify(data_check_string.as_bytes(), &signature) + .map_err(|_| VerifyError::IntegrityCheckFailed) + } +} diff --git a/src/xls_downloader/basic_impl.rs b/src/xls_downloader/basic_impl.rs index f3f1a74..dc53fff 100644 --- a/src/xls_downloader/basic_impl.rs +++ b/src/xls_downloader/basic_impl.rs @@ -1,14 +1,12 @@ use crate::xls_downloader::interface::{FetchError, FetchOk, FetchResult, XLSDownloader}; use chrono::{DateTime, Utc}; -use std::env; use std::sync::Arc; pub struct BasicXlsDownloader { pub url: Option, - user_agent: String, } -async fn fetch_specified(url: &String, user_agent: &String, head: bool) -> FetchResult { +async fn fetch_specified(url: &str, head: bool) -> FetchResult { let client = reqwest::Client::new(); let response = if head { @@ -16,59 +14,47 @@ async fn fetch_specified(url: &String, user_agent: &String, head: bool) -> Fetch } else { client.get(url) } - .header("User-Agent", user_agent.clone()) + .header("User-Agent", ua_generator::ua::spoof_chrome_ua()) .send() - .await; + .await + .map_err(|e| FetchError::unknown(Arc::new(e)))?; - match response { - Ok(r) => { - if r.status().as_u16() != 200 { - return Err(FetchError::BadStatusCode(r.status().as_u16())); - } - - let headers = r.headers(); - - let content_type = headers.get("Content-Type"); - let etag = headers.get("etag"); - let last_modified = headers.get("last-modified"); - let date = headers.get("date"); - - if content_type.is_none() { - Err(FetchError::BadHeaders("Content-Type".to_string())) - } else if etag.is_none() { - Err(FetchError::BadHeaders("ETag".to_string())) - } else if last_modified.is_none() { - Err(FetchError::BadHeaders("Last-Modified".to_string())) - } else if date.is_none() { - Err(FetchError::BadHeaders("Date".to_string())) - } else if content_type.unwrap() != "application/vnd.ms-excel" { - Err(FetchError::BadContentType( - content_type.unwrap().to_str().unwrap().to_string(), - )) - } else { - let etag = etag.unwrap().to_str().unwrap().to_string(); - let last_modified = - DateTime::parse_from_rfc2822(&last_modified.unwrap().to_str().unwrap()) - .unwrap() - .with_timezone(&Utc); - - Ok(if head { - FetchOk::head(etag, last_modified) - } else { - FetchOk::get(etag, last_modified, r.bytes().await.unwrap().to_vec()) - }) - } - } - Err(error) => Err(FetchError::Unknown(Arc::new(error))), + if response.status().as_u16() != 200 { + return Err(FetchError::bad_status_code(response.status().as_u16())); } + + let headers = response.headers(); + + let content_type = headers + .get("Content-Type") + .ok_or(FetchError::bad_headers("Content-Type"))?; + + if !headers.contains_key("etag") { + return Err(FetchError::bad_headers("etag")); + } + + let last_modified = headers + .get("last-modified") + .ok_or(FetchError::bad_headers("last-modified"))?; + + if content_type != "application/vnd.ms-excel" { + return Err(FetchError::bad_content_type(content_type.to_str().unwrap())); + } + + let last_modified = DateTime::parse_from_rfc2822(&last_modified.to_str().unwrap()) + .unwrap() + .with_timezone(&Utc); + + Ok(if head { + FetchOk::head(last_modified) + } else { + FetchOk::get(last_modified, response.bytes().await.unwrap().to_vec()) + }) } impl BasicXlsDownloader { pub fn new() -> Self { - BasicXlsDownloader { - url: None, - user_agent: env::var("REQWEST_USER_AGENT").expect("USER_AGENT must be set"), - } + BasicXlsDownloader { url: None } } } @@ -77,15 +63,15 @@ impl XLSDownloader for BasicXlsDownloader { if self.url.is_none() { Err(FetchError::NoUrlProvided) } else { - fetch_specified(self.url.as_ref().unwrap(), &self.user_agent, head).await + fetch_specified(&*self.url.as_ref().unwrap(), head).await } } - async fn set_url(&mut self, url: String) -> FetchResult { - let result = fetch_specified(&url, &self.user_agent, true).await; + async fn set_url(&mut self, url: &str) -> FetchResult { + let result = fetch_specified(url, true).await; if let Ok(_) = result { - self.url = Some(url); + self.url = Some(url.to_string()); } result @@ -94,17 +80,16 @@ impl XLSDownloader for BasicXlsDownloader { #[cfg(test)] mod tests { - use crate::xls_downloader::basic_impl::{fetch_specified, BasicXlsDownloader}; + use crate::xls_downloader::basic_impl::{BasicXlsDownloader, fetch_specified}; use crate::xls_downloader::interface::{FetchError, XLSDownloader}; #[tokio::test] async fn bad_url() { - let url = "bad_url".to_string(); - let user_agent = String::new(); + let url = "bad_url"; let results = [ - fetch_specified(&url, &user_agent, true).await, - fetch_specified(&url, &user_agent, false).await, + fetch_specified(url, true).await, + fetch_specified(url, false).await, ]; assert!(results[0].is_err()); @@ -113,18 +98,17 @@ mod tests { #[tokio::test] async fn bad_status_code() { - let url = "https://www.google.com/not-found".to_string(); - let user_agent = String::new(); + let url = "https://www.google.com/not-found"; let results = [ - fetch_specified(&url, &user_agent, true).await, - fetch_specified(&url, &user_agent, false).await, + fetch_specified(url, true).await, + fetch_specified(url, false).await, ]; assert!(results[0].is_err()); assert!(results[1].is_err()); - let expected_error = FetchError::BadStatusCode(404); + let expected_error = FetchError::BadStatusCode { status_code: 404 }; assert_eq!(*results[0].as_ref().err().unwrap(), expected_error); assert_eq!(*results[1].as_ref().err().unwrap(), expected_error); @@ -132,18 +116,19 @@ mod tests { #[tokio::test] async fn bad_headers() { - let url = "https://www.google.com/favicon.ico".to_string(); - let user_agent = String::new(); + let url = "https://www.google.com/favicon.ico"; let results = [ - fetch_specified(&url, &user_agent, true).await, - fetch_specified(&url, &user_agent, false).await, + fetch_specified(url, true).await, + fetch_specified(url, false).await, ]; assert!(results[0].is_err()); assert!(results[1].is_err()); - let expected_error = FetchError::BadHeaders("ETag".to_string()); + let expected_error = FetchError::BadHeaders { + expected_header: "ETag".to_string(), + }; assert_eq!(*results[0].as_ref().err().unwrap(), expected_error); assert_eq!(*results[1].as_ref().err().unwrap(), expected_error); @@ -151,12 +136,11 @@ mod tests { #[tokio::test] async fn bad_content_type() { - let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb59fd46303008396ac96%2Fexample.txt".to_string(); - let user_agent = String::new(); + let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb59fd46303008396ac96%2Fexample.txt"; let results = [ - fetch_specified(&url, &user_agent, true).await, - fetch_specified(&url, &user_agent, false).await, + fetch_specified(url, true).await, + fetch_specified(url, false).await, ]; assert!(results[0].is_err()); @@ -165,12 +149,11 @@ mod tests { #[tokio::test] async fn ok() { - let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb5fad46303008396ac97%2Fschedule.xls".to_string(); - let user_agent = String::new(); + let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb5fad46303008396ac97%2Fschedule.xls"; let results = [ - fetch_specified(&url, &user_agent, true).await, - fetch_specified(&url, &user_agent, false).await, + fetch_specified(url, true).await, + fetch_specified(url, false).await, ]; assert!(results[0].is_ok()); @@ -179,7 +162,7 @@ mod tests { #[tokio::test] async fn downloader_set_ok() { - let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb5fad46303008396ac97%2Fschedule.xls".to_string(); + let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb5fad46303008396ac97%2Fschedule.xls"; let mut downloader = BasicXlsDownloader::new(); @@ -188,7 +171,7 @@ mod tests { #[tokio::test] async fn downloader_set_err() { - let url = "bad_url".to_string(); + let url = "bad_url"; let mut downloader = BasicXlsDownloader::new(); @@ -197,7 +180,7 @@ mod tests { #[tokio::test] async fn downloader_ok() { - let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb5fad46303008396ac97%2Fschedule.xls".to_string(); + let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb5fad46303008396ac97%2Fschedule.xls"; let mut downloader = BasicXlsDownloader::new(); diff --git a/src/xls_downloader/interface.rs b/src/xls_downloader/interface.rs index 10dca0f..8e138ad 100644 --- a/src/xls_downloader/interface.rs +++ b/src/xls_downloader/interface.rs @@ -1,11 +1,11 @@ use chrono::{DateTime, Utc}; -use derive_more::Display; +use derive_more::{Display, Error}; use std::mem::discriminant; use std::sync::Arc; use utoipa::ToSchema; /// XLS data retrieval errors. -#[derive(Clone, Debug, ToSchema, Display)] +#[derive(Clone, Debug, ToSchema, Display, Error)] pub enum FetchError { /// File url is not set. #[display("The link to the timetable was not provided earlier.")] @@ -17,16 +17,38 @@ pub enum FetchError { Unknown(Arc), /// Server returned a status code different from 200. - #[display("Server returned a status code {_0}.")] - BadStatusCode(u16), + #[display("Server returned a status code {status_code}.")] + BadStatusCode { status_code: u16 }, /// The url leads to a file of a different type. - #[display("The link leads to a file of type '{_0}'.")] - BadContentType(String), + #[display("The link leads to a file of type '{content_type}'.")] + BadContentType { content_type: String }, /// Server doesn't return expected headers. - #[display("Server doesn't return expected header(s) '{_0}'.")] - BadHeaders(String), + #[display("Server doesn't return expected header(s) '{expected_header}'.")] + BadHeaders { expected_header: String }, +} + +impl FetchError { + pub fn unknown(error: Arc) -> Self { + Self::Unknown(error) + } + + pub fn bad_status_code(status_code: u16) -> Self { + Self::BadStatusCode { status_code } + } + + pub fn bad_content_type(content_type: &str) -> Self { + Self::BadContentType { + content_type: content_type.to_string(), + } + } + + pub fn bad_headers(expected_header: &str) -> Self { + Self::BadHeaders { + expected_header: expected_header.to_string(), + } + } } impl PartialEq for FetchError { @@ -37,9 +59,6 @@ impl PartialEq for FetchError { /// Result of XLS data retrieval. pub struct FetchOk { - /// ETag object. - pub etag: String, - /// File upload date. pub uploaded_at: DateTime, @@ -52,9 +71,8 @@ pub struct FetchOk { impl FetchOk { /// Result without file content. - pub fn head(etag: String, uploaded_at: DateTime) -> Self { + pub fn head(uploaded_at: DateTime) -> Self { FetchOk { - etag, uploaded_at, requested_at: Utc::now(), data: None, @@ -62,9 +80,8 @@ impl FetchOk { } /// Full result. - pub fn get(etag: String, uploaded_at: DateTime, data: Vec) -> Self { + pub fn get(uploaded_at: DateTime, data: Vec) -> Self { FetchOk { - etag, uploaded_at, requested_at: Utc::now(), data: Some(data), @@ -79,5 +96,5 @@ pub trait XLSDownloader { async fn fetch(&self, head: bool) -> FetchResult; /// Setting the file link. - async fn set_url(&mut self, url: String) -> FetchResult; + async fn set_url(&mut self, url: &str) -> FetchResult; }