This commit is contained in:
2025-09-19 19:33:53 -04:00
parent 8844ee75fe
commit 84312d0b50
36 changed files with 799 additions and 694 deletions

View File

@@ -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 \

91
Taskfile.yml Normal file
View File

@@ -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

229
api/Cargo.lock generated
View File

@@ -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"

View File

@@ -1,6 +1,6 @@
[package]
name = "api"
version = "0.1.2"
version = "0.1.3"
edition = "2024"
authors = ["Ben Sherriff <ben@bensherriff.com>"]
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"

View File

@@ -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<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
// Check for API key
let state = match req.app_data::<web::Data<AppState>>() {
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<State>
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,

View File

@@ -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<Self> {
let result: Option<String> = 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<Self> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<Option<String>> = 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);
}

View File

@@ -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<RegisterRequest>, req: HttpRequest) -> HttpResponse {
async fn register(state: web::Data<AppState>, user: web::Json<RegisterRequest>, 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<RegisterRequest>, 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<RegisterRequest>, 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<AppState>,
request: web::Json<ConfirmEmail>,
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<AppState>, 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 {
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<LoginRequest>, req: HttpRequest) -> HttpResponse {
async fn login(state: web::Data<AppState>, request: web::Json<LoginRequest>, 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<LoginRequest>, 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<LoginRequest>, req: HttpRequest) -> HttpRespon
)
)]
#[post("/logout")]
async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
async fn logout(state: web::Data<AppState>, 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<AppState>, 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<AppState>, 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<AppState>,
request: web::Json<ChangePassword>,
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<PasswordReset>, req: HttpRequest) -> HttpResponse {
async fn reset_password(state: web::Data<AppState>, request: web::Json<PasswordReset>, 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<AppState>,
request: web::Json<ConfirmPasswordReset>,
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<AppState>, 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<String>, auth: Auth) -> HttpResponse {
async fn add_favorite(state: web::Data<AppState>, icao: web::Path<String>, 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<String>, auth: Auth) -> HttpResponse {
)
)]
#[delete("/profile/favorites/{icao}")]
async fn remove_favorite(icao: web::Path<String>, auth: Auth) -> HttpResponse {
async fn remove_favorite(state: web::Data<AppState>, icao: web::Path<String>, 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),
}

View File

@@ -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<Self> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<Option<String>> = conn.get(session_id).await;
pub async fn get(state: &AppState, session_id: &str) -> ApiResult<Self> {
let result: Option<String> = 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<Self> {
let mut session = Self::verify(session_id, ip_address).await?;
pub async fn replace(state: &AppState, session_id: &str, ip_address: &str) -> ApiResult<Self> {
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<Self> {
let session = Self::get(session_id).await?;
pub async fn verify(state: &AppState, session_id: &str, ip_address: &str) -> ApiResult<Self> {
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,

View File

@@ -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<User> {
let pool = db::pool();
pub async fn update(&self, pool: &Pool<Postgres>, username: &str) -> ApiResult<User> {
let mut query_builder: QueryBuilder<Postgres> =
QueryBuilder::new(&format!("UPDATE {} SET ", TABLE_NAME));
@@ -189,8 +186,7 @@ pub struct User {
}
impl User {
pub async fn select(username: &str) -> Option<Self> {
let pool = db::pool();
pub async fn select(pool: &Pool<Postgres>, username: &str) -> Option<Self> {
let user: Option<Self> = 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<Self> {
let pool = db::pool();
pub async fn select_by_email(pool: &Pool<Postgres>, email: &str) -> Option<Self> {
let user: Option<Self> = 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<Postgres>) -> 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<User> {
let pool = db::pool();
pub async fn insert(&self, pool: &Pool<Postgres>) -> ApiResult<User> {
let user: User = sqlx::query_as::<_, Self>(&format!(
r#"
INSERT INTO {} (

View File

@@ -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<Vec<String>> {
let pool = db::pool();
pub async fn select_all(pool: &Pool<Postgres>, username: &str) -> ApiResult<Vec<String>> {
let user_favorites: Vec<UserFavorite> = 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<Postgres>, 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<Postgres>, username: &str, icao: &str) -> ApiResult<()> {
sqlx::query(&format!(
r#"
DELETE FROM {} WHERE username = $1 AND icao = $2

View File

@@ -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<AirportRow> for Airport {
}
impl Airport {
pub async fn select(icao: &str, metar: bool) -> Option<Self> {
let pool = db::pool();
pub async fn select(pool: &Pool<Postgres>, icao: &str, metar: bool) -> Option<Self> {
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<Vec<Self>> {
let pool = db::pool();
pub async fn select_all(pool: &Pool<Postgres>, query: &AirportQuery) -> ApiResult<Vec<Self>> {
let mut builder =
QueryBuilder::<Postgres>::new(format!("SELECT {} FROM {}", DEFAULT_COLUMNS, TABLE_NAME));
@@ -410,10 +407,10 @@ impl Airport {
// Bulk update airport subfields
let icaos: Vec<String> = 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<Postgres>, query: &AirportQuery) -> i64 {
let mut builder = QueryBuilder::<Postgres>::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<Self> {
let pool = db::pool();
pub async fn insert(&self, pool: &Pool<Postgres>) -> ApiResult<Self> {
let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
let mut all_frequency_rows: Vec<CommunicationRow> = 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<Self>) -> ApiResult<()> {
let pool = db::pool();
pub async fn insert_all(pool: &Pool<Postgres>, airports: Vec<Self>) -> ApiResult<()> {
let chunk_size = 1000;
let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
let mut all_frequency_rows: Vec<CommunicationRow> = 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<Postgres>, icao: &str, airport: &UpdateAirport) -> ApiResult<()> {
let mut query_builder: QueryBuilder<Postgres> =
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<Postgres>, 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<Postgres>) -> ApiResult<()> {
sqlx::query(&format!(
r#"
DELETE FROM {} WHERE true

View File

@@ -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<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
let pool = db::pool();
pub async fn select_all_map(pool: &Pool<Postgres>, icaos: &Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
let frequency_rows: Vec<CommunicationRow> = 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<Vec<Self>> {
let pool = db::pool();
pub async fn select_all(pool: &Pool<Postgres>, icao: &str) -> ApiResult<Vec<Self>> {
let frequency_row: Vec<CommunicationRow> = 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<CommunicationRow>) -> ApiResult<()> {
let pool = db::pool();
pub async fn insert_all(pool: &Pool<Postgres>, communications: &Vec<CommunicationRow>) -> ApiResult<()> {
let chunk_size = 1000;
for chunk in communications.chunks(chunk_size) {

View File

@@ -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<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
let pool = db::pool();
pub async fn select_all_map(pool: &Pool<Postgres>, icaos: &Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
let runway_rows: Vec<RunwayRow> = 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<Vec<Self>> {
let pool = db::pool();
pub async fn select_all(pool: &Pool<Postgres>, icao: &str) -> ApiResult<Vec<Self>> {
let runway_rows: Vec<RunwayRow> = 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<RunwayRow>) -> ApiResult<()> {
let pool = db::pool();
pub async fn insert_all(pool: &Pool<Postgres>, runways: &Vec<RunwayRow>) -> ApiResult<()> {
let chunk_size = 1000;
for chunk in runways.chunks(chunk_size) {

View File

@@ -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<AppState>, 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<AppState>, req: HttpRequest) -> HttpResponse {
let mut query = match web::Query::<AirportQuery>::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<String>, req: HttpRequest) -> HttpResponse {
async fn get_airport(state: web::Data<AppState>, icao: web::Path<String>, req: HttpRequest) -> HttpResponse {
let metar = match web::Query::<AirportQuery>::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<String>, 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<String>, req: HttpRequest) -> HttpResponse
)
)]
#[post("")]
async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
async fn insert_airport(state: web::Data<AppState>, airport: web::Json<Airport>, 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<Airport>, auth: Auth) -> HttpResponse
)]
#[put("/{icao}")]
async fn update_airport(
state: web::Data<AppState>,
icao: web::Path<String>,
airport: web::Json<UpdateAirport>,
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<AppState>, 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<String>, auth: Auth) -> HttpResponse {
async fn delete_airport(state: web::Data<AppState>, icao: web::Path<String>, 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);

View File

@@ -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<Pool<Postgres>> = OnceLock::new();
static REDIS: OnceLock<RedisClient> = OnceLock::new();
static BUCKET: OnceLock<Bucket> = 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 {}", &region.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<Postgres> {
POOL.get().unwrap()
}
fn redis() -> &'static RedisClient {
REDIS.get().unwrap()
}
// pub fn redis_connection() -> RedisResult<redis::Connection> {
// let conn = redis().get_connection()?;
// Ok(conn)
// }
pub async fn redis_async_connection() -> RedisResult<RedisConnection> {
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<ResponseData> {
let response = BUCKET.get().unwrap().put_object(path, content).await?;
Ok(response)
}
pub async fn get_file(path: &str) -> ApiResult<Vec<u8>> {
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<ResponseData> {
let response = BUCKET.get().unwrap().delete_object(path).await?;
Ok(response)
}
#[derive(Serialize, Deserialize)]
pub struct Paged<T> {
pub data: T,
pub page: u32,
pub limit: u32,
pub total: i64,
}

View File

@@ -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<T> = Result<T, Error>;
@@ -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<regex::Error> for Error {
Self::new(500, error.to_string())
}
}
impl<'a, T> From<PoisonError<MutexGuard<'a, T>>> for Error {
fn from(_: PoisonError<MutexGuard<'a, T>>) -> Self {
Self::new(500, "Failed to acquire lock".to_string())
}
}

View File

@@ -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<HttpClient>,
}
mod state;
mod utils;
#[actix_web::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
}
}
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());

View File

@@ -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<MetarCheck> {
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<Option<String>> = conn.get(icao).await;
pub async fn get(state: &AppState, icao: &str) -> Option<MetarCheck> {
let result: ApiResult<Option<String>> = 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(())
}

View File

@@ -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<i64> = 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<Postgres>) -> ApiResult<()> {
sqlx::query(&format!(
r#"
INSERT INTO {} (
@@ -305,8 +305,7 @@ impl MetarRow {
Ok(())
}
async fn insert_all(metars: Vec<Metar>) -> ApiResult<()> {
let pool = db::pool();
async fn insert_all(pool: &Pool<Postgres>, metars: Vec<Metar>) -> 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<Vec<Self>> {
fn parse_multiple(pool: &Pool<Postgres>, metar_strings: &Vec<&str>) -> ApiResult<Vec<Self>> {
let mut metars: Vec<Self> = 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<Self> {
fn parse(pool: &Pool<Postgres>, metar_string: &str) -> ApiResult<Self> {
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<String>,
) -> ApiResult<(Vec<Self>, 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<String>) -> ApiResult<Vec<Self>> {
pub async fn get_remote_metars(state: &AppState, icaos: &Vec<String>) -> ApiResult<Vec<Self>> {
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<String>) -> ApiResult<Vec<Self>> {
pub async fn get_all_distinct(pool: &Pool<Postgres>, icao_list: &Vec<String>) -> ApiResult<Vec<Self>> {
if icao_list.is_empty() {
return Ok(Vec::new());
}
let pool = db::pool();
let metar_rows: Vec<MetarRow> = 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<String>,
) -> ApiResult<Vec<Self>> {
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<Self> = 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<String>) -> ApiResult<String> {
let (remote_metars, etag) = Self::get_cached_remote_metars(client, etag)
pub async fn update_metars(state: &AppState, etag: Option<String>) -> ApiResult<String> {
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<Postgres>) -> 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);
}

View File

@@ -24,7 +24,7 @@ struct MetarQuery {
),
)]
#[get("")]
async fn find_all(req: HttpRequest) -> HttpResponse {
async fn find_all(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
let icao_option = &parameters.icaos;
if let None = icao_option {
@@ -37,7 +37,7 @@ async fn find_all(req: HttpRequest) -> HttpResponse {
};
let icaos: Vec<String> = 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<AppState>, req: HttpRequest, _auth: Auth) -> HttpResponse {
let client = data.client.clone();
async fn refresh_metars(state: web::Data<AppState>, req: HttpRequest, _auth: Auth) -> HttpResponse {
let client = state.client.clone();
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
let icao_option = &parameters.icaos;
if let None = icao_option {
@@ -75,7 +75,7 @@ async fn refresh_metars(data: web::Data<AppState>, req: HttpRequest, _auth: Auth
};
let icaos: Vec<String> = 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);

View File

@@ -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<AppState>) {
tokio::spawn(async move {
let seconds = env::var("METAR_INTERVAL")
.unwrap_or("300".to_string())
.parse::<u64>()
@@ -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),
}

167
api/src/state.rs Normal file
View File

@@ -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<Postgres>,
pub connection_manager: Arc<Mutex<ConnectionManager>>,
pub bucket: Box<Bucket>,
}
impl AppState {
pub async fn new() -> ApiResult<Self> {
let client = HttpClient::default()?;
let pool: Pool<Postgres> = {
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::<u32>()
.unwrap_or(5);
let timeout = env::var("POSTGRES_TIMEOUT")
.unwrap_or("30".to_string())
.parse::<u64>()
.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::<bool>()
.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 {}", &region.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<Option<String>> {
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(())
}
}

9
api/src/utils.rs Normal file
View File

@@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Paged<T> {
pub data: Vec<T>,
pub page: u32,
pub limit: u32,
pub total: i64,
}

View File

@@ -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

View File

@@ -4,12 +4,10 @@ worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
@@ -20,12 +18,16 @@ http {
access_log /var/log/nginx/access.log main;
# allow HTTP/2 on the frontend
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
sendfile on;
#tcp_nopush on;
tcp_nopush on;
keepalive_timeout 65;
#gzip on;
gzip on;
# Set client limit to 100 MB
client_max_body_size 100M;

View File

@@ -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 {

View File

@@ -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,10 +89,9 @@ function App() {
}
return (
<div className='App'>
<Header />
<div className='map-wrapper'>
<div className='map-wrapper' style={{ flex: 1, minHeight: 0 }}>
<MapContainer
style={{ height: '100%', width: '100%' }}
className='leaflet-container'
attributionControl={false}
center={defaultCenter}
@@ -141,8 +138,6 @@ function App() {
/>
</MapContainer>
</div>
<Footer />
</div>
);
}

View File

@@ -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 (
<>
<Header />
<AirportTable />
<AirportDrop />
</>

View File

@@ -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'

View File

@@ -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;

View File

@@ -40,7 +40,7 @@ export function Footer() {
</a>
</Text>
<Divider orientation={'vertical'} />
<Text size='sm'>© {new Date().getFullYear()} Aviation Data</Text>
<Text size='sm'>© {new Date().getFullYear()} bensherriff.com</Text>
</Group>
{!isMobile && (
<Group gap='xs' justify='flex-end' wrap='nowrap'>

View File

@@ -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));
}

View File

@@ -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() {
<Group justify='space-between' h='100%'>
<Group align='center' gap='xs'>
<Link to='/'>
<Avatar src='/logo.svg' alt='logo' onClick={toggle} />
<Avatar src='/logo.svg' alt='logo' />
</Link>
<Text size={'xl'}>Aviation Data</Text>
</Group>
@@ -173,6 +173,21 @@ export function Header() {
</Group>
</header>
</Box>
<Drawer.Root
opened={opened}
onClose={toggle}
zIndex={1001}
padding="md"
size="40%"
position="right"
>
<Drawer.Overlay />
<Drawer.Content>
<Drawer.Body>
test
</Drawer.Body>
</Drawer.Content>
</Drawer.Root>
<HeaderModal type={modalType} toggle={modalToggle} login={loginUser} register={registerUser} />
</>
);

View File

@@ -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 (
<AppShell padding={0} mih={'100dvh'}>
<AppShell.Header>
<Header />
</AppShell.Header>
<AppShell.Main p={0} style={{ display: 'flex', minHeight: 0 }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0
}}
>
<Outlet />
</div>
</AppShell.Main>
<AppShell.Footer>
<Footer />
</AppShell.Footer>
</AppShell>
);
}

View File

@@ -1,4 +1,3 @@
import { Header } from '@components/Header';
import { useUserContext } from '@components/context/UserContext.tsx';
import { NotFound } from '@components/NotFound';
@@ -11,7 +10,6 @@ export function Profile() {
return (
<>
<Header />
Todo: profile {user?.firstName}
</>
);

View File

@@ -1,3 +1,4 @@
import 'leaflet/dist/leaflet.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
@@ -9,6 +10,7 @@ import { BrowserRouter, Route, Routes } from 'react-router';
import { Profile } from '@components/Profile.tsx';
import { Administration } from '@components/Administration.tsx';
import { NotFound } from '@components/NotFound';
import { MainLayout } from '@components/MainLayout.tsx';
const theme = createTheme({
fontFamily: 'Inter, sans-serif'
@@ -21,9 +23,11 @@ createRoot(document.getElementById('root')!).render(
<Notifications zIndex={2000} />
<UserProvider>
<Routes>
<Route path='/' element={<App />} />
<Route path='/profile' element={<Profile />} />
<Route path='/administration' element={<Administration />} />
<Route path="/" element={<MainLayout />}>
<Route index element={<App />} />
<Route path='profile' element={<Profile />} />
<Route path='administration' element={<Administration />} />
</Route>
<Route path='*' element={<NotFound />} />
</Routes>
</UserProvider>