diff --git a/.env b/.env index c5f587b..d376f05 100644 --- a/.env +++ b/.env @@ -2,11 +2,11 @@ RUST_LOG=warn,api=info NGINX_HOST=localhost NGINX_SSL_ENABLED=false -NGINX_PROTOCOL=http NGINX_HTTP_PORT=8080 NGINX_HTTPS_PORT=8443 # Set to 'localhost' or 'host.docker.internal' or '172.17.0.1' NGINX_INTERNAL_HOST=host.docker.internal +EXTERNAL_URL=http://localhost:8080 POSTGRES_HOST=localhost POSTGRES_USER=aviation @@ -24,7 +24,7 @@ MINIO_BUCKET=aviation MINIO_PROTOCOL=http MINIO_PORT=9000 MINIO_INTERNAL_PORT=9001 -MINIO_BROWSER_REDIRECT_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTP_PORT}/minio/ +MINIO_BROWSER_REDIRECT_URL=${EXTERNAL_URL}/minio/ UI_PORT=3000 API_PORT=5000 @@ -35,11 +35,19 @@ SSL_CA_PATH=../ssl/${SSL_CA_NAME}.pem SSL_CERT_PATH=../ssl/localhost.crt SSL_CERT_KEY_PATH=../ssl/localhost.key -VITE_API_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTP_PORT}/api +SMTP_USERNAME=smtp-user +SMTP_PASSWORD=smtp-password +SMTP_FROM=noreply@example.com +SMTP_SERVER=smtp.example.com + +VITE_API_URL=${EXTERNAL_URL}/api VITE_DEFAULT_LIMIT=200 __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=${NGINX_HOST} ENVIRONMENT=development +API_CONTACT_NAME=changeme +API_CONTACT_EMAIL=contact@example.com + ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=changeme diff --git a/README.md b/README.md index 997d375..b5edea9 100755 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@

Aviation Data

+[Swagger Docs](https://aviation.bensherriff.com/swagger/#/) + ## Makefile * `make` or `make help` to list all commands * `make docker-up` to start all containers diff --git a/api/Cargo.lock b/api/Cargo.lock index 28567d6..9455931 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -254,6 +254,18 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy 0.8.24", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -362,6 +374,8 @@ dependencies = [ "env_logger", "futures", "futures-util", + "handlebars", + "lettre", "log", "rand 0.9.0", "rand_chacha 0.9.0", @@ -373,7 +387,20 @@ dependencies = [ "serde_json", "sqlx", "tokio", + "utoipa", + "utoipa-actix-web", + "utoipa-swagger-ui", "uuid", + "webpki-roots", +] + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", ] [[package]] @@ -468,9 +495,9 @@ dependencies = [ [[package]] name = "backon" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "970d91570c01a8a5959b36ad7dd1c30642df24b6b3068710066f6809f7033bb7" +checksum = "fd0b50b1b78dbadd44ab18b3c794e496f3a139abb9fbc27d9c94c4eebbb96496" dependencies = [ "fastrand", ] @@ -609,6 +636,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "colorchoice" version = "1.0.3" @@ -817,6 +854,48 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "derive_more" version = "0.99.19" @@ -904,6 +983,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1214,11 +1309,31 @@ dependencies = [ "tracing", ] +[[package]] +name = "handlebars" +version = "6.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.12", +] + [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "hashbrown" @@ -1279,6 +1394,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "0.2.12" @@ -1637,6 +1763,7 @@ checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" dependencies = [ "equivalent", "hashbrown 0.15.2", + "serde", ] [[package]] @@ -1715,6 +1842,34 @@ dependencies = [ "spin", ] +[[package]] +name = "lettre" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ffd14fa289730e3ad68edefdc31f603d56fe716ec38f2076bb7410e09147c2" +dependencies = [ + "async-trait", + "base64", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio", + "tokio-native-tls", + "url", +] + [[package]] name = "libc" version = "0.2.170" @@ -1821,6 +1976,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minidom" version = "0.15.2" @@ -1868,6 +2033,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1921,6 +2095,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2067,6 +2256,51 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +dependencies = [ + "memchr", + "thiserror 2.0.12", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2145,6 +2379,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f" +dependencies = [ + "cc", +] + [[package]] name = "quick-xml" version = "0.32.0" @@ -2164,6 +2407,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r2d2" version = "0.8.10" @@ -2237,13 +2486,14 @@ dependencies = [ [[package]] name = "redis" -version = "0.29.5" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc42f3a12fd4408ce64d8efef67048a924e543bd35c6591c0447fda9054695f" +checksum = "0bc1ea653e0b2e097db3ebb5b7f678be339620b8041f66b30a308c1d45d36a7f" dependencies = [ "arc-swap", "backon", "bytes", + "cfg-if", "combine", "futures-channel", "futures-util", @@ -2384,6 +2634,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60e425e204264b144d4c929d126d0de524b40a961686414bab5040f7465c71be" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf418c9a2e3f6663ca38b8a7134cc2c2167c9d69688860e8961e3faa731702e" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d55b95147fe01265d06b3955db798bdaed52e60e2211c41137701b3aba8e21" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rust-ini" version = "0.21.1" @@ -2528,6 +2812,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.27" @@ -2687,6 +2980,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "slab" version = "0.4.9" @@ -2946,6 +3245,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cddb07e32ddb770749da91081d8d0ac3a16f1a569a18b20348cd371f5dead06b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -3296,6 +3608,18 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3364,6 +3688,60 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435c6f69ef38c9017b4b4eea965dfb91e71e53d869e896db40d1cf2441dd75c0" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-actix-web" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7eda9c23c05af0fb812f6a177514047331dac4851a2c8e9c4b895d6d826967f" +dependencies = [ + "actix-service", + "actix-web", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77d306bc75294fd52f3e99b13ece67c02c1a2789190a6f31d32f736624326f7" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", + "uuid", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29519b3c485df6b13f4478ac909a491387e9ef70204487c3b64b53749aec0be" +dependencies = [ + "actix-web", + "base64", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + [[package]] name = "uuid" version = "1.16.0" @@ -3386,6 +3764,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3497,6 +3885,15 @@ dependencies = [ "wasm-bindgen", ] +[[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 = "whoami" version = "1.5.2" @@ -3507,6 +3904,15 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -3518,9 +3924,9 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" [[package]] name = "windows-registry" @@ -3898,6 +4304,33 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zopfli" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/api/Cargo.toml b/api/Cargo.toml index b770918..6d5fa9b 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -23,10 +23,16 @@ tokio = { version = "1.44.2", features = ["macros", "rt", "time"] } uuid = { version = "1.16.0", features = ["serde", "v4"] } log = "0.4.27" argon2 = "0.5.3" -redis = { version = "0.29.5", features = ["tokio-comp", "connection-manager", "r2d2", "json"] } +redis = { version = "0.31.0", features = ["tokio-comp", "connection-manager", "r2d2", "json"] } regex = "1.11.1" futures-util = "0.3.31" rust-s3 = "0.35.1" rand = "0.9.0" rand_chacha = "0.9.0" futures = "0.3.31" +utoipa = { version = "5.3.1", features = ["chrono", "uuid", "actix_extras"] } +utoipa-swagger-ui = { version = "9.0.1", features = ["actix-web"] } +utoipa-actix-web = "0.1.2" +webpki-roots = "1.0.0" +lettre = { version = "0.11.16", features = ["builder", "smtp-transport", "tokio1-native-tls"] } +handlebars = "6.3.2" diff --git a/api/migrations/20250513_initial.sql b/api/migrations/20250513_initial.sql index f711ead..57eb86b 100644 --- a/api/migrations/20250513_initial.sql +++ b/api/migrations/20250513_initial.sql @@ -30,7 +30,7 @@ CREATE INDEX ON airports (metar_observation_time); CREATE TABLE IF NOT EXISTS runways ( id UUID PRIMARY KEY NOT NULL, - icao TEXT NOT NULL, + icao TEXT NOT NULL REFERENCES airports(icao) ON DELETE CASCADE, runway_id TEXT NOT NULL, length_ft REAL NOT NULL, width_ft REAL NOT NULL, @@ -42,7 +42,7 @@ CREATE INDEX ON runways (runway_id); CREATE TABLE IF NOT EXISTS communications ( id UUID PRIMARY KEY NOT NULL, - icao TEXT NOT NULL, + icao TEXT NOT NULL REFERENCES airports(icao) ON DELETE CASCADE, frequency_id TEXT NOT NULL, name TEXT, frequencies_mhz REAL[] NOT NULL, @@ -64,7 +64,7 @@ CREATE TABLE IF NOT EXISTS metars ( CREATE INDEX ON metars (observation_time DESC); CREATE TABLE IF NOT EXISTS users ( - id UUID NOT NULL, + id UUID UNIQUE NOT NULL, email TEXT NOT NULL, email_verified BOOLEAN NOT NULL DEFAULT false, password_hash TEXT NOT NULL, @@ -75,4 +75,4 @@ CREATE TABLE IF NOT EXISTS users ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY(email) -); \ No newline at end of file +); diff --git a/api/rustfmt.toml b/api/rustfmt.toml index 76f62b4..aec2d6e 100644 --- a/api/rustfmt.toml +++ b/api/rustfmt.toml @@ -1,3 +1,3 @@ indent_style = "Block" -reorder_imports = false +reorder_imports = true tab_spaces = 2 \ No newline at end of file diff --git a/api/src/account/auth.rs b/api/src/account/auth.rs index 532e4d6..1c6fc93 100644 --- a/api/src/account/auth.rs +++ b/api/src/account/auth.rs @@ -1,10 +1,10 @@ use std::future::Future; use std::pin::Pin; -use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload, http}; -use serde::{Serialize, Deserialize}; +use super::{SESSION_COOKIE_NAME, Session}; use crate::{error::Error, users::User}; -use super::{Session, SESSION_COOKIE_NAME}; +use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http}; +use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] pub struct Auth { @@ -34,13 +34,13 @@ impl FromRequest for Auth { return Err(Error::new(401, "API Key does not exist".to_string()).into()); } }; - match User::select(&api_key.id).await { + match User::select(&api_key.user_id).await { Some(user) => Ok(Auth { session_id: None, api_key: Some(key_id), user, }), - None => Err(Error::new(404, format!("User {} not found", api_key.id)).into()), + None => Err(Error::new(404, format!("User {} not found", api_key.user_id)).into()), } }; return Box::pin(fut); @@ -79,13 +79,13 @@ impl FromRequest for Auth { // Verify the session let fut = async move { match Session::verify(&session_id, &ip_address).await { - Ok(session) => match User::select(&session.id).await { + Ok(session) => match User::select(&session.user_id).await { Some(user) => Ok(Auth { session_id: Some(session_id), api_key: None, user, }), - None => Err(Error::new(404, format!("User {} not found", session.id)).into()), + None => Err(Error::new(404, format!("User {} not found", session.user_id)).into()), }, Err(err) => Err(err.into()), } diff --git a/api/src/account/mod.rs b/api/src/account/mod.rs index bfff22c..f4cebf8 100644 --- a/api/src/account/mod.rs +++ b/api/src/account/mod.rs @@ -1,6 +1,6 @@ use argon2::{ - password_hash::{rand_core::OsRng, SaltString}, Argon2, PasswordHash, PasswordHasher, PasswordVerifier, + password_hash::{SaltString, rand_core::OsRng}, }; use rand::distr::Alphanumeric; use rand::prelude::*; @@ -11,10 +11,10 @@ mod routes; mod session; pub use auth::*; -pub use session::*; pub use routes::init_routes; +pub use session::*; -use crate::error::{Error, ApiResult}; +use crate::error::{ApiResult, Error}; pub fn csprng(take: usize) -> String { // Generate a CSPRNG 128-bit (16 byte) ID using alphanumeric characters (a-z, A-Z, 0-9) diff --git a/api/src/account/routes.rs b/api/src/account/routes.rs index 0eea37a..e5fb890 100644 --- a/api/src/account/routes.rs +++ b/api/src/account/routes.rs @@ -1,13 +1,26 @@ -use actix_web::{post, web, HttpResponse, ResponseError, HttpRequest, put, get}; use crate::{ - account::{verify_hash, Session, SESSION_COOKIE_NAME}, + account::{SESSION_COOKIE_NAME, Session, verify_hash}, error::Error, + smtp, users::{LoginRequest, RegisterRequest, User, UserResponse}, }; +use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web}; +use utoipa_actix_web::scope; +use utoipa_actix_web::service_config::ServiceConfig; -use crate::account::Auth; +use crate::account::{Auth, csprng}; use crate::users::UpdateUser; +#[utoipa::path( + tag = "Account", + request_body( + content = RegisterRequest, content_type = "application/json" + ), + responses( + (status = 200, description = "", body = UserResponse), + (status = 409, description = ""), + ) +)] #[post("/register")] async fn register(user: web::Json, req: HttpRequest) -> HttpResponse { let register_user = user.into_inner(); @@ -38,13 +51,22 @@ async fn register(user: web::Json, req: HttpRequest) -> HttpRes ); HttpResponse::Conflict().finish() } else { - log::error!("attemptFailed to register user [Email: {}]: {}", email, err); + log::error!("Failed to register user [Email: {}]: {}", email, err); ResponseError::error_response(&err) } } } } +#[utoipa::path( + tag = "Account", + request_body( + content = LoginRequest, content_type = "application/json" + ), + responses( + (status = 200, description = "", body = UserResponse), + ), +)] #[post("/login")] async fn login(request: web::Json, req: HttpRequest) -> HttpResponse { let email = &request.email; @@ -93,6 +115,17 @@ async fn login(request: web::Json, req: HttpRequest) -> HttpRespon } } +#[utoipa::path( + tag = "Account", + responses( + (status = 200, description = ""), + (status = 401, description = ""), + (status = 500, description = ""), + ), + security( + ("session_auth" = []) + ) +)] #[post("/logout")] async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse { let email = auth.user.email; @@ -132,6 +165,16 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse { .finish() } +#[utoipa::path( + tag = "Account", + responses( + (status = 200, description = "", body = UserResponse), + (status = 401, description = ""), + ), + security( + ("session_auth" = []) + ) +)] #[get("/profile")] async fn get_profile(req: HttpRequest) -> HttpResponse { let ip_address = req.peer_addr().unwrap().ip().to_string(); @@ -154,7 +197,7 @@ async fn get_profile(req: HttpRequest) -> HttpResponse { .finish(); } }; - let id = &session.id; + let id = &session.user_id; let query_user = match User::select(&id).await { Some(query_user) => query_user, None => { @@ -186,6 +229,16 @@ async fn get_profile(req: HttpRequest) -> HttpResponse { } } +#[utoipa::path( + tag = "Account", + responses( + (status = 200, description = ""), + (status = 401, description = ""), + ), + security( + ("session_auth" = []) + ) +)] #[get("/session")] async fn session_refresh(req: HttpRequest) -> HttpResponse { let ip_address = req.peer_addr().unwrap().ip().to_string(); @@ -208,7 +261,7 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse { .finish(); } }; - let id = &session.id; + let id = &session.user_id; let session_cookie = session.cookie(); let session_exp_cookie = session.expiration_cookie(); @@ -229,6 +282,19 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse { } } +#[utoipa::path( + tag = "Account", + request_body( + content = String, content_type = "application/json" + ), + responses( + (status = 200, description = "", body = UserResponse), + (status = 401, description = ""), + ), + security( + ("session_auth" = []) + ) +)] #[put("/password")] async fn change_password( password: web::Json, @@ -269,25 +335,52 @@ async fn change_password( ip_address, err ); - ResponseError::error_response(&Error::new(500, err.to_string())) + ResponseError::error_response(&err) } } } -#[post("/password-reset")] -async fn password_reset(req: HttpRequest, _auth: Auth) -> HttpResponse { - let _ip_address = req.peer_addr().unwrap().ip().to_string(); +#[utoipa::path( + tag = "Account", + responses( + (status = 200, description = ""), + (status = 401, description = ""), + ), + security( + ("session_auth" = []) + ) +)] +#[post("/password/reset")] +async fn reset_password(req: HttpRequest, auth: Auth) -> HttpResponse { + let ip_address = req.peer_addr().unwrap().ip().to_string(); + let id = auth.user.id; + let email = auth.user.email; + let token = csprng(128); + + match smtp::send_password_reset(&email, &token) { + Ok(_) => HttpResponse::Ok().finish(), + Err(err) => { + log::error!( + "Invalid password reset attempt [ID: {}] [IP Address: {}]: {}", + &id, + ip_address, + err + ); + ResponseError::error_response(&err) + } + }; HttpResponse::Ok().finish() } -pub fn init_routes(config: &mut web::ServiceConfig) { +pub fn init_routes(config: &mut ServiceConfig) { config.service( - web::scope("account") + scope::scope("/account") .service(register) .service(login) .service(logout) - .service(change_password) .service(get_profile) - .service(session_refresh), + .service(session_refresh) + .service(change_password) + .service(reset_password), ); } diff --git a/api/src/account/session.rs b/api/src/account/session.rs index 092678a..ba0a8c9 100644 --- a/api/src/account/session.rs +++ b/api/src/account/session.rs @@ -1,14 +1,14 @@ -use actix_web::cookie::{time::Duration, Cookie}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use redis::{AsyncCommands, RedisResult}; -use tokio::task; -use uuid::Uuid; +use super::{csprng, hash, verify_hash}; use crate::{ db::redis_async_connection, - error::{Error, ApiResult}, + error::{ApiResult, Error}, }; -use super::{csprng, hash, verify_hash}; +use actix_web::cookie::{Cookie, time::Duration}; +use chrono::{DateTime, Utc}; +use redis::{AsyncCommands, RedisResult}; +use serde::{Deserialize, Serialize}; +use tokio::task; +use uuid::Uuid; const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours pub const SESSION_COOKIE_NAME: &str = "session"; @@ -17,22 +17,22 @@ pub const SESSION_EXPIRATION_COOKIE_NAME: &str = "session_expiration"; #[derive(Debug, Serialize, Deserialize)] pub struct Session { pub session_id: String, - pub id: Uuid, + pub user_id: Uuid, pub ip_address: String, #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option>, } impl Session { - pub fn default(id: &Uuid, ip_address: &str) -> Self { - Self::new(64, id, ip_address, Some(DEFAULT_SESSION_TTL)) + pub fn default(user_id: &Uuid, ip_address: &str) -> Self { + Self::new(64, user_id, ip_address, Some(DEFAULT_SESSION_TTL)) } - pub fn new(take: usize, id: &Uuid, ip_address: &str, ttl: Option) -> Self { + pub fn new(take: usize, user_id: &Uuid, ip_address: &str, ttl: Option) -> Self { let now = Utc::now(); Self { session_id: csprng(take), - id: id.clone(), + user_id: user_id.clone(), ip_address: hash(&ip_address).unwrap(), expires_at: match ttl { Some(ttl) => Some(now + chrono::Duration::seconds(ttl)), @@ -79,7 +79,7 @@ impl Session { ); }; }); - session = Session::default(&session.id, ip_address); + session = Session::default(&session.user_id, ip_address); session.store().await?; Ok(session) } @@ -120,8 +120,8 @@ impl Session { if let Ok(environment) = std::env::var("ENVIRONMENT") { if environment == "development" || environment == "dev" { log::trace!( - "Session cookie [ID: {}]: {}", - self.id, + "Session cookie [User ID: {}]: {}", + self.user_id, self.session_id ); cookie.set_secure(false); @@ -148,8 +148,8 @@ impl Session { if let Ok(environment) = std::env::var("ENVIRONMENT") { if environment == "development" || environment == "dev" { log::trace!( - "Session expiration cookie [ID: {}]: {}", - self.id, + "Session expiration cookie [User ID: {}]: {}", + self.user_id, self.session_id ); cookie.set_secure(false); diff --git a/api/src/airports/mod.rs b/api/src/airports/mod.rs index 6fbb137..8dc27fa 100644 --- a/api/src/airports/mod.rs +++ b/api/src/airports/mod.rs @@ -1,5 +1,5 @@ -mod model; -mod routes; +pub mod model; +pub mod routes; pub use model::*; pub use routes::init_routes; diff --git a/api/src/airports/model/airport.rs b/api/src/airports/model/airport.rs index 21425db..850483c 100644 --- a/api/src/airports/model/airport.rs +++ b/api/src/airports/model/airport.rs @@ -1,20 +1,22 @@ -use std::collections::HashMap; -use std::str::FromStr; +use crate::airports::{ + AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication, + UpdateRunway, +}; +use crate::db; +use crate::error::{ApiResult, Error}; +use crate::metars::Metar; use chrono::{DateTime, Utc}; use futures_util::try_join; use reqwest::Client; use serde::{Deserialize, Serialize}; use sqlx::{Postgres, QueryBuilder}; -use crate::airports::{ - AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication, UpdateRunway, -}; -use crate::db; -use crate::error::{ApiResult, Error}; -use crate::metars::Metar; +use std::collections::HashMap; +use std::str::FromStr; +use utoipa::{IntoParams, ToSchema}; const TABLE_NAME: &str = "airports"; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct Airport { pub icao: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -40,7 +42,8 @@ pub struct Airport { pub latest_metar: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +#[into_params(parameter_in = Query)] pub struct AirportQuery { pub page: Option, pub limit: Option, @@ -75,7 +78,7 @@ impl Default for AirportQuery { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct Bounds { pub north_east_lat: f32, pub north_east_lon: f32, @@ -125,7 +128,7 @@ struct AirportRow { pub metar_observation_time: Option>, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct UpdateAirport { pub icao: Option, pub iata: Option, diff --git a/api/src/airports/model/airport_category.rs b/api/src/airports/model/airport_category.rs index c43e833..df15f49 100644 --- a/api/src/airports/model/airport_category.rs +++ b/api/src/airports/model/airport_category.rs @@ -1,8 +1,9 @@ +use serde::{Deserialize, Serialize}; use std::fmt::Display; use std::str::FromStr; -use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub enum AirportCategory { #[serde(rename = "small_airport")] Small, diff --git a/api/src/airports/model/communication.rs b/api/src/airports/model/communication.rs index 6af11f7..b5bcef2 100644 --- a/api/src/airports/model/communication.rs +++ b/api/src/airports/model/communication.rs @@ -1,13 +1,14 @@ -use std::collections::HashMap; -use serde::{Deserialize, Serialize}; -use sqlx::{Postgres, QueryBuilder}; -use uuid::Uuid; use crate::db; use crate::error::ApiResult; +use serde::{Deserialize, Serialize}; +use sqlx::{Postgres, QueryBuilder}; +use std::collections::HashMap; +use utoipa::ToSchema; +use uuid::Uuid; const TABLE_NAME: &str = "communications"; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct Communication { pub id: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -27,7 +28,7 @@ pub struct CommunicationRow { pub phone: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateCommunication { #[serde(skip_serializing_if = "Option::is_none")] pub icao: Option, diff --git a/api/src/airports/model/runway.rs b/api/src/airports/model/runway.rs index 5981218..223f114 100644 --- a/api/src/airports/model/runway.rs +++ b/api/src/airports/model/runway.rs @@ -1,13 +1,14 @@ -use std::collections::HashMap; -use serde::{Deserialize, Serialize}; -use sqlx::{Postgres, QueryBuilder}; -use uuid::Uuid; use crate::db; use crate::error::ApiResult; +use serde::{Deserialize, Serialize}; +use sqlx::{Postgres, QueryBuilder}; +use std::collections::HashMap; +use utoipa::ToSchema; +use uuid::Uuid; const TABLE_NAME: &str = "runways"; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct Runway { #[serde(rename = "id")] pub runway_id: String, @@ -26,7 +27,7 @@ pub struct RunwayRow { pub surface: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateRunway { #[serde(skip_serializing_if = "Option::is_none")] pub icao: Option, diff --git a/api/src/airports/routes.rs b/api/src/airports/routes.rs index b4594c5..ded3317 100644 --- a/api/src/airports/routes.rs +++ b/api/src/airports/routes.rs @@ -1,16 +1,39 @@ use futures_util::stream::StreamExt as _; -use crate::{ - airports::Airport, - db::Paged, - account::{Auth, verify_role}, - AppState, -}; -use actix_multipart::Multipart; -use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError}; use crate::airports::{AirportQuery, UpdateAirport}; use crate::users::ADMIN_ROLE; +use crate::{ + AppState, + account::{Auth, verify_role}, + airports::Airport, + db::Paged, +}; +use actix_multipart::Multipart; +use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web}; +use utoipa::ToSchema; +use utoipa_actix_web::scope; +use utoipa_actix_web::service_config::ServiceConfig; +#[derive(ToSchema)] +#[allow(unused)] +struct UploadedFile { + #[schema(value_type = String, format = Binary)] + file: Vec, +} + +#[utoipa::path( + tag = "Airports", + request_body( + content = UploadedFile, content_type = "multipart/form-data" + ), + responses( + (status = 200, description = "Successful import"), + (status = 401, description = ""), + ), + security( + ("session_auth" = []) + ) +)] #[post("/import")] async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse { if let Err(err) = verify_role(&auth, ADMIN_ROLE) { @@ -53,6 +76,15 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse { HttpResponse::Ok().finish() } +#[utoipa::path( + tag = "Airports", + params( + AirportQuery + ), + responses( + (status = 200, description = "", body = [Airport]), + ), +)] #[get("")] async fn get_airports(data: web::Data, req: HttpRequest) -> HttpResponse { let mut query = match web::Query::::from_query(req.query_string()) { @@ -87,6 +119,13 @@ async fn get_airports(data: web::Data, req: HttpRequest) -> HttpRespon } } +#[utoipa::path( + tag = "Airports", + responses( + (status = 200, description = "", body = Airport), + (status = 404, description = ""), + ), +)] #[get("/{icao}")] async fn get_airport( data: web::Data, @@ -108,6 +147,17 @@ async fn get_airport( } } +#[utoipa::path( + tag = "Airports", + responses( + (status = 200, description = "", body = Airport), + (status = 401, description = ""), + (status = 409, description = ""), + ), + security( + ("session_auth" = []) + ) +)] #[post("")] async fn insert_airport(airport: web::Json, auth: Auth) -> HttpResponse { let _ = match verify_role(&auth, ADMIN_ROLE) { @@ -123,6 +173,16 @@ async fn insert_airport(airport: web::Json, auth: Auth) -> HttpResponse } } +#[utoipa::path( + tag = "Airports", + responses( + (status = 200, description = "", body = Airport), + (status = 401, description = ""), + ), + security( + ("session_auth" = []) + ) +)] #[put("/{icao}")] async fn update_airport( icao: web::Path, @@ -142,6 +202,16 @@ async fn update_airport( } } +#[utoipa::path( + tag = "Airports", + responses( + (status = 201, description = ""), + (status = 401, description = ""), + ), + security( + ("session_auth" = []) + ) +)] #[delete("")] async fn delete_airports(auth: Auth) -> HttpResponse { let _ = match verify_role(&auth, ADMIN_ROLE) { @@ -157,6 +227,16 @@ async fn delete_airports(auth: Auth) -> HttpResponse { } } +#[utoipa::path( + tag = "Airports", + responses( + (status = 201, description = ""), + (status = 401, description = ""), + ), + security( + ("session_auth" = []) + ) +)] #[delete("/{icao}")] async fn delete_airport(icao: web::Path, auth: Auth) -> HttpResponse { let _ = match verify_role(&auth, ADMIN_ROLE) { @@ -172,9 +252,9 @@ async fn delete_airport(icao: web::Path, auth: Auth) -> HttpResponse { } } -pub fn init_routes(config: &mut web::ServiceConfig) { +pub fn init_routes(config: &mut ServiceConfig) { config.service( - web::scope("airports") + scope::scope("/airports") .service(import_airports) .service(get_airports) .service(get_airport) diff --git a/api/src/db/mod.rs b/api/src/db/mod.rs index 3f5ee21..020b80c 100644 --- a/api/src/db/mod.rs +++ b/api/src/db/mod.rs @@ -1,11 +1,11 @@ use crate::error::ApiResult; -use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection, RedisResult}; -use s3::{Bucket, Region, creds::Credentials, BucketConfiguration, request::ResponseData}; +use redis::{Client as RedisClient, RedisResult, aio::MultiplexedConnection as RedisConnection}; +use s3::{Bucket, BucketConfiguration, Region, creds::Credentials, request::ResponseData}; use serde::{Deserialize, Serialize}; +use sqlx::postgres::PgPoolOptions; +use sqlx::{Pool, Postgres}; use std::sync::OnceLock; use std::time::Duration; -use sqlx::{Pool, Postgres}; -use sqlx::postgres::PgPoolOptions; static POOL: OnceLock> = OnceLock::new(); static REDIS: OnceLock = OnceLock::new(); @@ -169,9 +169,3 @@ pub struct Paged { pub limit: u32, pub total: i64, } - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub struct Coordinate { - pub lon: f64, - pub lat: f64, -} diff --git a/api/src/error.rs b/api/src/error.rs index a0db0e4..ac19e10 100644 --- a/api/src/error.rs +++ b/api/src/error.rs @@ -204,3 +204,27 @@ impl From for Error { Error::new(500, error.to_string()) } } + +impl From for Error { + fn from(error: lettre::address::AddressError) -> Self { + Error::new(500, error.to_string()) + } +} + +impl From for Error { + fn from(error: lettre::error::Error) -> Self { + Error::new(500, error.to_string()) + } +} + +impl From for Error { + fn from(error: lettre::transport::smtp::Error) -> Self { + Error::new(500, error.to_string()) + } +} + +impl From for Error { + fn from(error: String) -> Self { + Self::new(500, error) + } +} diff --git a/api/src/main.rs b/api/src/main.rs index e9821f5..6428d3e 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,12 +1,16 @@ -use std::env; -use std::time::Duration; +use crate::account::hash; +use crate::users::{ADMIN_ROLE, User}; use actix_cors::Cors; use actix_web::{App, HttpServer, middleware::Logger, web}; use dotenv::from_filename; use reqwest::Certificate; +use std::env; +use std::time::Duration; +use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}; +use utoipa::openapi::{Contact, SecurityRequirement}; +use utoipa_actix_web::{AppExt, scope}; +use utoipa_swagger_ui::SwaggerUi; use uuid::Uuid; -use crate::account::hash; -use crate::users::{User, ADMIN_ROLE}; mod account; mod airports; @@ -14,6 +18,7 @@ mod db; mod error; mod metars; mod scheduler; +mod smtp; mod system; mod users; @@ -91,18 +96,49 @@ async fn main() -> Result<(), Box> { .allow_any_header() .supports_credentials() .max_age(3600); - App::new() + let (app, mut api) = App::new() .wrap(cors) .wrap(Logger::default()) .app_data(web::Data::new(state.clone())) + .into_utoipa_app() .service( - web::scope("api") + scope::scope("/api") .configure(airports::init_routes) .configure(metars::init_routes) .configure(account::init_routes) .configure(users::init_routes) .configure(system::init_routes), ) + .split_for_parts(); + + let contact_name = env::var("API_CONTACT_NAME").unwrap(); + let contact_url = env::var("EXTERNAL_URL").unwrap(); + let contact_email = env::var("API_CONTACT_EMAIL").unwrap(); + let version = env::var("CARGO_PKG_VERSION").unwrap(); + + api.info.title = "Aviation Data".to_string(); + api.info.description = None; + api.info.terms_of_service = None; + api.info.contact = Some( + Contact::builder() + .name(Some(contact_name)) + .url(Some(format!("{}/support", contact_url))) + .email(Some(contact_email)) + .build(), + ); + api.info.license = None; + api.info.version = version; + + let session_scheme = SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new("session"))); + let mut components = api.components.take().unwrap_or_default(); + components + .security_schemes + .insert("session_auth".to_string(), session_scheme); + api.components = Some(components); + // api.security = Some(vec![SecurityRequirement::new("session_auth", [""])]); + api.security = Some(vec![SecurityRequirement::default()]); + + app.service(SwaggerUi::new("/swagger/{_:.*}").url("/api-docs/openapi.json", api)) }) .bind(format!("{}:{}", host, port)) { diff --git a/api/src/metars/metar_check.rs b/api/src/metars/metar_check.rs index 8c15fac..5b9dbb8 100644 --- a/api/src/metars/metar_check.rs +++ b/api/src/metars/metar_check.rs @@ -1,9 +1,9 @@ -use chrono::{DateTime, Utc}; -use redis::{AsyncCommands, RedisResult}; -use serde::{Deserialize, Serialize}; use crate::db::redis_async_connection; use crate::error::ApiResult; use crate::metars::Metar; +use chrono::{DateTime, Utc}; +use redis::{AsyncCommands, RedisResult}; +use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] pub struct MetarCheck { diff --git a/api/src/metars/mod.rs b/api/src/metars/mod.rs index 40caa39..41f14b8 100644 --- a/api/src/metars/mod.rs +++ b/api/src/metars/mod.rs @@ -2,6 +2,6 @@ mod metar_check; mod model; mod routes; -pub use model::*; pub use metar_check::*; +pub use model::*; pub use routes::init_routes; diff --git a/api/src/metars/model.rs b/api/src/metars/model.rs index 37f11ad..9782066 100644 --- a/api/src/metars/model.rs +++ b/api/src/metars/model.rs @@ -1,20 +1,21 @@ +use crate::airports::{Airport, UpdateAirport}; +use crate::db::redis_async_connection; use crate::error::Error; -use crate::{error::ApiResult, db}; +use crate::metars::MetarCheck; +use crate::{db, error::ApiResult}; use chrono::{DateTime, Datelike, NaiveDate, Utc}; +use redis::{AsyncCommands, RedisResult}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::env; use std::fmt::Display; use std::str::FromStr; -use redis::{AsyncCommands, RedisResult}; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use crate::airports::{Airport, UpdateAirport}; -use crate::db::redis_async_connection; -use crate::metars::MetarCheck; +use utoipa::ToSchema; const TABLE_NAME: &str = "metars"; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct Metar { pub icao: String, pub raw_text: String, @@ -60,7 +61,7 @@ pub struct Metar { pub density_altitude: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub enum ReportModifier { #[serde(rename = "AUTO")] Auto, @@ -88,7 +89,7 @@ impl Display for ReportModifier { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct RunwayVisualRange { pub runway: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -110,7 +111,7 @@ impl Default for RunwayVisualRange { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub enum AutomatedStationType { #[serde(rename = "AO1")] WithoutPrecipitationDiscriminator, @@ -141,7 +142,7 @@ impl Display for AutomatedStationType { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct Remarks { #[serde(skip_serializing_if = "Option::is_none")] pub peak_wind: Option, @@ -165,7 +166,7 @@ pub struct Remarks { pub sky_condition_at_secondary_location_not_available: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct PeakWind { pub degrees: i32, pub speed: i32, @@ -190,7 +191,7 @@ impl Default for Remarks { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct SkyCondition { pub sky_cover: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -209,7 +210,7 @@ impl Default for SkyCondition { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub enum FlightCategory { VFR, MVFR, @@ -1134,8 +1135,8 @@ impl Metar { #[cfg(test)] mod tests { - use chrono::NaiveDateTime; use super::*; + use chrono::NaiveDateTime; #[test] fn test_parse_time() { diff --git a/api/src/metars/routes.rs b/api/src/metars/routes.rs index 7218ed7..0fb9eba 100644 --- a/api/src/metars/routes.rs +++ b/api/src/metars/routes.rs @@ -1,18 +1,34 @@ +use crate::AppState; use crate::metars::Metar; -use actix_web::{get, web, HttpResponse, HttpRequest}; +use actix_web::{HttpRequest, HttpResponse, get, web}; use log::error; use serde::{Deserialize, Serialize}; -use crate::AppState; +use utoipa::{IntoParams, ToSchema}; +use utoipa_actix_web::service_config::ServiceConfig; -#[derive(Debug, Serialize, Deserialize)] -struct FindAllParameters { +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +#[into_params(parameter_in = Query)] +struct MetarQuery { icaos: Option, } -#[get("metars")] +#[utoipa::path( + tag = "METARs", + params( + MetarQuery, + ), + responses( + (status = 200, description = "", body = [Metar]), + ), +)] +#[get("/metars")] async fn find_all(data: web::Data, req: HttpRequest) -> HttpResponse { - let parameters = web::Query::::from_query(req.query_string()).unwrap(); + let parameters = web::Query::::from_query(req.query_string()).unwrap(); let icao_option = ¶meters.icaos; + if let None = icao_option { + let empty_metars: Vec = vec![]; + return HttpResponse::Ok().json(empty_metars); + } let icao_string = match icao_option { Some(i) => i, None => return HttpResponse::UnprocessableEntity().body("Missing icaos parameter"), @@ -30,6 +46,6 @@ async fn find_all(data: web::Data, req: HttpRequest) -> HttpResponse { HttpResponse::Ok().json(metars) } -pub fn init_routes(config: &mut web::ServiceConfig) { +pub fn init_routes(config: &mut ServiceConfig) { config.service(find_all); } diff --git a/api/src/smtp/mod.rs b/api/src/smtp/mod.rs new file mode 100644 index 0000000..fb61f80 --- /dev/null +++ b/api/src/smtp/mod.rs @@ -0,0 +1,103 @@ +use crate::error::ApiResult; +use chrono::{Datelike, Utc}; +use handlebars::Handlebars; +use lettre::message::header::ContentType; +use lettre::message::{Mailbox, MultiPart, SinglePart}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Address, Message, SmtpTransport, Transport}; +use serde::Serialize; +use std::env; +use std::sync::OnceLock; + +static MAILER: OnceLock = OnceLock::new(); +static FROM_ADDRESS: OnceLock = OnceLock::new(); +static REGISTRY: OnceLock = OnceLock::new(); + +fn mailer() -> &'static SmtpTransport { + MAILER.get_or_init(|| { + let server = env::var("SMTP_SERVER").expect("SMTP_SERVER missing"); + let username = env::var("SMTP_USERNAME").expect("SMTP_USERNAME missing"); + let password = env::var("SMTP_PASSWORD").expect("SMTP_PASSWORD missing"); + let creds = Credentials::new(username, password); + SmtpTransport::relay(&server) + .expect("invalid SMTP_SERVER") + .credentials(creds) + .build() + }) +} + +fn from_address() -> &'static Mailbox { + FROM_ADDRESS.get_or_init(|| { + let raw = env::var("SMTP_FROM").expect("SMTP_FROM missing"); + let addr = raw.parse().expect("SMTP_FROM invalid"); + Mailbox::new(Some("Aviation Data".into()), addr) + }) +} + +fn registry() -> &'static Handlebars<'static> { + REGISTRY.get_or_init(|| Handlebars::new()) +} + +#[derive(Serialize)] +struct PasswordResetCtx { + logo_url: String, + link: String, + domain: String, + year: i32, +} + +pub fn send_password_reset(to: &str, token: &str) -> ApiResult<()> { + let base_url = env::var("EXTERNAL_URL")?.trim_end_matches('/').to_string(); + let link = format!("{base_url}/profile/reset?token={token}"); + let subject = "Reset your password"; + + let plain = format!( + "Hello,\n\n\ + We received a password reset request. Click the link below:\n\n\ + {link}\n\n\ + This link expires in 24 hours. If you didn't request this, please ignore.\n\n\ + Cheers,\n\ + \tAviation Data", + link = link + ); + + let ctx = PasswordResetCtx { + logo_url: format!("{}/logo.svg", base_url), + link: link.clone(), + domain: base_url, + year: Utc::now().year(), + }; + + let template_html = include_str!("../.././templates/password_reset.html"); + let html = registry().render_template(template_html, &ctx).unwrap(); + + send_email(to, subject, plain, html) +} + +pub fn send_email(to: &str, subject: &str, header: String, html: String) -> ApiResult<()> { + let to_address = to.parse::
()?; + let to_mailbox = Mailbox::new(None, to_address); + + // Build the email + let email = Message::builder() + .from(from_address().clone()) + .to(to_mailbox) + .subject(subject) + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(header), + ) + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html), + ), + )?; + + // Send the email + mailer().send(&email)?; + Ok(()) +} diff --git a/api/src/system/mod.rs b/api/src/system/mod.rs index 4042fc6..1a0b758 100644 --- a/api/src/system/mod.rs +++ b/api/src/system/mod.rs @@ -1,13 +1,22 @@ -use std::env; -use actix_web::{get, web, HttpResponse}; +use actix_web::{HttpResponse, get}; use serde::{Deserialize, Serialize}; +use std::env; +use utoipa::ToSchema; +use utoipa_actix_web::scope; +use utoipa_actix_web::service_config::ServiceConfig; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SystemInfo { version: String, healthy: bool, } +#[utoipa::path( + tag = "System", + responses( + (status = 200, description = "Successful system info"), + ) +)] #[get("/info")] async fn info() -> HttpResponse { let mut healthy = true; @@ -24,6 +33,6 @@ async fn info() -> HttpResponse { HttpResponse::Ok().json(info) } -pub fn init_routes(config: &mut web::ServiceConfig) { - config.service(web::scope("/system").service(info)); +pub fn init_routes(config: &mut ServiceConfig) { + config.service(scope::scope("/system").service(info)); } diff --git a/api/src/users/model.rs b/api/src/users/model.rs index 22b8e05..0501d7f 100644 --- a/api/src/users/model.rs +++ b/api/src/users/model.rs @@ -1,15 +1,17 @@ +use crate::db; +use crate::{account::hash, error::ApiResult}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_json::json; use sqlx::{Postgres, QueryBuilder}; +use utoipa::ToSchema; use uuid::Uuid; -use crate::{account::hash, error::ApiResult}; -use crate::db; pub const ADMIN_ROLE: &str = "ADMIN"; pub const USER_ROLE: &str = "USER"; const TABLE_NAME: &str = "users"; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct RegisterRequest { pub email: String, pub password: String, @@ -35,13 +37,21 @@ impl RegisterRequest { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] +#[schema( + example = json!( + { + "email": "user@example.com", + "password": "changeme" + } + ) +)] pub struct LoginRequest { pub email: String, pub password: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct UserResponse { pub id: Uuid, pub role: String, @@ -65,7 +75,7 @@ impl From for UserResponse { } } -#[derive(Debug, Deserialize, sqlx::FromRow)] +#[derive(Debug, Deserialize, sqlx::FromRow, ToSchema)] pub struct UpdateUser { pub email: Option, pub email_verified: Option, @@ -167,13 +177,13 @@ impl User { "#, TABLE_NAME )) - .bind(id) - .fetch_optional(pool) - .await - .unwrap_or_else(|err| { - log::error!("Unable to find user by id '{}': {}", id, err); - None - }); + .bind(id) + .fetch_optional(pool) + .await + .unwrap_or_else(|err| { + log::error!("Unable to find user by id '{}': {}", id, err); + None + }); user } diff --git a/api/src/users/routes.rs b/api/src/users/routes.rs index eb4b7cc..9700f7c 100644 --- a/api/src/users/routes.rs +++ b/api/src/users/routes.rs @@ -152,7 +152,9 @@ // } // } -pub fn init_routes(_config: &mut actix_web::web::ServiceConfig) { +use utoipa_actix_web::service_config::ServiceConfig; + +pub fn init_routes(_config: &mut ServiceConfig) { // config.service( // web::scope("users") // .service(get_favorites) diff --git a/api/templates/password_reset.html b/api/templates/password_reset.html new file mode 100644 index 0000000..fedf627 --- /dev/null +++ b/api/templates/password_reset.html @@ -0,0 +1,53 @@ + + + + + + Reset your password + + + +
+
+ + +
+ Aviation Data Logo +

Aviation Data

+

Your source for aviation data

+
+ + +
+

Reset Your Password

+

We received a request to reset your password. Click the button below to choose a new one:

+ +

If you didn’t request this reset, you can safely ignore this email.

+

Cheers,
The Aviation Data Team

+
+
+ + + +
+ + \ No newline at end of file diff --git a/bruno/Users/Get Profile.bru b/bruno/Users/Get Profile.bru index 3c43de0..6c02c61 100644 --- a/bruno/Users/Get Profile.bru +++ b/bruno/Users/Get Profile.bru @@ -1,7 +1,7 @@ meta { name: Get Profile type: http - seq: 6 + seq: 7 } get { diff --git a/bruno/Users/Refresh Session.bru b/bruno/Users/Refresh Session.bru index 10fdd75..23d9565 100644 --- a/bruno/Users/Refresh Session.bru +++ b/bruno/Users/Refresh Session.bru @@ -1,7 +1,7 @@ meta { name: Refresh Session type: http - seq: 5 + seq: 6 } get { diff --git a/bruno/Users/Reset Password.bru b/bruno/Users/Reset Password.bru new file mode 100644 index 0000000..ca05566 --- /dev/null +++ b/bruno/Users/Reset Password.bru @@ -0,0 +1,11 @@ +meta { + name: Reset Password + type: http + seq: 5 +} + +post { + url: {{API_URL}}/account/password/reset + body: none + auth: none +} diff --git a/nginx/templates/nossl.conf.template b/nginx/templates/nossl.conf.template index 5b735b1..e36afe3 100644 --- a/nginx/templates/nossl.conf.template +++ b/nginx/templates/nossl.conf.template @@ -12,6 +12,22 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location = /api-docs/openapi.json { + proxy_pass http://${NGINX_INTERNAL_HOST}:${API_PORT}/api-docs/openapi.json; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /swagger/ { + proxy_pass http://${NGINX_INTERNAL_HOST}:${API_PORT}/swagger/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /minio/ { proxy_pass http://${NGINX_INTERNAL_HOST}:${MINIO_INTERNAL_PORT}/; proxy_set_header Host $host; diff --git a/nginx/templates/ssl.conf.template b/nginx/templates/ssl.conf.template index 874f58b..a4fe525 100644 --- a/nginx/templates/ssl.conf.template +++ b/nginx/templates/ssl.conf.template @@ -32,6 +32,22 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location = /api-docs/openapi.json { + proxy_pass http://${NGINX_INTERNAL_HOST}:${API_PORT}/api-docs/openapi.json; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /swagger/ { + proxy_pass http://${NGINX_INTERNAL_HOST}:${API_PORT}/swagger/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /minio/ { proxy_pass http://${NGINX_INTERNAL_HOST}:${MINIO_INTERNAL_PORT}/; proxy_set_header Host $host; diff --git a/ui/index.html b/ui/index.html index feddfe0..7e5f5d0 100644 --- a/ui/index.html +++ b/ui/index.html @@ -10,6 +10,7 @@
+ diff --git a/ui/src/components/AirportDrawer/CommunicationTable.tsx b/ui/src/components/AirportDrawer/CommunicationTable.tsx index 9ef15e2..b1eb2c9 100644 --- a/ui/src/components/AirportDrawer/CommunicationTable.tsx +++ b/ui/src/components/AirportDrawer/CommunicationTable.tsx @@ -17,11 +17,11 @@ export function CommunicationTable({ communications }: { communications: Communi ID Name - Frequencies (MHz) + MHz Phone {rows} ); -} \ No newline at end of file +} diff --git a/ui/src/components/AirportDrawer/RunwayTable.tsx b/ui/src/components/AirportDrawer/RunwayTable.tsx index 0505228..434ac1a 100644 --- a/ui/src/components/AirportDrawer/RunwayTable.tsx +++ b/ui/src/components/AirportDrawer/RunwayTable.tsx @@ -24,4 +24,4 @@ export function RunwayTable({ runways }: { runways: Runway[] }) { {rows} ); -} \ No newline at end of file +} diff --git a/ui/src/components/AirportDrawer/index.tsx b/ui/src/components/AirportDrawer/index.tsx index ef94c3c..44e57e6 100644 --- a/ui/src/components/AirportDrawer/index.tsx +++ b/ui/src/components/AirportDrawer/index.tsx @@ -151,10 +151,10 @@ function AirportInfoRow({ style, children }: { style?: CSSProperties; children: ); } -function AirportInfo({ map, airport }: { map: LeafletMap, airport: Airport }) { +function AirportInfo({ map, airport }: { map: LeafletMap; airport: Airport }) { function goToLocation(map: LeafletMap, latitude: number, longitude: number) { - if (!map) return - map.setView([latitude, longitude], map.getZoom()) + if (!map) return; + map.setView([latitude, longitude], map.getZoom()); } return ( @@ -171,9 +171,11 @@ function AirportInfo({ map, airport }: { map: LeafletMap, airport: Airport }) { - { - goToLocation(map, airport.latitude, airport.longitude) - }}> + { + goToLocation(map, airport.latitude, airport.longitude); + }} + > @@ -181,9 +183,7 @@ function AirportInfo({ map, airport }: { map: LeafletMap, airport: Airport }) { {airport.runways != null && airport.runways.length > 0 && ( - - Runways - + Runways @@ -191,9 +191,7 @@ function AirportInfo({ map, airport }: { map: LeafletMap, airport: Airport }) { )} {airport.communications != null && airport.communications.length > 0 && ( - - Communication - + Communication diff --git a/ui/src/components/CustomControl.tsx b/ui/src/components/CustomControl.tsx index e405ba1..40485f3 100644 --- a/ui/src/components/CustomControl.tsx +++ b/ui/src/components/CustomControl.tsx @@ -20,7 +20,7 @@ export function CustomControl({ position = 'bottomright', onClick, active = fals const ctrl = new L.Control({ position }); ctrl.onAdd = () => { return L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control'); - } + }; ctrl.addTo(map); controlRef.current = ctrl; @@ -28,6 +28,7 @@ export function CustomControl({ position = 'bottomright', onClick, active = fals // @ts-expect-error ctrl is a L.Control const container = (ctrl as unknown)._container as HTMLElement; rootRef.current = createRoot(container); + L.DomEvent.disableClickPropagation(container); return () => { if (rootRef.current) { @@ -45,7 +46,7 @@ export function CustomControl({ position = 'bottomright', onClick, active = fals href={'#'} title={title} className={active ? 'active' : ''} - onClick={e => { + onClick={(e) => { e.preventDefault(); e.stopPropagation(); onClick(); @@ -59,7 +60,7 @@ export function CustomControl({ position = 'bottomright', onClick, active = fals > {children} - ) + ); } }, [onClick, active, title, children]); diff --git a/ui/src/components/GroupControl.tsx b/ui/src/components/GroupControl.tsx index 5a332bb..7f57c9e 100644 --- a/ui/src/components/GroupControl.tsx +++ b/ui/src/components/GroupControl.tsx @@ -34,6 +34,7 @@ export function GroupControl({ position = 'bottomright', buttons }: GroupControl // @ts-expect-error ctrl is a L.Control const container = (ctrl as unknown)._container as HTMLElement; rootRef.current = createRoot(container); + L.DomEvent.disableClickPropagation(container); return () => { ctrl.remove(); @@ -48,10 +49,10 @@ export function GroupControl({ position = 'bottomright', buttons }: GroupControl {buttons.map((b, i) => ( { + onClick={(e) => { e.preventDefault(); e.stopPropagation(); b.onClick(); diff --git a/ui/src/components/LocateControl.tsx b/ui/src/components/LocateControl.tsx index 9e52b44..f25b70d 100644 --- a/ui/src/components/LocateControl.tsx +++ b/ui/src/components/LocateControl.tsx @@ -7,7 +7,7 @@ export function LocateControl() { function handleClick() { if (!navigator.geolocation) { - alert('Geolocation is not supported by your browser'); + console.warn('Geolocation is not supported by your browser'); return; } navigator.geolocation.getCurrentPosition( @@ -17,15 +17,14 @@ export function LocateControl() { map.setView([latitude, longitude], map.getZoom()); }, (err) => { - console.error(err); - alert('Unable to retrieve your location'); + console.warn('Unable to retrieve your location', err); } ); } return ( - + ); -} \ No newline at end of file +} diff --git a/ui/src/components/context/UserProvider.tsx b/ui/src/components/context/UserProvider.tsx index a0e4e12..681c750 100644 --- a/ui/src/components/context/UserProvider.tsx +++ b/ui/src/components/context/UserProvider.tsx @@ -3,20 +3,36 @@ import { UserContext } from './UserContext.tsx'; import { profile } from '@lib/account.ts'; import { User } from '@lib/account.types.ts'; import { Center, Loader } from '@mantine/core'; +import Cookies from 'js-cookie'; + +const sessionExpirationName = 'session_expiration'; export function UserProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(undefined); const [loading, setLoading] = useState(true); useEffect(() => { - profile().then((refreshUser) => { - if (refreshUser) { - setUser(refreshUser); + const sessionExpiration = Cookies.get(sessionExpirationName); + + if (sessionExpiration != undefined) { + const date = new Date(parseInt(sessionExpiration) * 1000); + const now = new Date(); + if (date > now) { + profile().then((refreshUser) => { + if (refreshUser) { + setUser(refreshUser); + } else { + setUser(undefined); + } + setLoading(false); + }); } else { - setUser(undefined); + Cookies.remove(sessionExpirationName); + setLoading(false); } + } else { setLoading(false); - }); + } }, []); return (