diff --git a/Makefile b/Makefile index 78f95d8..ee5c4ab 100644 --- a/Makefile +++ b/Makefile @@ -124,7 +124,7 @@ push: image=${registry}/aviation-${folder}:${version} push: ## Build and push a specific docker image from a folder docker buildx create \ --use \ - --name aviation-builder \ + --name default-builder \ --platform ${platform} || true; \ docker buildx build \ -f ${folder}/Dockerfile \ diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..6724cae --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,91 @@ +# https://taskfile.dev + +version: '3' + +dotenv: ['.env.local', '.env'] + +tasks: + default: + cmds: + - task: docker-up + silent: true + + test: + cmds: + - task: docker-backend + - task: dev-servers + + dev-servers: + deps: + - task: run-api + - task: run-ui + + # API Commands + build-api: + dir: api + cmds: + - cargo build + format-api: + dir: api + cmds: + - cargo fmt + run-api: + dir: api + cmds: + - cargo run + silent: true + + # UI Commands + build-ui: + dir: ui + cmds: + - npm run build + format-ui: + dir: ui + cmds: + - npm run format + clean-ui: + dir: ui + cmds: + - rm -rf node_modules dist stats.html + run-ui: + dir: ui + cmds: + - npm run dev + silent: true + + # Docker Commands + docker-backend: + cmds: + - docker compose --profile backend up -d + docker-up: + cmds: + - docker compose --profile backend --profile api up -d + docker-down: + cmds: + - docker compose --profile backend --profile api down + docker-clean: + cmds: + - docker compose --profile backend --profile api down -v + docker-refresh: + cmds: + - task: docker-clean + - task: docker-up + docker-build: + cmds: + - docker compose build + + psql: + cmds: + - docker exec -it aviation-postgres psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -P pager=off + + cert: + cmds: + - ./scripts/generate_ca_cert.sh + - ./scripts/generate_server_cert.sh ${TLS_HOST} nginx + - ./scripts/generate_server_cert.sh ${API_HOST} api + silent: true + cert-clean: + cmds: + - rm -rf ./data/certificates + silent: true diff --git a/api/Cargo.lock b/api/Cargo.lock index 8e1cec1..03ac794 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -64,7 +64,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand 0.9.1", + "rand 0.9.2", "sha1", "smallvec", "tokio", @@ -158,7 +158,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2", + "socket2 0.5.9", "tokio", "tracing", ] @@ -220,7 +220,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2", + "socket2 0.5.9", "time", "tracing", "url", @@ -295,12 +295,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -362,7 +356,7 @@ dependencies = [ [[package]] name = "api" -version = "0.1.2" +version = "0.1.3" dependencies = [ "actix-cors", "actix-multipart", @@ -378,7 +372,7 @@ dependencies = [ "handlebars", "lettre", "log", - "rand 0.9.1", + "rand 0.9.2", "rand_chacha 0.9.0", "redis", "regex", @@ -496,9 +490,9 @@ dependencies = [ [[package]] name = "backon" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd0b50b1b78dbadd44ab18b3c794e496f3a139abb9fbc27d9c94c4eebbb96496" +checksum = "592277618714fbcecda9a02ba7a8781f319d26532a88553bbacc77ba5d2b3a8d" dependencies = [ "fastrand", ] @@ -624,17 +618,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.0", ] [[package]] @@ -1092,9 +1085,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "flate2" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", "libz-rs-sys", @@ -1297,9 +1290,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "governor" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbe789d04bf14543f03c4b60cd494148aa79438c8440ae7d81a7778147745c3" +checksum = "444405bbb1a762387aa22dd569429533b54a1d8759d35d3b64cb39b0293eaa19" dependencies = [ "cfg-if", "dashmap", @@ -1312,7 +1305,7 @@ dependencies = [ "parking_lot", "portable-atomic", "quanta", - "rand 0.9.1", + "rand 0.9.2", "smallvec", "spinning_top", "web-time", @@ -1449,7 +1442,7 @@ checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" dependencies = [ "cfg-if", "libc", - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -1536,7 +1529,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.9", "tokio", "tower-service", "tracing", @@ -1615,18 +1608,23 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", "http 1.3.1", "http-body 1.0.1", "hyper 1.6.0", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.5.9", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1783,12 +1781,33 @@ dependencies = [ "serde", ] +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1862,9 +1881,9 @@ dependencies = [ [[package]] name = "lettre" -version = "0.11.16" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ffd14fa289730e3ad68edefdc31f603d56fe716ec38f2076bb7410e09147c2" +checksum = "5cb54db6ff7a89efac87dba5baeac57bb9ccd726b49a9b6f21fb92b3966aaf56" dependencies = [ "async-trait", "base64", @@ -1882,7 +1901,7 @@ dependencies = [ "nom", "percent-encoding", "quoted_printable", - "socket2", + "socket2 0.6.0", "tokio", "tokio-native-tls", "url", @@ -1890,9 +1909,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.172" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libm" @@ -1912,9 +1931,9 @@ dependencies = [ [[package]] name = "libz-rs-sys" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6489ca9bd760fe9642d7644e827b0c9add07df89857b0416ee15c1cc1a3b8c5a" +checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" dependencies = [ "zlib-rs", ] @@ -1960,9 +1979,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "maybe-async" @@ -2494,9 +2513,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -2551,9 +2570,9 @@ dependencies = [ [[package]] name = "redis" -version = "0.31.0" +version = "0.32.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bc1ea653e0b2e097db3ebb5b7f678be339620b8041f66b30a308c1d45d36a7f" +checksum = "7cd3650deebc68526b304898b192fa4102a4ef0b9ada24da096559cb60e0eef8" dependencies = [ "arc-swap", "backon", @@ -2571,7 +2590,7 @@ dependencies = [ "serde", "serde_json", "sha1_smol", - "socket2", + "socket2 0.6.0", "tokio", "tokio-util", "url", @@ -2588,9 +2607,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", @@ -2623,15 +2642,14 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.15" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64", "bytes", "encoding_rs", "futures-core", - "futures-util", "h2 0.4.10", "http 1.3.1", "http-body 1.0.1", @@ -2640,29 +2658,26 @@ dependencies = [ "hyper-rustls", "hyper-tls 0.6.0", "hyper-util", - "ipnet", "js-sys", "log", "mime", "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", - "system-configuration", "tokio", "tokio-native-tls", "tower", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-registry", ] [[package]] @@ -2822,15 +2837,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -2964,9 +2970,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" dependencies = [ "itoa", "memchr", @@ -3093,6 +3099,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "spin" version = "0.9.8" @@ -3123,9 +3139,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3a85280daca669cfd3bcb68a337882a8bc57ec882f72c5d13a430613a738e" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", @@ -3136,9 +3152,9 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64", "bytes", @@ -3172,9 +3188,9 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4200e0fde19834956d4252347c12a083bdcb237d7a1a1446bffd8768417dce" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", @@ -3185,9 +3201,9 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ceaa29cade31beca7129b6beeb05737f44f82dbe2a9806ecea5a7093d00b7" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", @@ -3204,16 +3220,15 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn", - "tempfile", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0afdd3aa7a629683c2d750c2df343025545087081ab5942593a5288855b1b7a7" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", @@ -3255,9 +3270,9 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0bedbe1bbb5e2615ef347a5e9d8cd7680fb63e77d9dafc0f29be15e53f1ebe6" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", @@ -3294,9 +3309,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", @@ -3538,20 +3553,22 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.45.0" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "slab", + "socket2 0.6.0", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3624,6 +3641,24 @@ dependencies = [ "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -3762,9 +3797,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" -version = "5.3.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435c6f69ef38c9017b4b4eea965dfb91e71e53d869e896db40d1cf2441dd75c0" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" dependencies = [ "indexmap", "serde", @@ -3785,9 +3820,9 @@ dependencies = [ [[package]] name = "utoipa-gen" -version = "5.3.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77d306bc75294fd52f3e99b13ece67c02c1a2789190a6f31d32f736624326f7" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" dependencies = [ "proc-macro2", "quote", @@ -3816,12 +3851,14 @@ dependencies = [ [[package]] name = "uuid" -version = "1.16.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.3", + "js-sys", "serde", + "wasm-bindgen", ] [[package]] @@ -3969,9 +4006,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" dependencies = [ "rustls-pki-types", ] @@ -4025,7 +4062,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.1", "windows-result", "windows-strings 0.4.2", ] @@ -4058,6 +4095,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + [[package]] name = "windows-registry" version = "0.4.0" @@ -4075,7 +4118,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -4084,7 +4127,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -4093,7 +4136,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -4443,9 +4486,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "868b928d7949e09af2f6086dfc1e01936064cc7a819253bce650d4e2a2d63ba8" +checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" [[package]] name = "zopfli" diff --git a/api/Cargo.toml b/api/Cargo.toml index b53ff03..f104b64 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "api" -version = "0.1.2" +version = "0.1.3" edition = "2024" authors = ["Ben Sherriff "] repository = "https://gitea.bensherriff.com/bsherriff/aviation" @@ -9,32 +9,32 @@ readme = "../README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -actix-web = "4.10.2" +actix-web = "4.11.0" actix-cors = "0.7.1" actix-multipart = "0.7.2" chrono = { version = "0.4.41", features = ["serde"] } dotenv = "0.15.0" -sqlx = { version = "0.8.5", features = ["runtime-tokio", "postgres", "chrono", "uuid"] } +sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "chrono", "uuid"] } env_logger = "0.11.8" -reqwest = "0.12.15" +reqwest = "0.12.23" serde = {version = "1.0.219", features = ["derive"]} -serde_json = "1.0.140" -tokio = { version = "1.45.0", features = ["macros", "rt", "time"] } -uuid = { version = "1.16.0", features = ["serde", "v4"] } +serde_json = "1.0.142" +tokio = { version = "1.47.1", features = ["macros", "rt", "time"] } +uuid = { version = "1.18.0", features = ["serde", "v4"] } log = "0.4.27" argon2 = "0.5.3" -redis = { version = "0.31.0", features = ["tokio-comp", "connection-manager", "r2d2", "json"] } +redis = { version = "0.32.5", features = ["tokio-comp", "connection-manager", "r2d2", "json"] } regex = "1.11.1" futures-util = "0.3.31" rust-s3 = "0.35.1" -rand = "0.9.1" +rand = "0.9.2" rand_chacha = "0.9.0" futures = "0.3.31" -utoipa = { version = "5.3.1", features = ["chrono", "uuid", "actix_extras"] } +utoipa = { version = "5.4.0", features = ["chrono", "uuid", "actix_extras"] } utoipa-swagger-ui = { version = "9.0.2", 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"] } +lettre = { version = "0.11.18", features = ["builder", "smtp-transport", "tokio1-native-tls"] } handlebars = "6.3.2" -governor = "0.10.0" -flate2 = "1.1.1" +governor = "0.10.1" +flate2 = "1.1.2" diff --git a/api/src/account/auth.rs b/api/src/account/auth.rs index e87a052..07bb1d1 100644 --- a/api/src/account/auth.rs +++ b/api/src/account/auth.rs @@ -1,11 +1,12 @@ use std::future::Future; use std::pin::Pin; - +use std::sync::Arc; use super::{SESSION_COOKIE_NAME, Session}; use crate::account::user::User; use crate::error::Error; -use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http}; +use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http, web}; use serde::{Deserialize, Serialize}; +use crate::state::AppState; #[derive(Debug, Serialize, Deserialize)] pub struct Auth { @@ -19,23 +20,31 @@ impl FromRequest for Auth { type Future = Pin>>>; fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { - // Check for API key + let state = match req.app_data::>() { + Some(state) => state, + None => return Box::pin( + async { Err(Error::new(500, "Internal server error".to_string()).into()) }, + ) + }; + + // Check for an API key match req .headers() .get(http::header::AUTHORIZATION) .map(|h| h.to_str().unwrap().split_at(7).1.to_string()) { Some(key_id) => { + let state = Arc::clone(&state); let fut = async move { // Check if the Session API key exists - let api_key = match Session::get(&key_id).await { + let api_key = match Session::get(&state, &key_id).await { Ok(session) => session, Err(err) => { log::error!("Invalid session auth attempt: {}", err); return Err(Error::new(401, "API Key does not exist".to_string()).into()); } }; - match User::select(&api_key.username).await { + match User::select(&state.pool, &api_key.username).await { Some(user) => Ok(Auth { session_id: None, api_key: Some(key_id), @@ -78,9 +87,10 @@ impl FromRequest for Auth { let ip_address = req.peer_addr().unwrap().ip().to_string(); // Verify the session + let state = Arc::clone(&state); // state: Arc let fut = async move { - match Session::verify(&session_id, &ip_address).await { - Ok(session) => match User::select(&session.username).await { + match Session::verify(&state, &session_id, &ip_address).await { + Ok(session) => match User::select(&state.pool, &session.username).await { Some(user) => Ok(Auth { session_id: Some(session_id), api_key: None, diff --git a/api/src/account/email_token.rs b/api/src/account/email_token.rs index 69d8393..ee74197 100644 --- a/api/src/account/email_token.rs +++ b/api/src/account/email_token.rs @@ -1,12 +1,12 @@ use crate::account::{csprng, hash}; -use crate::db::redis_async_connection; use crate::error::{ApiResult, Error}; use crate::smtp; use chrono::{Datelike, Utc}; -use redis::{AsyncCommands, RedisResult}; use serde::{Deserialize, Serialize}; use std::path::Path; use std::{env, fs}; +use redis::aio::ConnectionManager; +use crate::state::AppState; #[derive(Debug, Serialize, Deserialize)] pub struct EmailToken { @@ -24,37 +24,25 @@ impl EmailToken { } } - pub async fn store(&self, ttl_secs: i64) -> ApiResult<()> { - let mut conn = redis_async_connection().await?; + pub async fn store(&self, state: &AppState, ttl_secs: i64) -> ApiResult<()> { let key = self.token.clone(); let value = serde_json::to_string(self)?; let now = Utc::now(); let expires_at = now + chrono::Duration::seconds(ttl_secs); let ttl = expires_at.timestamp() - now.timestamp(); - let result: RedisResult<()> = conn.set_ex(key, &value, ttl as u64).await; + state.set_ex(&key, &value, ttl as u64).await + } + + pub async fn get(state: &AppState, token: &str) -> ApiResult { + let result: Option = state.get(token).await?; match result { - Ok(_) => Ok(()), - Err(err) => Err(err.into()), + Some(value) => Ok(serde_json::from_str(&value)?), + None => Err(Error::new(404, format!("Missing email token {}", token))), } } - pub async fn get(token: &str) -> ApiResult { - let mut conn = redis_async_connection().await?; - let result: RedisResult> = conn.get(token).await; - match result { - Ok(Some(value)) => Ok(serde_json::from_str(&value)?), - Ok(None) => Err(Error::new(404, format!("Missing email token {}", token))), - Err(err) => Err(err.into()), - } - } - - pub async fn delete(token: &str) -> ApiResult<()> { - let mut conn = redis_async_connection().await?; - let result: RedisResult<()> = conn.del(token).await; - match result { - Ok(_) => Ok(()), - Err(err) => Err(err.into()), - } + pub async fn delete(state: &AppState, token: &str) -> ApiResult<()> { + state.del(token).await } } @@ -113,10 +101,10 @@ pub async fn send_password_reset_email( } } -pub async fn send_confirm_email(email: &str, ip_address: &str) -> ApiResult<()> { +pub async fn send_confirm_email(state: &AppState, email: &str, ip_address: &str) -> ApiResult<()> { let token = csprng(128); let email_token = EmailToken::new(email.to_string(), token, &ip_address); - email_token.store(86400).await?; + email_token.store(state, 86400).await?; let base_url = env::var("EXTERNAL_URL")?; let link = format!("{base_url}/profile/confirm?token={}", email_token.token); @@ -153,7 +141,7 @@ pub async fn send_confirm_email(email: &str, ip_address: &str) -> ApiResult<()> ip_address, err ); - let _ = EmailToken::delete(&email_token.token); + let _ = EmailToken::delete(state, &email_token.token); return Err(err); } diff --git a/api/src/account/routes.rs b/api/src/account/routes.rs index 3187d38..09703df 100644 --- a/api/src/account/routes.rs +++ b/api/src/account/routes.rs @@ -12,6 +12,7 @@ use crate::account::email_token::{EmailToken, send_confirm_email, send_password_ use crate::account::user::{LoginRequest, RegisterRequest, UpdateUser, User, UserResponse}; use crate::account::user_favorites::UserFavorite; use crate::account::{Auth, csprng}; +use crate::state::AppState; #[utoipa::path( tag = "account", @@ -24,7 +25,7 @@ use crate::account::{Auth, csprng}; ) )] #[post("/register")] -async fn register(user: web::Json, req: HttpRequest) -> HttpResponse { +async fn register(state: web::Data, user: web::Json, req: HttpRequest) -> HttpResponse { let register_user = user.into_inner(); let username = register_user.username.clone(); let email = register_user.email.clone(); @@ -34,7 +35,7 @@ async fn register(user: web::Json, req: HttpRequest) -> HttpRes Err(err) => return ResponseError::error_response(&err), }; - match insert_user.insert().await { + match insert_user.insert(&state.pool).await { Ok(user) => { let user_response: UserResponse = user.into(); log::info!( @@ -46,8 +47,8 @@ async fn register(user: web::Json, req: HttpRequest) -> HttpRes // Send confirmation email if let Some(email) = email { if !email.is_empty() { - tokio::spawn(async move { - if let Err(err) = send_confirm_email(&email, &ip_address).await { + tokio::task::spawn_local(async move { + if let Err(err) = send_confirm_email(&state, &email, &ip_address).await { log::error!("Failed to send confirmation email: {}", err); }; }); @@ -91,15 +92,16 @@ struct ConfirmEmail { )] #[post("/register/confirm")] async fn confirm_email_registration( + state: web::Data, request: web::Json, req: HttpRequest, ) -> HttpResponse { let ip_address = req.peer_addr().unwrap().ip().to_string(); let token = &request.token; - let email_token = match EmailToken::get(token).await { + let email_token = match EmailToken::get(&state, token).await { Ok(password_reset) => { - if let Err(err) = EmailToken::delete(&password_reset.token).await { + if let Err(err) = EmailToken::delete(&state, &password_reset.token).await { return ResponseError::error_response(&err); }; password_reset @@ -109,7 +111,7 @@ async fn confirm_email_registration( } }; - match User::select_by_email(&email_token.email).await { + match User::select_by_email(&state.pool, &email_token.email).await { Some(user) => { let update_user = UpdateUser { email: None, @@ -121,7 +123,7 @@ async fn confirm_email_registration( avatar: None, }; - match update_user.update(&user.username).await { + match update_user.update(&state.pool, &user.username).await { Ok(user) => { let response: UserResponse = user.into(); log::info!( @@ -157,13 +159,13 @@ async fn confirm_email_registration( ) )] #[post("/register/email")] -async fn resend_email_verification(req: HttpRequest, auth: Auth) -> HttpResponse { +async fn resend_email_verification(state: web::Data, req: HttpRequest, auth: Auth) -> HttpResponse { let email = auth.user.email; let ip_address = req.peer_addr().unwrap().ip().to_string(); match email { Some(email) => { - let user = match User::select_by_email(&email).await { + let user = match User::select_by_email(&state.pool, &email).await { Some(query_user) => query_user, None => return HttpResponse::Unauthorized().finish(), }; @@ -174,11 +176,10 @@ async fn resend_email_verification(req: HttpRequest, auth: Auth) -> HttpResponse } // Send reverify confirmation email - tokio::spawn(async move { - if let Err(err) = send_confirm_email(&email, &ip_address).await { - log::error!("Failed to send reverify confirmation email: {}", err); - }; - }); + if let Err(err) = send_confirm_email(&state, &email, &ip_address).await { + log::error!("Failed to send reverify confirmation email: {}", err); + return HttpResponse::InternalServerError().finish(); + }; HttpResponse::Ok().finish() } None => HttpResponse::NotFound().finish(), @@ -195,11 +196,11 @@ async fn resend_email_verification(req: HttpRequest, auth: Auth) -> HttpResponse ), )] #[post("/login")] -async fn login(request: web::Json, req: HttpRequest) -> HttpResponse { +async fn login(state: web::Data, request: web::Json, req: HttpRequest) -> HttpResponse { let username = &request.username; let ip_address = req.peer_addr().unwrap().ip().to_string(); - let query_user = match User::select(&username).await { + let query_user = match User::select(&state.pool, &username).await { Some(query_user) => query_user, None => return HttpResponse::Unauthorized().finish(), }; @@ -210,7 +211,7 @@ async fn login(request: web::Json, req: HttpRequest) -> HttpRespon let session_cookie = session.cookie(); let session_exp_cookie = session.expiration_cookie(); // Save the session to the database - if let Err(err) = session.store().await { + if let Err(err) = session.store(&state).await { log::error!( "Login attempt failure [User: {}] [IP Address: {}]: {}", username, @@ -253,14 +254,14 @@ async fn login(request: web::Json, req: HttpRequest) -> HttpRespon ) )] #[post("/logout")] -async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse { +async fn logout(state: web::Data, req: HttpRequest, auth: Auth) -> HttpResponse { let username = auth.user.username; let ip_address = req.peer_addr().unwrap().ip().to_string(); // Delete the session from the store match req.cookie(SESSION_COOKIE_NAME) { Some(cookie) => { let session_id = cookie.value().to_string(); - if let Err(err) = Session::delete(&session_id).await { + if let Err(err) = Session::delete(&state, &session_id).await { log::error!( "Logout attempt failure [User: {}] [IP Address: {}]: {}", username, @@ -302,14 +303,14 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse { ) )] #[get("/profile")] -async fn get_profile(req: HttpRequest) -> HttpResponse { +async fn get_profile(state: web::Data, req: HttpRequest) -> HttpResponse { let ip_address = req.peer_addr().unwrap().ip().to_string(); // Verify a session cookie exists match req.cookie(SESSION_COOKIE_NAME) { // Validate the session Some(cookie) => { let session_id = cookie.value().to_string(); - let session = match Session::get(&session_id).await { + let session = match Session::get(&state, &session_id).await { Ok(session) => session, Err(_) => { log::error!( @@ -324,7 +325,7 @@ async fn get_profile(req: HttpRequest) -> HttpResponse { } }; let username = &session.username; - let query_user = match User::select(&username).await { + let query_user = match User::select(&state.pool, &username).await { Some(query_user) => query_user, None => { return HttpResponse::Unauthorized() @@ -366,14 +367,14 @@ async fn get_profile(req: HttpRequest) -> HttpResponse { ) )] #[post("/session")] -async fn session_refresh(req: HttpRequest) -> HttpResponse { +async fn session_refresh(state: web::Data, req: HttpRequest) -> HttpResponse { let ip_address = req.peer_addr().unwrap().ip().to_string(); // Verify a session cookie exists match req.cookie(SESSION_COOKIE_NAME) { // Validate the session Some(cookie) => { let session_id = cookie.value().to_string(); - let session = match Session::replace(&session_id, &ip_address).await { + let session = match Session::replace(&state, &session_id, &ip_address).await { Ok(session) => session, Err(_) => { log::error!( @@ -428,6 +429,7 @@ struct ChangePassword { )] #[put("/password")] async fn change_password( + state: web::Data, request: web::Json, req: HttpRequest, auth: Auth, @@ -435,7 +437,7 @@ async fn change_password( let ip_address = req.peer_addr().unwrap().ip().to_string(); let username = auth.user.username; - if let None = User::select(&username).await { + if let None = User::select(&state.pool, &username).await { return HttpResponse::Unauthorized().finish(); }; @@ -449,7 +451,7 @@ async fn change_password( avatar: None, }; - match update_user.update(&username).await { + match update_user.update(&state.pool, &username).await { Ok(user) => { let response: UserResponse = user.into(); log::info!( @@ -486,18 +488,18 @@ struct PasswordReset { ) )] #[post("/password/reset")] -async fn reset_password(request: web::Json, req: HttpRequest) -> HttpResponse { +async fn reset_password(state: web::Data, request: web::Json, req: HttpRequest) -> HttpResponse { let email = &request.email; let ip_address = req.peer_addr().unwrap().ip().to_string(); let token = csprng(128); // Silently return if the user's email does not exist - if let None = User::select_by_email(&email).await { + if let None = User::select_by_email(&state.pool, &email).await { return HttpResponse::Ok().finish(); }; let email_token = EmailToken::new(email.clone(), token, &ip_address); - if let Err(err) = email_token.store(86400).await { + if let Err(err) = email_token.store(&state, 86400).await { return ResponseError::error_response(&err); } @@ -525,6 +527,7 @@ struct ConfirmPasswordReset { )] #[post("/password/reset/confirm")] async fn confirm_password_reset( + state: web::Data, request: web::Json, req: HttpRequest, ) -> HttpResponse { @@ -532,9 +535,9 @@ async fn confirm_password_reset( let ip_address = req.peer_addr().unwrap().ip().to_string(); let token = &request.token; - let email_token = match EmailToken::get(token).await { + let email_token = match EmailToken::get(&state, token).await { Ok(password_reset) => { - if let Err(err) = EmailToken::delete(&password_reset.token).await { + if let Err(err) = EmailToken::delete(&state, &password_reset.token).await { return ResponseError::error_response(&err); }; password_reset @@ -558,9 +561,9 @@ async fn confirm_password_reset( ) )] #[get("/profile/favorites")] -async fn get_favorites(auth: Auth) -> HttpResponse { +async fn get_favorites(state: web::Data, auth: Auth) -> HttpResponse { let username = auth.user.username; - match UserFavorite::select_all(&username).await { + match UserFavorite::select_all(&state.pool, &username).await { Ok(favorites) => HttpResponse::Ok().json(favorites), Err(err) => ResponseError::error_response(&err), } @@ -577,9 +580,9 @@ async fn get_favorites(auth: Auth) -> HttpResponse { ) )] #[post("/profile/favorites/{icao}")] -async fn add_favorite(icao: web::Path, auth: Auth) -> HttpResponse { +async fn add_favorite(state: web::Data, icao: web::Path, auth: Auth) -> HttpResponse { let username = auth.user.username; - match UserFavorite::insert(&username, &icao.into_inner()).await { + match UserFavorite::insert(&state.pool, &username, &icao.into_inner()).await { Ok(_) => HttpResponse::Ok().finish(), Err(err) => ResponseError::error_response(&err), } @@ -596,9 +599,9 @@ async fn add_favorite(icao: web::Path, auth: Auth) -> HttpResponse { ) )] #[delete("/profile/favorites/{icao}")] -async fn remove_favorite(icao: web::Path, auth: Auth) -> HttpResponse { +async fn remove_favorite(state: web::Data, icao: web::Path, auth: Auth) -> HttpResponse { let username = auth.user.username; - match UserFavorite::delete(&username, &icao.into_inner()).await { + match UserFavorite::delete(&state.pool, &username, &icao.into_inner()).await { Ok(_) => HttpResponse::Ok().finish(), Err(err) => ResponseError::error_response(&err), } diff --git a/api/src/account/session.rs b/api/src/account/session.rs index 59c8840..983d88f 100644 --- a/api/src/account/session.rs +++ b/api/src/account/session.rs @@ -1,13 +1,12 @@ use super::{csprng, hash, verify_hash}; -use crate::{ - db::redis_async_connection, - error::{ApiResult, Error}, -}; +use crate::error::{ApiResult, Error}; use actix_web::cookie::{Cookie, time::Duration}; use chrono::{DateTime, Utc}; use redis::{AsyncCommands, RedisResult}; +use redis::aio::ConnectionManager; use serde::{Deserialize, Serialize}; use tokio::task; +use crate::state::AppState; const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours pub const SESSION_COOKIE_NAME: &str = "session"; @@ -40,16 +39,15 @@ impl Session { } } - pub async fn store(&self) -> ApiResult<()> { - let mut conn = redis_async_connection().await?; + pub async fn store(&self, state: &AppState) -> ApiResult<()> { let key = self.session_id.clone(); let value = serde_json::to_string(self)?; - let result: RedisResult<()> = match self.expires_at { + let result: ApiResult<()> = match self.expires_at { Some(expires_at) => { let ttl = expires_at.timestamp() - Utc::now().timestamp(); - conn.set_ex(key, &value, ttl as u64).await + state.set_ex(&key, &value, ttl as u64).await } - None => conn.set(key, value).await, + None => state.set(&key, &value).await, }; match result { Ok(_) => Ok(()), @@ -57,43 +55,33 @@ impl Session { } } - pub async fn get(session_id: &str) -> ApiResult { - let mut conn = redis_async_connection().await?; - let result: RedisResult> = conn.get(session_id).await; + pub async fn get(state: &AppState, session_id: &str) -> ApiResult { + let result: Option = state.get(session_id).await?; match result { - Ok(Some(value)) => Ok(serde_json::from_str(&value)?), - Ok(None) => Err(Error::new(401, format!("Missing session {}", session_id))), - Err(err) => Err(err.into()), + Some(value) => Ok(serde_json::from_str(&value)?), + None => Err(Error::new(401, format!("Missing session {}", session_id))), } } - pub async fn replace(session_id: &str, ip_address: &str) -> ApiResult { - let mut session = Self::verify(session_id, ip_address).await?; + pub async fn replace(state: &AppState, session_id: &str, ip_address: &str) -> ApiResult { + let mut session = Self::verify(state, session_id, ip_address).await?; let session_id_owned = session_id.to_owned(); - task::spawn(async move { - if let Err(err) = Self::delete(&session_id_owned).await { - log::error!( - "Error deleting old session in replace session call: {}", - err - ); - }; - }); + Self::delete(state, &session_id_owned).await?; session = Session::default(&session.username, ip_address); - session.store().await?; + session.store(state).await?; Ok(session) } - pub async fn delete(session_id: &str) -> ApiResult<()> { - let mut conn = redis_async_connection().await?; - let result: RedisResult<()> = conn.del(session_id).await; + pub async fn delete(state: &AppState, session_id: &str) -> ApiResult<()> { + let result: ApiResult<()> = state.del(session_id).await; match result { Ok(_) => Ok(()), Err(err) => Err(err.into()), } } - pub async fn verify(session_id: &str, ip_address: &str) -> ApiResult { - let session = Self::get(session_id).await?; + pub async fn verify(state: &AppState, session_id: &str, ip_address: &str) -> ApiResult { + let session = Self::get(state, session_id).await?; // Check if the IP Address matches the Session's IP Address if verify_hash(ip_address, &session.ip_address) { @@ -103,7 +91,7 @@ impl Session { } } - pub fn cookie(&self) -> Cookie { + pub fn cookie(&self) -> Cookie<'_> { let expires_at = match self.expires_at { Some(expires_at) => expires_at.timestamp(), None => DEFAULT_SESSION_TTL, @@ -131,7 +119,7 @@ impl Session { cookie } - pub fn expiration_cookie(&self) -> Cookie { + pub fn expiration_cookie(&self) -> Cookie<'_> { let expires_at = match self.expires_at { Some(expires_at) => expires_at.timestamp(), None => DEFAULT_SESSION_TTL, diff --git a/api/src/account/user.rs b/api/src/account/user.rs index f32e107..64e69e7 100644 --- a/api/src/account/user.rs +++ b/api/src/account/user.rs @@ -1,10 +1,9 @@ -use crate::db; use crate::{account::hash, error::ApiResult}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; #[allow(unused_imports)] // Import is used in schema examples use serde_json::json; -use sqlx::{Postgres, QueryBuilder}; +use sqlx::{Pool, Postgres, QueryBuilder}; use utoipa::ToSchema; pub const ADMIN_ROLE: &str = "ADMIN"; @@ -107,9 +106,7 @@ pub struct UpdateUser { } impl UpdateUser { - pub async fn update(&self, username: &str) -> ApiResult { - let pool = db::pool(); - + pub async fn update(&self, pool: &Pool, username: &str) -> ApiResult { let mut query_builder: QueryBuilder = QueryBuilder::new(&format!("UPDATE {} SET ", TABLE_NAME)); @@ -189,8 +186,7 @@ pub struct User { } impl User { - pub async fn select(username: &str) -> Option { - let pool = db::pool(); + pub async fn select(pool: &Pool, username: &str) -> Option { let user: Option = sqlx::query_as::<_, Self>(&format!( r#" SELECT * FROM {} WHERE username = $1 @@ -208,8 +204,7 @@ impl User { user } - pub async fn select_by_email(email: &str) -> Option { - let pool = db::pool(); + pub async fn select_by_email(pool: &Pool, email: &str) -> Option { let user: Option = sqlx::query_as::<_, Self>(&format!( r#" SELECT * FROM {} WHERE email = $1 @@ -228,9 +223,7 @@ impl User { } #[allow(dead_code)] - pub async fn count() -> i64 { - let pool = db::pool(); - + pub async fn count(pool: &Pool) -> i64 { sqlx::query_scalar(&format!( r#" SELECT COUNT(*) FROM {} @@ -242,8 +235,7 @@ impl User { .unwrap_or_else(|_| 0) } - pub async fn insert(&self) -> ApiResult { - let pool = db::pool(); + pub async fn insert(&self, pool: &Pool) -> ApiResult { let user: User = sqlx::query_as::<_, Self>(&format!( r#" INSERT INTO {} ( diff --git a/api/src/account/user_favorites.rs b/api/src/account/user_favorites.rs index ae6e389..98c9560 100644 --- a/api/src/account/user_favorites.rs +++ b/api/src/account/user_favorites.rs @@ -1,6 +1,6 @@ -use crate::db; use crate::error::ApiResult; use serde::Deserialize; +use sqlx::{Pool, Postgres}; const TABLE_NAME: &str = "user_airport_favorites"; @@ -11,8 +11,7 @@ pub struct UserFavorite { } impl UserFavorite { - pub async fn select_all(username: &str) -> ApiResult> { - let pool = db::pool(); + pub async fn select_all(pool: &Pool, username: &str) -> ApiResult> { let user_favorites: Vec = sqlx::query_as::<_, UserFavorite>(&format!( r#" SELECT * FROM {} WHERE username = $1 @@ -28,8 +27,7 @@ impl UserFavorite { Ok(favorites) } - pub async fn insert(username: &str, icao: &str) -> ApiResult<()> { - let pool = db::pool(); + pub async fn insert(pool: &Pool, username: &str, icao: &str) -> ApiResult<()> { sqlx::query(&format!( r#" INSERT INTO {} ( @@ -46,8 +44,7 @@ impl UserFavorite { Ok(()) } - pub async fn delete(username: &str, icao: &str) -> ApiResult<()> { - let pool = db::pool(); + pub async fn delete(pool: &Pool, username: &str, icao: &str) -> ApiResult<()> { sqlx::query(&format!( r#" DELETE FROM {} WHERE username = $1 AND icao = $2 diff --git a/api/src/airports/model/airport.rs b/api/src/airports/model/airport.rs index d1dccf6..9b0b922 100644 --- a/api/src/airports/model/airport.rs +++ b/api/src/airports/model/airport.rs @@ -2,16 +2,16 @@ 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 serde::{Deserialize, Serialize}; -use sqlx::{Execute, Postgres, QueryBuilder}; +use sqlx::{Pool, Postgres, QueryBuilder}; use std::collections::HashMap; use std::str::FromStr; use utoipa::{IntoParams, ToSchema}; +use crate::state::AppState; const TABLE_NAME: &str = "airports"; const DEFAULT_COLUMNS: &str = "icao, iata, local, name, category, iso_country, \ @@ -255,8 +255,7 @@ impl From for Airport { } impl Airport { - pub async fn select(icao: &str, metar: bool) -> Option { - let pool = db::pool(); + pub async fn select(pool: &Pool, icao: &str, metar: bool) -> Option { let airport_fut = async { sqlx::query_as(&format!( @@ -270,7 +269,7 @@ impl Airport { let metar_fut = async { if metar { - match Metar::get_all_distinct(&vec![icao.to_uppercase()]).await { + match Metar::get_all_distinct(pool, &vec![icao.to_uppercase()]).await { Ok(m) => Some(m.into_iter().nth(0)), Err(err) => { log::error!("{}", err); @@ -282,8 +281,8 @@ impl Airport { } }; - let runways_fut = Runway::select_all(icao); - let communications_fut = Communication::select_all(icao); + let runways_fut = Runway::select_all(pool, icao); + let communications_fut = Communication::select_all(pool, icao); let (airport_result, runways_result, communications_result, metar_result) = tokio::join!(airport_fut, runways_fut, communications_fut, metar_fut); @@ -333,9 +332,7 @@ impl Airport { }) } - pub async fn select_all(query: &AirportQuery) -> ApiResult> { - let pool = db::pool(); - + pub async fn select_all(pool: &Pool, query: &AirportQuery) -> ApiResult> { let mut builder = QueryBuilder::::new(format!("SELECT {} FROM {}", DEFAULT_COLUMNS, TABLE_NAME)); @@ -410,10 +407,10 @@ impl Airport { // Bulk update airport subfields let icaos: Vec = airports.iter().map(|a| a.icao.to_uppercase()).collect(); - let runway_future = Runway::select_all_map(&icaos); - let frequency_future = Communication::select_all_map(&icaos); + let runway_future = Runway::select_all_map(pool, &icaos); + let frequency_future = Communication::select_all_map(pool, &icaos); let metar_future = if query.metars.unwrap_or(false) { - Some(Metar::get_all_distinct(&icaos)) + Some(Metar::get_all_distinct(pool, &icaos)) } else { None }; @@ -453,9 +450,7 @@ impl Airport { Ok(airports) } - pub async fn count(query: &AirportQuery) -> i64 { - let pool = db::pool(); - + pub async fn count(pool: &Pool, query: &AirportQuery) -> i64 { let mut builder = QueryBuilder::::new("SELECT COUNT(*) FROM "); builder.push(TABLE_NAME); @@ -492,9 +487,7 @@ impl Airport { sql_query.fetch_one(pool).await.unwrap_or_else(|_| 0) } - pub async fn insert(&self) -> ApiResult { - let pool = db::pool(); - + pub async fn insert(&self, pool: &Pool) -> ApiResult { let mut all_runway_rows: Vec = Vec::new(); let mut all_frequency_rows: Vec = Vec::new(); for runway in &self.runways { @@ -503,8 +496,8 @@ impl Airport { for frequency in &self.communications { all_frequency_rows.push(Communication::into(frequency, &self.icao)); } - Runway::insert_all(&all_runway_rows).await?; - Communication::insert_all(&all_frequency_rows).await?; + Runway::insert_all(pool, &all_runway_rows).await?; + Communication::insert_all(pool, &all_frequency_rows).await?; let airport: AirportRow = sqlx::query_as(&format!( r#" @@ -542,8 +535,7 @@ impl Airport { Ok(airport.into()) } - pub async fn insert_all(airports: Vec) -> ApiResult<()> { - let pool = db::pool(); + pub async fn insert_all(pool: &Pool, airports: Vec) -> ApiResult<()> { let chunk_size = 1000; let mut all_runway_rows: Vec = Vec::new(); let mut all_frequency_rows: Vec = Vec::new(); @@ -593,16 +585,14 @@ impl Airport { query.execute(pool).await?; } - Runway::insert_all(&all_runway_rows).await?; - Communication::insert_all(&all_frequency_rows).await?; + Runway::insert_all(pool, &all_runway_rows).await?; + Communication::insert_all(pool, &all_frequency_rows).await?; Ok(()) } // TODO - pub async fn update(icao: &str, airport: &UpdateAirport) -> ApiResult<()> { - let pool = db::pool(); - + pub async fn update(pool: &Pool, icao: &str, airport: &UpdateAirport) -> ApiResult<()> { let mut query_builder: QueryBuilder = QueryBuilder::new(format!("UPDATE {} SET ", TABLE_NAME)); if let Some(latest_metar_observation) = airport.latest_metar_observation { @@ -617,9 +607,7 @@ impl Airport { Ok(()) } - pub async fn delete(icao: &str) -> ApiResult<()> { - let pool = db::pool(); - + pub async fn delete(pool: &Pool, icao: &str) -> ApiResult<()> { sqlx::query(&format!( r#" DELETE FROM {} WHERE icao = $1 @@ -633,9 +621,7 @@ impl Airport { Ok(()) } - pub async fn delete_all() -> ApiResult<()> { - let pool = db::pool(); - + pub async fn delete_all(pool: &Pool) -> ApiResult<()> { sqlx::query(&format!( r#" DELETE FROM {} WHERE true diff --git a/api/src/airports/model/communication.rs b/api/src/airports/model/communication.rs index 20cca0a..fd1afc6 100644 --- a/api/src/airports/model/communication.rs +++ b/api/src/airports/model/communication.rs @@ -1,7 +1,6 @@ -use crate::db; use crate::error::ApiResult; use serde::{Deserialize, Serialize}; -use sqlx::{Postgres, QueryBuilder}; +use sqlx::{Pool, Postgres, QueryBuilder}; use std::collections::HashMap; use utoipa::ToSchema; use uuid::Uuid; @@ -65,9 +64,7 @@ impl Communication { } } - pub async fn select_all_map(icaos: &Vec) -> ApiResult>> { - let pool = db::pool(); - + pub async fn select_all_map(pool: &Pool, icaos: &Vec) -> ApiResult>> { let frequency_rows: Vec = sqlx::query_as(&format!( r#"SELECT * FROM {} WHERE icao = ANY($1)"#, TABLE_NAME @@ -89,9 +86,7 @@ impl Communication { Ok(frequency_map) } - pub async fn select_all(icao: &str) -> ApiResult> { - let pool = db::pool(); - + pub async fn select_all(pool: &Pool, icao: &str) -> ApiResult> { let frequency_row: Vec = sqlx::query_as(&format!( r#" SELECT * FROM {} WHERE icao = $1 @@ -104,8 +99,7 @@ impl Communication { Ok(frequency_row.into_iter().map(From::from).collect()) } - pub async fn insert_all(communications: &Vec) -> ApiResult<()> { - let pool = db::pool(); + pub async fn insert_all(pool: &Pool, communications: &Vec) -> ApiResult<()> { let chunk_size = 1000; for chunk in communications.chunks(chunk_size) { diff --git a/api/src/airports/model/runway.rs b/api/src/airports/model/runway.rs index f8685d4..3910103 100644 --- a/api/src/airports/model/runway.rs +++ b/api/src/airports/model/runway.rs @@ -1,7 +1,6 @@ -use crate::db; use crate::error::ApiResult; use serde::{Deserialize, Serialize}; -use sqlx::{Postgres, QueryBuilder}; +use sqlx::{Pool, Postgres, QueryBuilder}; use std::collections::HashMap; use utoipa::ToSchema; use uuid::Uuid; @@ -64,9 +63,7 @@ impl Runway { } } - pub async fn select_all_map(icaos: &Vec) -> ApiResult>> { - let pool = db::pool(); - + pub async fn select_all_map(pool: &Pool, icaos: &Vec) -> ApiResult>> { let runway_rows: Vec = sqlx::query_as(&format!( r#"SELECT * FROM {} WHERE icao = ANY($1)"#, TABLE_NAME @@ -85,9 +82,7 @@ impl Runway { Ok(runway_map) } - pub async fn select_all(icao: &str) -> ApiResult> { - let pool = db::pool(); - + pub async fn select_all(pool: &Pool, icao: &str) -> ApiResult> { let runway_rows: Vec = sqlx::query_as(&format!( r#" SELECT * FROM {} WHERE icao = $1 @@ -100,8 +95,7 @@ impl Runway { Ok(runway_rows.into_iter().map(From::from).collect()) } - pub async fn insert_all(runways: &Vec) -> ApiResult<()> { - let pool = db::pool(); + pub async fn insert_all(pool: &Pool, runways: &Vec) -> ApiResult<()> { let chunk_size = 1000; for chunk in runways.chunks(chunk_size) { diff --git a/api/src/airports/routes.rs b/api/src/airports/routes.rs index cb50c4d..460159a 100644 --- a/api/src/airports/routes.rs +++ b/api/src/airports/routes.rs @@ -5,13 +5,14 @@ use crate::airports::{AirportQuery, UpdateAirport}; use crate::{ 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; +use crate::state::AppState; +use crate::utils::Paged; #[derive(ToSchema)] #[allow(unused)] @@ -34,7 +35,7 @@ struct FileUpload { ) )] #[post("/import")] -async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse { +async fn import_airports(state: web::Data, mut payload: Multipart, auth: Auth) -> HttpResponse { if let Err(err) = verify_role(&auth, ADMIN_ROLE) { return ResponseError::error_response(&err); }; @@ -67,7 +68,7 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse { } }; - match Airport::insert_all(airports).await { + match Airport::insert_all(&state.pool, airports).await { Ok(_) => {} Err(err) => return ResponseError::error_response(&err), }; @@ -85,7 +86,7 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse { ), )] #[get("")] -async fn get_airports(req: HttpRequest) -> HttpResponse { +async fn get_airports(state: web::Data, req: HttpRequest) -> HttpResponse { let mut query = match web::Query::::from_query(req.query_string()) { Ok(q) => q.into_inner(), Err(err) => { @@ -94,7 +95,7 @@ async fn get_airports(req: HttpRequest) -> HttpResponse { } }; - let total = Airport::count(&query).await; + let total = Airport::count(&state.pool, &query).await; let page = query.page.unwrap_or(1); let mut limit = query.limit.unwrap_or(total as u32); if limit > 1000 { @@ -103,7 +104,7 @@ async fn get_airports(req: HttpRequest) -> HttpResponse { query.limit = Some(limit); query.page = Some(page); - match Airport::select_all(&query).await { + match Airport::select_all(&state.pool, &query).await { Ok(airports) => HttpResponse::Ok().json(Paged { data: airports, page, @@ -125,7 +126,7 @@ async fn get_airports(req: HttpRequest) -> HttpResponse { ), )] #[get("/{icao}")] -async fn get_airport(icao: web::Path, req: HttpRequest) -> HttpResponse { +async fn get_airport(state: web::Data, icao: web::Path, req: HttpRequest) -> HttpResponse { let metar = match web::Query::::from_query(req.query_string()) { Ok(q) => q.metars.unwrap_or_else(|| false), Err(err) => { @@ -134,7 +135,7 @@ async fn get_airport(icao: web::Path, req: HttpRequest) -> HttpResponse } }; - match Airport::select(&icao.into_inner(), metar).await { + match Airport::select(&state.pool, &icao.into_inner(), metar).await { Some(airport) => HttpResponse::Ok().json(airport), None => HttpResponse::NotFound().finish(), } @@ -152,12 +153,12 @@ async fn get_airport(icao: web::Path, req: HttpRequest) -> HttpResponse ) )] #[post("")] -async fn insert_airport(airport: web::Json, auth: Auth) -> HttpResponse { +async fn insert_airport(state: web::Data, airport: web::Json, auth: Auth) -> HttpResponse { let _ = match verify_role(&auth, ADMIN_ROLE) { Ok(_) => {} Err(err) => return ResponseError::error_response(&err), }; - match airport.insert().await { + match airport.insert(&state.pool).await { Ok(a) => HttpResponse::Ok().json(a), Err(err) => { log::error!("{}", err); @@ -178,6 +179,7 @@ async fn insert_airport(airport: web::Json, auth: Auth) -> HttpResponse )] #[put("/{icao}")] async fn update_airport( + state: web::Data, icao: web::Path, airport: web::Json, auth: Auth, @@ -186,7 +188,7 @@ async fn update_airport( Ok(_) => {} Err(err) => return ResponseError::error_response(&err), }; - match Airport::update(&icao.into_inner(), &airport.into_inner()).await { + match Airport::update(&state.pool, &icao.into_inner(), &airport.into_inner()).await { Ok(a) => HttpResponse::Ok().json(a), Err(err) => { log::error!("{}", err); @@ -206,12 +208,12 @@ async fn update_airport( ) )] #[delete("")] -async fn delete_airports(auth: Auth) -> HttpResponse { +async fn delete_airports(state: web::Data, auth: Auth) -> HttpResponse { let _ = match verify_role(&auth, ADMIN_ROLE) { Ok(_) => {} Err(err) => return ResponseError::error_response(&err), }; - match Airport::delete_all().await { + match Airport::delete_all(&state.pool).await { Ok(_) => HttpResponse::NoContent().finish(), Err(err) => { log::error!("{}", err); @@ -231,12 +233,12 @@ async fn delete_airports(auth: Auth) -> HttpResponse { ) )] #[delete("/{icao}")] -async fn delete_airport(icao: web::Path, auth: Auth) -> HttpResponse { +async fn delete_airport(state: web::Data, icao: web::Path, auth: Auth) -> HttpResponse { let _ = match verify_role(&auth, ADMIN_ROLE) { Ok(_) => {} Err(err) => return ResponseError::error_response(&err), }; - match Airport::delete(&icao.into_inner()).await { + match Airport::delete(&state.pool, &icao.into_inner()).await { Ok(_) => HttpResponse::NoContent().finish(), Err(err) => { log::error!("{}", err); diff --git a/api/src/db/mod.rs b/api/src/db/mod.rs deleted file mode 100644 index 020b80c..0000000 --- a/api/src/db/mod.rs +++ /dev/null @@ -1,171 +0,0 @@ -use crate::error::ApiResult; -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; - -static POOL: OnceLock> = OnceLock::new(); -static REDIS: OnceLock = OnceLock::new(); -static BUCKET: OnceLock = OnceLock::new(); - -pub async fn initialize() -> ApiResult<()> { - // Setup Postgres pool connection - let pool = { - let user = std::env::var("POSTGRES_USER").unwrap_or("aviation".to_string()); - let password = std::env::var("POSTGRES_PASSWORD").expect("POSTGRES_PASSWORD must be set"); - let host: String = std::env::var("POSTGRES_HOST").expect("POSTGRES_HOST must be set"); - let port = std::env::var("POSTGRES_PORT").unwrap_or("5432".to_string()); - let name = std::env::var("POSTGRES_DB").unwrap_or("aviation_db".to_string()); - - let db_url = format!( - "postgres://{}:{}@{}:{}/{}", - &user, &password, &host, &port, &name - ); - - log::info!( - "Connecting to database at postgres://{}:*****@{}:{}/{}...", - &user, - &host, - &port, - &name - ); - - PgPoolOptions::new() - .max_connections(5) - .acquire_timeout(Duration::from_secs(30)) - .connect(&db_url) - .await? - }; - match POOL.set(pool) { - Ok(_) => log::info!("Database connection established"), - Err(_) => log::warn!("Database pool already initialized"), - } - - // Setup Redis connection - let redis = { - let host = std::env::var("REDIS_HOST").unwrap_or("localhost".to_string()); - let port = std::env::var("REDIS_PORT").unwrap_or("6379".to_string()); - let url = format!("redis://{}:{}", host, port); - log::info!("Connecting to redis at {}", &url); - RedisClient::open(url).expect("Failed to create redis client") - }; - match REDIS.set(redis) { - Ok(_) => log::info!("Redis connection established"), - Err(_) => log::warn!("Redis client already initialized"), - } - - // Setup Bucket connection - let bucket = { - let protocol = std::env::var("MINIO_PROTOCOL").unwrap_or("http".to_string()); - let host = std::env::var("MINIO_HOST").unwrap_or("localhost".to_string()); - let port = std::env::var("MINIO_PORT").unwrap_or("9000".to_string()); - let user = std::env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set"); - let password = std::env::var("MINIO_ROOT_PASSWORD").expect("MINIO_ROOT_PASSWORD is not set"); - let bucket_name = std::env::var("MINIO_BUCKET").unwrap_or("aviation".to_string()); - let url = format!("{}://{}:{}", protocol, host, port); - - let region = Region::Custom { - region: "".to_string(), - endpoint: url.to_string(), - }; - - let credentials = Credentials { - access_key: Some(user), - secret_key: Some(password), - security_token: None, - session_token: None, - expiration: None, - }; - - let bucket = Bucket::new(&bucket_name, region.clone(), credentials.clone())?.with_path_style(); - log::info!("Checking for object in bucket at {}", ®ion.endpoint()); - match bucket.head_object("/").await { - Ok(_) => bucket, - Err(_) => { - log::debug!("Creating '{}' bucket", &bucket_name); - let response = match Bucket::create_with_path_style( - &bucket_name, - region, - credentials, - BucketConfiguration::default(), - ) - .await - { - Ok(response) => response, - Err(err) => { - log::error!("Failed to create bucket '{}': {}", &bucket_name, err); - return Err(err.into()); - } - }; - response.bucket - } - } - }; - - match BUCKET.set(*bucket) { - Ok(_) => log::info!("Bucket connection initialized"), - Err(_) => log::warn!("Bucket connection already initialized"), - } - - // Run migrations - match run_migrations().await { - Ok(_) => log::debug!("Successfully ran database migrations"), - Err(e) => log::error!("Failed to run migrations: {}", e), - } - - log::info!("Database initialized"); - - Ok(()) -} - -pub fn pool() -> &'static Pool { - POOL.get().unwrap() -} - -fn redis() -> &'static RedisClient { - REDIS.get().unwrap() -} - -// pub fn redis_connection() -> RedisResult { -// let conn = redis().get_connection()?; -// Ok(conn) -// } - -pub async fn redis_async_connection() -> RedisResult { - let conn = redis().get_multiplexed_async_connection().await?; - Ok(conn) -} - -async fn run_migrations() -> ApiResult<()> { - log::debug!("Running database migrations"); - let pool = pool(); - sqlx::migrate!().run(pool).await?; - Ok(()) -} - -pub async fn upload_file(path: &str, content: &[u8]) -> ApiResult { - let response = BUCKET.get().unwrap().put_object(path, content).await?; - Ok(response) -} - -pub async fn get_file(path: &str) -> ApiResult> { - let response = BUCKET.get().unwrap().get_object(path).await?; - let bytes = response.bytes(); - Ok(bytes.to_vec()) -} - -pub async fn delete_file(path: &str) -> ApiResult { - let response = BUCKET.get().unwrap().delete_object(path).await?; - Ok(response) -} - -#[derive(Serialize, Deserialize)] -pub struct Paged { - pub data: T, - pub page: u32, - pub limit: u32, - pub total: i64, -} diff --git a/api/src/error.rs b/api/src/error.rs index d7d8e3b..4a92c15 100644 --- a/api/src/error.rs +++ b/api/src/error.rs @@ -5,6 +5,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::json; use std::fmt; +use std::sync::{MutexGuard, PoisonError}; pub type ApiResult = Result; @@ -15,10 +16,10 @@ pub struct Error { } impl Error { - pub fn new(status: u16, message: String) -> Self { + pub fn new(status: u16, details: String) -> Self { Self { status, - details: message, + details, } } @@ -236,3 +237,9 @@ impl From for Error { Self::new(500, error.to_string()) } } + +impl<'a, T> From>> for Error { + fn from(_: PoisonError>) -> Self { + Self::new(500, "Failed to acquire lock".to_string()) + } +} diff --git a/api/src/main.rs b/api/src/main.rs index db66f08..380ecf4 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,6 +1,5 @@ use crate::account::User; use crate::account::{ADMIN_ROLE, hash}; -use crate::http_client::HttpClient; use actix_cors::Cors; use actix_web::{App, HttpServer, middleware::Logger, web}; use dotenv::from_filename; @@ -10,27 +9,24 @@ use utoipa::openapi::SecurityRequirement; use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}; use utoipa_actix_web::{AppExt, scope}; use utoipa_swagger_ui::{Config, SwaggerUi}; +use crate::state::AppState; mod account; mod airports; -mod db; mod error; mod http_client; mod metars; mod scheduler; mod smtp; mod system; - -#[derive(Debug, Clone)] -struct AppState { - client: Arc, -} +mod state; +mod utils; #[actix_web::main] async fn main() -> Result<(), Box> { initialize_environment()?; - db::initialize().await?; - scheduler::run(); + let state = Arc::new(AppState::new().await?); + scheduler::run(state.clone()); // Initialize admin user let admin_username = env::var("ADMIN_USERNAME"); @@ -39,7 +35,7 @@ async fn main() -> Result<(), Box> { if admin_username.is_ok() && admin_email.is_ok() && admin_password.is_ok() { let username = admin_username.unwrap(); let email = admin_email.unwrap(); - if User::select_by_email(&email).await.is_none() { + if User::select_by_email(&state.pool, &email).await.is_none() { log::debug!("Creating default administrator"); let password = admin_password.unwrap(); let password_hash = hash(&password)?; @@ -60,7 +56,7 @@ async fn main() -> Result<(), Box> { updated_at: Default::default(), created_at: Default::default(), }; - match admin_user.insert().await { + match admin_user.insert(&state.pool).await { Ok(_) => log::debug!("Default administrator was successfully created"), Err(err) => { log::warn!("{}", err); @@ -69,8 +65,6 @@ async fn main() -> Result<(), Box> { } } - let client = Arc::new(HttpClient::default()?); - let state = AppState { client }; let host = "0.0.0.0"; let port = env::var("API_PORT").unwrap_or("5000".to_string()); diff --git a/api/src/metars/metar_check.rs b/api/src/metars/metar_check.rs index d9fd1ec..76203f8 100644 --- a/api/src/metars/metar_check.rs +++ b/api/src/metars/metar_check.rs @@ -1,9 +1,8 @@ -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}; +use crate::state::AppState; #[derive(Debug, Serialize, Deserialize)] pub struct MetarCheck { @@ -14,8 +13,8 @@ pub struct MetarCheck { } impl MetarCheck { - pub async fn new(icao: String, status: bool) -> Self { - match Self::get(&icao).await { + pub async fn new(state: &AppState, icao: String, status: bool) -> Self { + match Self::get(state, &icao).await { Some(c) => Self { icao, status, @@ -31,15 +30,8 @@ impl MetarCheck { } } - pub async fn get(icao: &str) -> Option { - let mut conn = match redis_async_connection().await { - Ok(conn) => conn, - Err(err) => { - log::error!("Unable to get connection for ICAO {}: {}", icao, err); - return None; - } - }; - let result: RedisResult> = conn.get(icao).await; + pub async fn get(state: &AppState, icao: &str) -> Option { + let result: ApiResult> = state.get(icao).await; match result { Ok(Some(value)) => match serde_json::from_str(&value) { Ok(result) => Some(result), @@ -56,10 +48,9 @@ impl MetarCheck { } } - pub async fn insert(&self) -> ApiResult<()> { - let mut conn = redis_async_connection().await?; + pub async fn insert(&self, state: &AppState) -> ApiResult<()> { let value = serde_json::to_string(&self)?; - conn.set::<_, _, ()>(self.icao.as_str(), value).await?; + state.set(self.icao.as_str(), &value).await?; Ok(()) } diff --git a/api/src/metars/model.rs b/api/src/metars/model.rs index cee3794..4e8b538 100644 --- a/api/src/metars/model.rs +++ b/api/src/metars/model.rs @@ -1,21 +1,22 @@ use crate::airports::{Airport, UpdateAirport}; use crate::error::Error; -use crate::http_client::HttpClient; use crate::metars::MetarCheck; use crate::metars::utils::parse_metar_time; -use crate::{db, error::ApiResult}; +use crate::error::ApiResult; use chrono::{DateTime, Utc}; use flate2::read::GzDecoder; use reqwest::header::ETAG; use serde::{Deserialize, Serialize}; -use sqlx::{Postgres, QueryBuilder}; +use sqlx::{Pool, Postgres, QueryBuilder}; use std::collections::HashSet; use std::env; use std::fmt::Display; use std::io::{Cursor, Read}; use std::str::FromStr; use std::sync::OnceLock; +use regex::Regex; use utoipa::ToSchema; +use crate::state::AppState; static TIME_OFFSET: OnceLock = OnceLock::new(); @@ -278,8 +279,7 @@ struct MetarRow { } impl MetarRow { - async fn insert(&self) -> ApiResult<()> { - let pool = db::pool(); + async fn insert(&self, pool: &Pool) -> ApiResult<()> { sqlx::query(&format!( r#" INSERT INTO {} ( @@ -305,8 +305,7 @@ impl MetarRow { Ok(()) } - async fn insert_all(metars: Vec) -> ApiResult<()> { - let pool = db::pool(); + async fn insert_all(pool: &Pool, metars: Vec) -> ApiResult<()> { let chunk_size = 1000; for chunk in metars.chunks(chunk_size) { @@ -342,10 +341,10 @@ impl MetarRow { } impl Metar { - fn parse_multiple(metar_strings: &Vec<&str>) -> ApiResult> { + fn parse_multiple(pool: &Pool, metar_strings: &Vec<&str>) -> ApiResult> { let mut metars: Vec = vec![]; for metar_string in metar_strings { - match Self::parse(metar_string) { + match Self::parse(pool, metar_string) { Ok(metar) => metars.push(metar), Err(e) => { log::warn!("Failed to parse metar string: {}", e); @@ -357,7 +356,7 @@ impl Metar { Ok(metars) } - fn parse(metar_string: &str) -> ApiResult { + fn parse(pool: &Pool, metar_string: &str) -> ApiResult { if metar_string.is_empty() { return Err(Error::new( 404, @@ -368,7 +367,11 @@ impl Metar { log::trace!("Parsing METAR data: {}", metar_string); let mut metar: Self = Self::default(); metar.raw_text = metar_string.to_owned(); - let mut metar_parts: Vec<&str> = metar_string.split_whitespace().collect(); + let mut metar_parts: Vec<&str> = metar_string + .trim() + .trim_matches(|c| c == '"' || c == '\'' || c == '“' || c == '”' || c == '‘' || c == '’') + .trim() + .split_whitespace().collect(); if metar_parts.len() < 4 { return Err(Error::new( 500, @@ -380,8 +383,14 @@ impl Metar { } // Remove METAR at the start of the text - if metar_parts[0].to_string() == "METAR".to_string() { + let metar_re: Regex = Regex::new(r"(?i)^[\p{P}\s]*METAR[\p{P}\s]*$")?; + let speci_re: Regex = Regex::new(r"(?i)^[\p{P}\s]*SPECI[\p{P}\s]*$")?; + let token = metar_parts[0].trim(); + + if metar_re.is_match(token) { metar_parts.remove(0); + } else if speci_re.is_match(token) { + return Err(Error::new(500, format!("Unable to parse SPECI data: {}", metar_string))); } // Station Identifier @@ -880,8 +889,10 @@ impl Metar { // Update the airport's metar observation time let icao = metar.icao.clone(); let observation_time = metar.observation_time.clone(); + let pool = pool.clone(); tokio::spawn(async move { match Airport::update( + &pool.clone(), &icao, &UpdateAirport { icao: None, @@ -982,13 +993,13 @@ impl Metar { } pub async fn get_cached_remote_metars( - client: &HttpClient, + state: &AppState, etag: Option, ) -> ApiResult<(Vec, String)> { let base_url = env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set"); let url = format!("{}/data/cache/metars.cache.csv.gz", base_url); - match client.get(&url, etag.clone()).await { + match state.client.get(&url, etag.clone()).await { Ok(r) => { let new_etag = r .headers() @@ -1006,7 +1017,7 @@ impl Metar { for line in text.lines() { // Split off the first column let raw_text = line.splitn(2, ',').next().unwrap(); - match Metar::parse(raw_text) { + match Metar::parse(&state.pool, raw_text) { Ok(m) => output.push(m), Err(err) => { log::warn!("{}", err); @@ -1026,7 +1037,7 @@ impl Metar { } } - pub async fn get_remote_metars(client: &HttpClient, icaos: &Vec) -> ApiResult> { + pub async fn get_remote_metars(state: &AppState, icaos: &Vec) -> ApiResult> { let base_url = env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set"); // Query the remote API for the missing METAR data 10 at a time let icao_chunks = icaos @@ -1039,7 +1050,7 @@ impl Metar { "{}/api/data/metar?ids={}&hours=0&order=id,-obs", base_url, icao_chunk ); - let mut m = match client.get(&url, None).await { + let mut m = match state.client.get(&url, None).await { Ok(r) => match r.text().await { Ok(r) => { let metar_chunk = r @@ -1047,7 +1058,7 @@ impl Metar { .split("\n") .filter(|m| !m.trim().is_empty()) .collect(); - match Self::parse_multiple(&metar_chunk) { + match Self::parse_multiple(&state.pool, &metar_chunk) { Ok(m) => m, Err(err) => return Err(err), } @@ -1076,12 +1087,11 @@ impl Metar { }) } - pub async fn get_all_distinct(icao_list: &Vec) -> ApiResult> { + pub async fn get_all_distinct(pool: &Pool, icao_list: &Vec) -> ApiResult> { if icao_list.is_empty() { return Ok(Vec::new()); } - let pool = db::pool(); let metar_rows: Vec = sqlx::query_as::<_, MetarRow>(&format!( r#" SELECT DISTINCT ON (icao) * FROM {} @@ -1101,10 +1111,10 @@ impl Metar { } pub async fn get_or_update_metars( - client: &HttpClient, + state: &AppState, icaos: &Vec, ) -> ApiResult> { - let metars = Self::get_all_distinct(&icaos).await?; + let metars = Self::get_all_distinct(&state.pool, &icaos).await?; let current_time = Utc::now().timestamp(); let mut updated_metars: Vec = vec![]; @@ -1120,7 +1130,7 @@ impl Metar { // Handle outdated METARs if current_time > (metar.observation_time.timestamp() + time_offset()) { // If the METAR has previously been found, get the updated_at time, otherwise default - let refresh_seconds = match MetarCheck::get(&icao).await { + let refresh_seconds = match MetarCheck::get(state, &icao).await { Some(c) => current_time - c.updated_at.timestamp(), None => DEFAULT_REFRESH_DURATION, }; @@ -1143,15 +1153,15 @@ impl Metar { // Otherwise add the valid metar to the updated list else { found_metar_icaos.insert(icao.clone()); - let metar_check = MetarCheck::new(icao, true).await; - metar_check.insert().await?; + let metar_check = MetarCheck::new(state, icao, true).await; + metar_check.insert(state).await?; updated_metars.push(metar); } } // Add all METARs that were not in the returned database METARs for icao in &requested_icaos { - match MetarCheck::get(icao).await { + match MetarCheck::get(state, icao).await { Some(c) => { if current_time > (c.updated_at.timestamp() + DEFAULT_REFRESH_DURATION) { missing_metar_icaos.push(icao.to_string()); @@ -1169,7 +1179,7 @@ impl Metar { "Retrieving missing METAR data for {:?}", missing_metar_icaos ); - let mut remote_metars = Self::get_remote_metars(client, &missing_metar_icaos) + let mut remote_metars = Self::get_remote_metars(&state, &missing_metar_icaos) .await .unwrap_or_else(|err| { log::warn!("Unable to get remote METAR data; {}", err); @@ -1179,19 +1189,19 @@ impl Metar { // Insert missing METARs if remote_metars.len() > 0 { for remote_metar in remote_metars.clone() { - remote_metar.insert().await?; + remote_metar.insert(&state.pool).await?; found_metar_icaos.insert(remote_metar.icao.to_string()); - let mut metar_check = MetarCheck::new(remote_metar.icao.clone(), true).await; + let mut metar_check = MetarCheck::new(state, remote_metar.icao.clone(), true).await; metar_check.last_metar = Some(remote_metar); - metar_check.insert().await?; + metar_check.insert(state).await?; } updated_metars.append(&mut remote_metars); } // Update still missing METARs for difference in found_metar_icaos.symmetric_difference(&requested_icaos) { - let metar_check = MetarCheck::new(difference.to_string(), false).await; - metar_check.insert().await?; + let metar_check = MetarCheck::new(state, difference.to_string(), false).await; + metar_check.insert(state).await?; // Only add cached metar data if it's less than 4 hours old if let Some(last_metar) = metar_check.last_metar { let four_hours_ago = Utc::now() - chrono::Duration::hours(4); @@ -1205,26 +1215,26 @@ impl Metar { Ok(updated_metars) } - pub async fn update_metars(client: &HttpClient, etag: Option) -> ApiResult { - let (remote_metars, etag) = Self::get_cached_remote_metars(client, etag) + pub async fn update_metars(state: &AppState, etag: Option) -> ApiResult { + let (remote_metars, etag) = Self::get_cached_remote_metars(state, etag) .await .unwrap_or_else(|err| { log::warn!("Unable to get cached remote METAR data; {}", err); (vec![], String::new()) }); - MetarRow::insert_all(remote_metars).await?; + MetarRow::insert_all(&state.pool, remote_metars).await?; Ok(etag) } - pub async fn insert(&self) -> ApiResult<()> { + pub async fn insert(&self, pool: &Pool) -> ApiResult<()> { log::trace!( "Inserting metar {} with observation time {}", self.icao, self.observation_time ); let metar: MetarRow = self.to_row()?; - metar.insert().await?; + metar.insert(pool).await?; Ok(()) } } @@ -1235,43 +1245,45 @@ mod tests { #[tokio::test] async fn test_metar_parse() { + let state = AppState::new().await.unwrap(); + let mut metar_string = "METAR KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT \ -RA BR BKN015 OVC025 06/04 A2990 RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 \ RWY11 RAB07 CIG 013V017 CIG 017 RWY11 PRESFR SLP125 P0003 60009 T00640036 10066 21012 58033 \ TSNO $" .to_string(); - let metar = Metar::parse(&metar_string).unwrap(); + let metar = Metar::parse(&state.pool, &metar_string).unwrap(); dbg!(&metar.observation_time); metar_string = "KMIA 090053Z 33004KT 10SM FEW015 FEW024 SCT075 SCT250 25/22 A2990 RMK AO2 \ SLP126 T02500217 $" .to_string(); - let metar = Metar::parse(&metar_string).unwrap(); + let metar = Metar::parse(&state.pool, &metar_string).unwrap(); dbg!(&metar.observation_time); metar_string = "KMRB 082253Z 30014G23KT 10SM CLR 05/M12 A3002 RMK AO2 PK WND 30028/2157 SLP168 T00501117" .to_string(); - let metar = Metar::parse(&metar_string).unwrap(); + let metar = Metar::parse(&state.pool, &metar_string).unwrap(); dbg!(&metar.observation_time); metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 \ 10133 20078 53002 PNO $" .to_string(); - let metar = Metar::parse(&metar_string).unwrap(); + let metar = Metar::parse(&state.pool, &metar_string).unwrap(); dbg!(&metar.observation_time); metar_string = "KSLK 162351Z AUTO VRB03KT 1SM -SN BR FEW007 OVC014 00/M02 A2974 RMK AO2 \ SLP090 P0001 60004 T00001017 10000 21011 53026" .to_string(); - let metar = Metar::parse(&metar_string).unwrap(); + let metar = Metar::parse(&state.pool, &metar_string).unwrap(); dbg!(&metar.observation_time); metar_string = "KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT -RA BR BKN015 OVC025 \ SCTCB FEW123TCU 06/04 A2990 RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 RWY11 \ RAB07 CIG 013V017 CIG 017 RWY11 PRESFR SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $" .to_string(); - let metar = Metar::parse(&metar_string).unwrap(); + let metar = Metar::parse(&state.pool, &metar_string).unwrap(); dbg!(&metar.observation_time); dbg!(&metar.sky_condition); } diff --git a/api/src/metars/routes.rs b/api/src/metars/routes.rs index f6b482a..6edfde5 100644 --- a/api/src/metars/routes.rs +++ b/api/src/metars/routes.rs @@ -24,7 +24,7 @@ struct MetarQuery { ), )] #[get("")] -async fn find_all(req: HttpRequest) -> HttpResponse { +async fn find_all(state: web::Data, req: HttpRequest) -> HttpResponse { let parameters = web::Query::::from_query(req.query_string()).unwrap(); let icao_option = ¶meters.icaos; if let None = icao_option { @@ -37,7 +37,7 @@ async fn find_all(req: HttpRequest) -> HttpResponse { }; let icaos: Vec = icao_string.split(',').map(|s| s.to_uppercase()).collect(); - let metars = match Metar::get_all_distinct(&icaos).await { + let metars = match Metar::get_all_distinct(&state.pool, &icaos).await { Ok(a) => a, Err(err) => { error!("{}", err); @@ -61,8 +61,8 @@ async fn find_all(req: HttpRequest) -> HttpResponse { ) )] #[put("")] -async fn refresh_metars(data: web::Data, req: HttpRequest, _auth: Auth) -> HttpResponse { - let client = data.client.clone(); +async fn refresh_metars(state: web::Data, req: HttpRequest, _auth: Auth) -> HttpResponse { + let client = state.client.clone(); let parameters = web::Query::::from_query(req.query_string()).unwrap(); let icao_option = ¶meters.icaos; if let None = icao_option { @@ -75,7 +75,7 @@ async fn refresh_metars(data: web::Data, req: HttpRequest, _auth: Auth }; let icaos: Vec = icao_string.split(',').map(|s| s.to_uppercase()).collect(); - let metars = match Metar::get_or_update_metars(&client, &icaos).await { + let metars = match Metar::get_or_update_metars(&state, &icaos).await { Ok(a) => a, Err(err) => { error!("{}", err); diff --git a/api/src/scheduler.rs b/api/src/scheduler.rs index bb661bc..3f814b2 100644 --- a/api/src/scheduler.rs +++ b/api/src/scheduler.rs @@ -1,19 +1,13 @@ -use crate::http_client::HttpClient; use crate::metars::Metar; use chrono::{DateTime, Utc}; use std::env; +use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::time::interval; +use crate::state::AppState; -pub fn run() { - tokio::spawn(async { - let client = match HttpClient::default() { - Ok(client) => client, - Err(err) => { - log::error!("Failed to create HTTP client: {}", err); - return; - } - }; +pub fn run(state: Arc) { + tokio::spawn(async move { let seconds = env::var("METAR_INTERVAL") .unwrap_or("300".to_string()) .parse::() @@ -32,7 +26,7 @@ pub fn run() { log::debug!("METAR update started at {}", start_utc); // Run the update - match Metar::update_metars(&client, etag.clone()).await { + match Metar::update_metars(&state, etag.clone()).await { Ok(new_etag) => etag = Some(new_etag), Err(err) => log::error!("METAR update failed: {}", err), } diff --git a/api/src/state.rs b/api/src/state.rs new file mode 100644 index 0000000..133ef8d --- /dev/null +++ b/api/src/state.rs @@ -0,0 +1,167 @@ +use std::env; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use redis::aio::ConnectionManager; +use redis::AsyncTypedCommands; +use s3::{Bucket, BucketConfiguration, Region}; +use s3::creds::Credentials; +use sqlx::{Pool, Postgres}; +use sqlx::postgres::PgPoolOptions; +use crate::error::ApiResult; +use crate::http_client::HttpClient; + +#[derive(Clone)] +pub struct AppState { + pub client: HttpClient, + pub pool: Pool, + pub connection_manager: Arc>, + pub bucket: Box, +} + +impl AppState { + pub async fn new() -> ApiResult { + let client = HttpClient::default()?; + + let pool: Pool = { + let user = env::var("POSTGRES_USER").unwrap_or("raac".to_string()); + let password = env::var("POSTGRES_PASSWORD").expect("POSTGRES_PASSWORD must be set"); + let host: String = env::var("POSTGRES_HOST").expect("POSTGRES_HOST must be set"); + let port = env::var("POSTGRES_PORT").unwrap_or("5432".to_string()); + let name = env::var("POSTGRES_DB").unwrap_or("raac".to_string()); + + let url = format!( + "postgres://{}:{}@{}:{}/{}", + &user, &password, &host, &port, &name + ); + + log::info!( + "Connecting to database at postgres://{}:*****@{}:{}/{}...", + &user, + &host, + &port, + &name + ); + + let connections = env::var("POSTGRES_CONNECTIONS") + .unwrap_or("5".to_string()) + .parse::() + .unwrap_or(5); + let timeout = env::var("POSTGRES_TIMEOUT") + .unwrap_or("30".to_string()) + .parse::() + .unwrap_or(30); + + PgPoolOptions::new() + .max_connections(connections) + .acquire_timeout(Duration::from_secs(timeout)) + .connect(&url) + .await + .expect("Failed to create postgres pool") + }; + + let run_migrations = env::var("POSTGRES_MIGRATE") + .unwrap_or("true".to_string()) + .parse::() + .unwrap_or(true); + + if run_migrations { + log::debug!("Running database migrations..."); + match sqlx::migrate!().run(&pool).await { + Ok(_) => log::debug!("Database migrations completed"), + Err(err) => log::error!("Failed to run database migrations: {}", err), + }; + } + + let connection_manager: ConnectionManager = { + let host = env::var("VALKEY_HOST").unwrap_or("localhost".to_string()); + let port = env::var("VALKEY_PORT").unwrap_or("6379".to_string()); + let url = format!("redis://{}:{}", host, port); + log::info!("Connecting to in-memory datastore at {}...", &url); + let client = redis::Client::open(url).expect("Failed to create in-memory datastore client"); + ConnectionManager::new(client) + .await + .expect("Failed to create in-memory datastore connection manager") + }; + + // Setup Bucket connection + let bucket = { + let protocol = std::env::var("MINIO_PROTOCOL").unwrap_or("http".to_string()); + let host = std::env::var("MINIO_HOST").unwrap_or("localhost".to_string()); + let port = std::env::var("MINIO_PORT").unwrap_or("9000".to_string()); + let user = std::env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set"); + let password = std::env::var("MINIO_ROOT_PASSWORD").expect("MINIO_ROOT_PASSWORD is not set"); + let bucket_name = std::env::var("MINIO_BUCKET").unwrap_or("aviation".to_string()); + let url = format!("{}://{}:{}", protocol, host, port); + + let region = Region::Custom { + region: "".to_string(), + endpoint: url.to_string(), + }; + + let credentials = Credentials { + access_key: Some(user), + secret_key: Some(password), + security_token: None, + session_token: None, + expiration: None, + }; + + let bucket = Bucket::new(&bucket_name, region.clone(), credentials.clone())?.with_path_style(); + log::info!("Checking for object in bucket at {}", ®ion.endpoint()); + match bucket.head_object("/").await { + Ok(_) => bucket, + Err(_) => { + log::debug!("Creating '{}' bucket", &bucket_name); + let response = match Bucket::create_with_path_style( + &bucket_name, + region, + credentials, + BucketConfiguration::default(), + ) + .await + { + Ok(response) => response, + Err(err) => { + log::error!("Failed to create bucket '{}': {}", &bucket_name, err); + return Err(err.into()); + } + }; + response.bucket + } + } + }; + + Ok(Self { + client, + pool, + connection_manager: Arc::new(Mutex::new(connection_manager)), + bucket, + }) + } + + pub async fn set(&self, key: &str, value: &str) -> ApiResult<()> { + let mut connection_manager = self.connection_manager.lock()?; + connection_manager.set(key, value).await?; + Ok(()) + } + + pub async fn set_ex(&self, key: &str, value: &str, seconds: u64) -> ApiResult<()> { + let mut connection_manager = self.connection_manager.lock()?; + connection_manager.set_ex(key, value, seconds).await?; + Ok(()) + } + + pub async fn get(&self, key: &str) -> ApiResult> { + let mut connection_manager = self.connection_manager.lock()?; + match connection_manager.get(key).await { + Ok(value) => Ok(value), + Err(_) => Ok(None), + } + } + + pub async fn del(&self, key: &str) -> ApiResult<()> { + let mut connection_manager = self.connection_manager.lock()?; + connection_manager.del(key).await?; + Ok(()) + } +} \ No newline at end of file diff --git a/api/src/utils.rs b/api/src/utils.rs new file mode 100644 index 0000000..1a90455 --- /dev/null +++ b/api/src/utils.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Paged { + pub data: Vec, + pub page: u32, + pub limit: u32, + pub total: i64, +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 90e1af1..7f90047 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: volumes: - ./ssl:/etc/nginx/ssl/ networks: - - default + - web <<: *default_restart postgres: @@ -37,36 +37,32 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} volumes: - - /data/aviation/postgres:/var/lib/postgresql/data - - /data/aviation/postgres_logs:/var/log + - postgres:/var/lib/postgresql/data + - postgres_logs:/var/log ports: - "${POSTGRES_PORT:-5432}:5432" - networks: - - default profiles: - backend <<: *default_restart - redis: - image: gitea.bensherriff.com/homelab/redis:8.0-M03 - container_name: aviation-redis + valkey: + image: valkey/valkey:8.1.3 + container_name: aviation-valkey volumes: - - redis:/data + - valkey:/data ports: - - "${REDIS_PORT:-6379}:6379" + - "${VALKEY_PORT:-6379}:6379" healthcheck: - test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ] + test: [ "CMD", "valkey-cli", "--raw", "incr", "ping" ] interval: 10s timeout: 5s retries: 3 - networks: - - default profiles: - backend <<: *default_restart minio: - image: gitea.bensherriff.com/homelab/minio:RELEASE.2025-02-28T09-55-16Z + image: minio/minio:RELEASE.2025-07-23T15-54-02Z container_name: aviation-minio environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER} @@ -78,8 +74,6 @@ services: ports: - "${MINIO_PORT:-9000}:9000" - "${MINIO_INTERNAL_PORT:-9001}:9001" - networks: - - default profiles: - backend command: server --console-address ":9001" /data @@ -97,8 +91,8 @@ services: API_PORT: 5000 POSTGRES_HOST: aviation-postgres POSTGRES_PORT: 5432 - REDIS_HOST: aviation-redis - REDIS_PORT: 6379 + VALKEY_HOST: aviation-valkey + VALKEY_PORT: 6379 MINIO_HOST: aviation-minio MINIO_PORT: 9000 TEMPLATE_DIR: /templates @@ -109,36 +103,12 @@ services: - "${API_PORT:-5000}:5000" depends_on: - postgres - - redis + - valkey - minio - networks: - - default profiles: - api <<: *default_restart - # Development Containers -# ui-dev: -# image: gitea.bensherriff.com/bsherriff/aviation-ui:latest -# container_name: aviation-ui-dev -# build: -# context: . -# dockerfile: Dockerfile -# env_file: *env -# environment: -# - VITE_NODE_ENV=${VITE_NODE_ENV:-development} -# ports: -# - "${UI_PORT:-3000}:3000" -# volumes: -# - ./ui/src:/app/src -# - ./ui/public:/app/public -# - ./ui/styles:/app/styles -# networks: -# - default -# profiles: -# - dev -# command: ["npm", "run", "dev"] -# <<: *default_restart mailpit: image: axllent/mailpit container_name: mailpit @@ -152,16 +122,17 @@ services: - "${MAILPIT_SMTP_PORT:-1025}:1025" volumes: - mailpit:/data - networks: - - default profiles: - dev <<: *default_restart volumes: - redis: + postgres: + postgres_logs: + valkey: minio: mailpit: networks: - default: + web: + driver: bridge diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 261ac58..c88bda2 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -4,31 +4,33 @@ worker_processes auto; error_log /var/log/nginx/error.log notice; pid /var/run/nginx.pid; - events { - worker_connections 1024; + worker_connections 1024; } - http { - include /etc/nginx/mime.types; - default_type application/octet-stream; + include /etc/nginx/mime.types; + default_type application/octet-stream; - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; - sendfile on; - #tcp_nopush on; + # allow HTTP/2 on the front‐end + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; - keepalive_timeout 65; + sendfile on; + tcp_nopush on; - #gzip on; + keepalive_timeout 65; - # Set client limit to 100 MB - client_max_body_size 100M; + gzip on; - include /etc/nginx/conf.d/*.conf; + # Set client limit to 100 MB + client_max_body_size 100M; + + include /etc/nginx/conf.d/*.conf; } diff --git a/ui/src/App.css b/ui/src/App.css index 37e711c..9c6c973 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -1,12 +1,12 @@ -/* Set up Flexbox layout */ -.App { - display: flex; - flex-direction: column; +html, body, #root { height: 100%; } .map-wrapper { + display: flex; + flex-direction: column; flex: 1; + min-height: 0; } .leaflet-container { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e1d4cc0..60f4bae 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -6,7 +6,6 @@ import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; import markerIcon from 'leaflet/dist/images/marker-icon.png'; import markerShadow from 'leaflet/dist/images/marker-shadow.png'; import L from 'leaflet'; -import { Header } from '@components/Header'; import AirportLayer from '@components/AirportLayer.tsx'; import { useEffect, useState } from 'react'; import { Airport } from '@lib/airport.types.ts'; @@ -16,7 +15,6 @@ import { IconBuildingAirport, IconRadar } from '@tabler/icons-react'; import { GroupControl } from '@components/GroupControl.tsx'; import { AirportDrawer } from '@components/AirportDrawer'; import { LocateControl } from '@components/LocateControl.tsx'; -import { Footer } from '@components/Footer'; // Fix Leaflet's default icon path issues with Webpack // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error @@ -91,57 +89,54 @@ function App() { } return ( -
-
-
- + + + + {layerMap.map((layer, index) => ( + + + + ))} + + + {rainViewerUrl && showRadar && } + + + + + + }, + { + title: 'Toggle non‐METAR airports', + active: showNoMetar, + onClick: toggleShowNoMetar, + icon: + } ]} - scrollWheelZoom={true} - zoomControl={false} - markerZoomAnimation={false} - > - - - {layerMap.map((layer, index) => ( - - - - ))} - - - {rainViewerUrl && showRadar && } - - - - - - }, - { - title: 'Toggle non‐METAR airports', - active: showNoMetar, - onClick: toggleShowNoMetar, - icon: - } - ]} - /> - -
-
+ /> +
); } diff --git a/ui/src/components/Administration.tsx b/ui/src/components/Administration.tsx index 9cddae3..03a3862 100644 --- a/ui/src/components/Administration.tsx +++ b/ui/src/components/Administration.tsx @@ -1,4 +1,3 @@ -import { Header } from '@components/Header'; import { useUserContext } from '@components/context/UserContext.tsx'; import { AirportTable } from '@components/AirportTable'; import { AirportDrop } from '@components/AirportDrop'; @@ -13,7 +12,6 @@ export function Administration() { return ( <> -
diff --git a/ui/src/components/AirportDrawer/index.tsx b/ui/src/components/AirportDrawer/index.tsx index 33b5edf..22218af 100644 --- a/ui/src/components/AirportDrawer/index.tsx +++ b/ui/src/components/AirportDrawer/index.tsx @@ -71,7 +71,7 @@ export function AirportDrawer({ onClose={() => setAirport(null)} withinPortal zIndex={1000} - styles={{ root: { padding: 0, margin: 0, width: 0, height: 0, backgroundColor: 'red' } }} + styles={{ root: { padding: 0, margin: 0, width: 0, height: 0 } }} padding='md' size={isMobile ? '100%' : 'md'} position='left' diff --git a/ui/src/components/Footer/Footer.module.css b/ui/src/components/Footer/Footer.module.css index aeed945..5def4b7 100644 --- a/ui/src/components/Footer/Footer.module.css +++ b/ui/src/components/Footer/Footer.module.css @@ -1,9 +1,11 @@ .footer { - background: #32495f; + height: 48px; + background: #2b2d31; border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); } .inner { + height: 48px; display: flex; justify-content: space-between; align-items: center; diff --git a/ui/src/components/Footer/index.tsx b/ui/src/components/Footer/index.tsx index b6cebc4..be81201 100644 --- a/ui/src/components/Footer/index.tsx +++ b/ui/src/components/Footer/index.tsx @@ -40,7 +40,7 @@ export function Footer() { - © {new Date().getFullYear()} Aviation Data + © {new Date().getFullYear()} bensherriff.com {!isMobile && ( diff --git a/ui/src/components/Header/Header.module.css b/ui/src/components/Header/Header.module.css index 304b5f8..0b5195b 100644 --- a/ui/src/components/Header/Header.module.css +++ b/ui/src/components/Header/Header.module.css @@ -2,7 +2,7 @@ height: 56px; padding: 0 16px 0 16px; /*background-color: var(--mantine-color-body);*/ - background: #32495f; + background: #2b2d31; border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); } diff --git a/ui/src/components/Header/index.tsx b/ui/src/components/Header/index.tsx index fc44e55..5990996 100644 --- a/ui/src/components/Header/index.tsx +++ b/ui/src/components/Header/index.tsx @@ -1,4 +1,4 @@ -import { Avatar, Box, Burger, Button, Group, Text } from '@mantine/core'; +import { Avatar, Box, Burger, Button, Drawer, Group, Text } from '@mantine/core'; import { useDisclosure, useMediaQuery, useToggle } from '@mantine/hooks'; import classes from './Header.module.css'; import { HeaderModal } from '@components/Header/HeaderModal.tsx'; @@ -146,7 +146,7 @@ export function Header() { - + Aviation Data @@ -173,6 +173,21 @@ export function Header() {
+ + + + + test + + + ); diff --git a/ui/src/components/MainLayout.tsx b/ui/src/components/MainLayout.tsx new file mode 100644 index 0000000..2495ed2 --- /dev/null +++ b/ui/src/components/MainLayout.tsx @@ -0,0 +1,29 @@ +import { AppShell } from '@mantine/core'; +import { Outlet } from 'react-router'; +import { Header } from '@components/Header'; +import { Footer } from '@components/Footer'; + +export function MainLayout() { + return ( + + +
+ + +
+ +
+
+ +