Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28dc464ec5 | |||
| 6ad2afe6dd | |||
| ed98140d22 |
15
.env
15
.env
@@ -38,7 +38,13 @@ SSL_CERT_KEY_PATH=../ssl/localhost.key
|
||||
SMTP_USERNAME=smtp-user
|
||||
SMTP_PASSWORD=smtp-password
|
||||
SMTP_FROM=noreply@example.com
|
||||
SMTP_SERVER=smtp.example.com
|
||||
SMTP_SERVER=localhost
|
||||
SMTP_PORT=1025
|
||||
#SMTP_USERNAME=smtp-user
|
||||
#SMTP_PASSWORD=smtp-password
|
||||
#SMTP_FROM=noreply@example.com
|
||||
#SMTP_SERVER=smtp.example.com
|
||||
#SMTP_PORT=587
|
||||
|
||||
VITE_API_URL=${EXTERNAL_URL}/api
|
||||
VITE_DEFAULT_LIMIT=200
|
||||
@@ -46,9 +52,14 @@ __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=${NGINX_HOST}
|
||||
|
||||
ENVIRONMENT=development
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=changeme
|
||||
|
||||
TEMPLATE_DIR=../templates
|
||||
METAR_INTERVAL=300
|
||||
|
||||
AVIATION_WEATHER_URL=https://aviationweather.gov/api/data
|
||||
MAILPIT_WEB_PORT=8025
|
||||
MAILPIT_SMTP_PORT=1025
|
||||
|
||||
AVIATION_WEATHER_URL=https://aviationweather.gov
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,7 +8,6 @@
|
||||
node_modules
|
||||
target/
|
||||
dist/
|
||||
Cargo.lock
|
||||
ssl/
|
||||
|
||||
.DS_Store
|
||||
|
||||
20
Makefile
20
Makefile
@@ -76,24 +76,24 @@ down-backend: backend-down
|
||||
run: ## Run the api
|
||||
@cd api && cargo run
|
||||
|
||||
frontend-up: ## Start Docker containers
|
||||
@docker compose --profile frontend up -d
|
||||
dev-up: ## Start Docker containers
|
||||
@docker compose --profile dev up -d
|
||||
|
||||
up-frontend: frontend-up
|
||||
up-dev: dev-up
|
||||
|
||||
frontend-down: ## Stop Docker containers
|
||||
@docker compose --profile frontend down
|
||||
dev-down: ## Stop Docker containers
|
||||
@docker compose --profile dev down
|
||||
|
||||
down-frontend: frontend-down
|
||||
down-dev: dev-down
|
||||
|
||||
docker-prune: ## Prune the docker system
|
||||
@docker system prune -a
|
||||
|
||||
docker-clean: ## Stop the docker containers and remove volumes
|
||||
@docker compose --profile frontend --profile api --profile backend down -v
|
||||
@docker compose --profile dev --profile api --profile backend down -v
|
||||
|
||||
docker-down: ## Stop the docker container
|
||||
@docker compose --profile frontend --profile api --profile backend down
|
||||
@docker compose --profile dev --profile api --profile backend down
|
||||
|
||||
docker-up: ## Start the docker container
|
||||
@docker compose --profile backend --profile api up -d
|
||||
@@ -122,6 +122,10 @@ push: registry=$(if $(r),$(r),gitea.bensherriff.com/bsherriff)
|
||||
push: platform=$(if $(p),$(p),linux/amd64,linux/arm64)
|
||||
push: image=${registry}/aviation-${folder}:${version}
|
||||
push: ## Build and push a specific docker image (`make push f=httpd`)
|
||||
docker buildx create \
|
||||
--use \
|
||||
--name aviation-builder \
|
||||
--platform ${platform} || true; \
|
||||
docker buildx build \
|
||||
-f ${folder}/Dockerfile \
|
||||
--platform ${platform} \
|
||||
|
||||
475
adsb/Cargo.lock
generated
Normal file
475
adsb/Cargo.lock
generated
Normal file
@@ -0,0 +1,475 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adsb"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
"env_logger",
|
||||
"log",
|
||||
"rusb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362"
|
||||
dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "697b5419f348fd5ae2478e8018cb016c00a5881c7f46c717de98ffd135a5651c"
|
||||
dependencies = [
|
||||
"nix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a064218214dc6a10fbae5ec5fa888d80c45d611aba169222fc272072bf7aef6"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "199b7932d97e325aff3a7030e141eafe7f2c6268e1d1b24859b753a627f45254"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.172"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
|
||||
|
||||
[[package]]
|
||||
name = "libusb1-sys"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "rusb"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libusb1-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
711
api/Cargo.lock
generated
711
api/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "api"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
authors = ["Ben Sherriff <ben@bensherriff.com>"]
|
||||
repository = "https://gitea.bensherriff.com/bsherriff/aviation"
|
||||
@@ -31,8 +31,10 @@ rand = "0.9.1"
|
||||
rand_chacha = "0.9.0"
|
||||
futures = "0.3.31"
|
||||
utoipa = { version = "5.3.1", features = ["chrono", "uuid", "actix_extras"] }
|
||||
utoipa-swagger-ui = { version = "9.0.1", features = ["actix-web"] }
|
||||
utoipa-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"] }
|
||||
handlebars = "6.3.2"
|
||||
governor = "0.10.0"
|
||||
flate2 = "1.1.1"
|
||||
|
||||
@@ -65,8 +65,8 @@ CREATE TABLE IF NOT EXISTS metars (
|
||||
CREATE INDEX ON metars (observation_time DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID UNIQUE NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
username TEXT PRIMARY KEY NOT NULL,
|
||||
email TEXT,
|
||||
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
@@ -74,6 +74,5 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
last_name TEXT NOT NULL,
|
||||
avatar TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY(email)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -34,13 +34,13 @@ impl FromRequest for Auth {
|
||||
return Err(Error::new(401, "API Key does not exist".to_string()).into());
|
||||
}
|
||||
};
|
||||
match User::select(&api_key.user_id).await {
|
||||
match User::select(&api_key.username).await {
|
||||
Some(user) => Ok(Auth {
|
||||
session_id: None,
|
||||
api_key: Some(key_id),
|
||||
user,
|
||||
}),
|
||||
None => Err(Error::new(404, format!("User {} not found", api_key.user_id)).into()),
|
||||
None => Err(Error::new(404, format!("User {} not found", api_key.username)).into()),
|
||||
}
|
||||
};
|
||||
return Box::pin(fut);
|
||||
@@ -79,13 +79,13 @@ impl FromRequest for Auth {
|
||||
// Verify the session
|
||||
let fut = async move {
|
||||
match Session::verify(&session_id, &ip_address).await {
|
||||
Ok(session) => match User::select(&session.user_id).await {
|
||||
Ok(session) => match User::select(&session.username).await {
|
||||
Some(user) => Ok(Auth {
|
||||
session_id: Some(session_id),
|
||||
api_key: None,
|
||||
user,
|
||||
}),
|
||||
None => Err(Error::new(404, format!("User {} not found", session.user_id)).into()),
|
||||
None => Err(Error::new(404, format!("User {} not found", session.username)).into()),
|
||||
},
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::account::hash;
|
||||
use crate::account::{csprng, hash};
|
||||
use crate::db::redis_async_connection;
|
||||
use crate::error::{ApiResult, Error};
|
||||
use crate::smtp;
|
||||
@@ -66,7 +66,7 @@ pub struct SimpleEmailCtx {
|
||||
pub year: i32,
|
||||
}
|
||||
|
||||
pub fn send_password_reset_email(
|
||||
pub async fn send_password_reset_email(
|
||||
email: &str,
|
||||
email_token: &EmailToken,
|
||||
ip_address: &str,
|
||||
@@ -99,7 +99,7 @@ pub fn send_password_reset_email(
|
||||
.render_template(&template_html, &ctx)
|
||||
.unwrap();
|
||||
|
||||
match smtp::send_email(&email, subject, plain, html) {
|
||||
match smtp::send_email(&email, subject, plain, html).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
@@ -113,11 +113,11 @@ pub fn send_password_reset_email(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_confirm_email(
|
||||
email: &str,
|
||||
email_token: &EmailToken,
|
||||
ip_address: &str,
|
||||
) -> ApiResult<()> {
|
||||
pub async fn send_confirm_email(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?;
|
||||
|
||||
let base_url = env::var("EXTERNAL_URL")?;
|
||||
let link = format!("{base_url}/profile/confirm?token={}", email_token.token);
|
||||
let subject = "Confirm your email address";
|
||||
@@ -146,16 +146,16 @@ pub fn send_confirm_email(
|
||||
.render_template(&template_html, &ctx)
|
||||
.unwrap();
|
||||
|
||||
match smtp::send_email(&email, subject, plain, html) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Invalid email confirmation attempt [Email: {}] [IP Address: {}]: {}",
|
||||
email,
|
||||
ip_address,
|
||||
err
|
||||
);
|
||||
Err(err.into())
|
||||
}
|
||||
if let Err(err) = smtp::send_email(&email, subject, plain, html).await {
|
||||
log::error!(
|
||||
"Invalid email confirmation attempt [Email: {}] [IP Address: {}]: {}",
|
||||
email,
|
||||
ip_address,
|
||||
err
|
||||
);
|
||||
let _ = EmailToken::delete(&email_token.token);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use rand_chacha::ChaCha20Rng;
|
||||
|
||||
mod auth;
|
||||
mod email_token;
|
||||
mod model;
|
||||
mod routes;
|
||||
mod session;
|
||||
|
||||
|
||||
24
api/src/account/model.rs
Normal file
24
api/src/account/model.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PasswordRequirements {
|
||||
pub max_length: Option<usize>,
|
||||
pub min_length: Option<usize>,
|
||||
pub lowercase_count: Option<usize>,
|
||||
pub uppercase_count: Option<usize>,
|
||||
pub numeric_count: Option<usize>,
|
||||
pub special_count: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for PasswordRequirements {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_length: Some(128),
|
||||
min_length: Some(6),
|
||||
lowercase_count: None,
|
||||
uppercase_count: None,
|
||||
numeric_count: None,
|
||||
special_count: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ use crate::{
|
||||
};
|
||||
use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web};
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use utoipa::ToSchema;
|
||||
use utoipa_actix_web::scope;
|
||||
use utoipa_actix_web::service_config::ServiceConfig;
|
||||
@@ -15,7 +14,7 @@ use crate::account::{Auth, csprng};
|
||||
use crate::users::UpdateUser;
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Account",
|
||||
tag = "account",
|
||||
request_body(
|
||||
content = RegisterRequest, content_type = "application/json"
|
||||
),
|
||||
@@ -27,6 +26,7 @@ use crate::users::UpdateUser;
|
||||
#[post("/register")]
|
||||
async fn register(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();
|
||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||
let insert_user: User = match register_user.to_user() {
|
||||
@@ -38,20 +38,19 @@ async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpRes
|
||||
Ok(user) => {
|
||||
let user_response: UserResponse = user.into();
|
||||
log::info!(
|
||||
"Successful user registration [Email: {}] [IP Address: {}]",
|
||||
email,
|
||||
"Successful user registration [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
|
||||
// Send confirmation email
|
||||
let token = csprng(128);
|
||||
let email_token = EmailToken::new(email.clone(), token, &ip_address);
|
||||
if let Err(err) = email_token.store(86400).await {
|
||||
return ResponseError::error_response(&err);
|
||||
if let Some(email) = email {
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = send_confirm_email(&email, &ip_address).await {
|
||||
log::error!("Failed to send confirmation email: {}", err);
|
||||
};
|
||||
});
|
||||
}
|
||||
if let Err(err) = send_confirm_email(&email, &email_token, &ip_address) {
|
||||
return ResponseError::error_response(&Error::new(500, err.to_string()));
|
||||
};
|
||||
|
||||
HttpResponse::Created().json(user_response)
|
||||
}
|
||||
@@ -59,21 +58,133 @@ async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpRes
|
||||
// Obfuscate the service error message to prevent leaking database details
|
||||
if err.status == 409 {
|
||||
log::warn!(
|
||||
"Duplicate user registration attempt [Email: {}] [IP Address: {}]",
|
||||
email,
|
||||
"Duplicate user registration attempt [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
HttpResponse::Conflict().finish()
|
||||
} else {
|
||||
log::error!("Failed to register user [Email: {}]: {}", email, err);
|
||||
log::error!("Failed to register user [User: {}]: {}", username, err);
|
||||
ResponseError::error_response(&err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct ConfirmEmail {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Account",
|
||||
tag = "account",
|
||||
request_body(
|
||||
content = ConfirmEmail, content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Successful Response", body = UserResponse),
|
||||
(status = 404, description = "Not Found"),
|
||||
(status = 409, description = "Conflict"),
|
||||
),
|
||||
)]
|
||||
#[post("/register/confirm")]
|
||||
async fn confirm_email_registration(
|
||||
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 {
|
||||
Ok(password_reset) => {
|
||||
if let Err(err) = EmailToken::delete(&password_reset.token).await {
|
||||
return ResponseError::error_response(&err);
|
||||
};
|
||||
password_reset
|
||||
}
|
||||
Err(_) => {
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
};
|
||||
|
||||
match User::select_by_email(&email_token.email).await {
|
||||
Some(user) => {
|
||||
let update_user = UpdateUser {
|
||||
email: None,
|
||||
email_verified: Some(true),
|
||||
password: None,
|
||||
role: None,
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
avatar: None,
|
||||
};
|
||||
|
||||
match update_user.update(&user.username).await {
|
||||
Ok(user) => {
|
||||
let response: UserResponse = user.into();
|
||||
log::info!(
|
||||
"Successful email confirmation attempt [Email: {}] [IP Address: {}]",
|
||||
&email_token.email,
|
||||
ip_address
|
||||
);
|
||||
HttpResponse::Ok().json(response)
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Invalid email confirmation attempt [Email: {}] [IP Address: {}]: {}",
|
||||
&email_token.email,
|
||||
ip_address,
|
||||
err
|
||||
);
|
||||
ResponseError::error_response(&err)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => HttpResponse::NotFound().finish(),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "account",
|
||||
responses(
|
||||
(status = 200, description = "Successful Response"),
|
||||
(status = 404, description = "Not Found"),
|
||||
),
|
||||
security(
|
||||
("session_auth" = [])
|
||||
)
|
||||
)]
|
||||
#[post("/register/email")]
|
||||
async fn resend_email_verification(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 {
|
||||
Some(query_user) => query_user,
|
||||
None => return HttpResponse::Unauthorized().finish(),
|
||||
};
|
||||
|
||||
// Cannot reverify if user is already verified
|
||||
if user.email_verified {
|
||||
return HttpResponse::Conflict().finish();
|
||||
}
|
||||
|
||||
// Send reverify confirmation email
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = send_confirm_email(&email, &ip_address).await {
|
||||
log::error!("Failed to send reverify confirmation email: {}", err);
|
||||
};
|
||||
});
|
||||
HttpResponse::Ok().finish()
|
||||
}
|
||||
None => HttpResponse::NotFound().finish(),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "account",
|
||||
request_body(
|
||||
content = LoginRequest, content_type = "application/json"
|
||||
),
|
||||
@@ -83,32 +194,32 @@ async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpRes
|
||||
)]
|
||||
#[post("/login")]
|
||||
async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
|
||||
let email = &request.email;
|
||||
let username = &request.username;
|
||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||
|
||||
let query_user = match User::select_by_email(&email).await {
|
||||
let query_user = match User::select(&username).await {
|
||||
Some(query_user) => query_user,
|
||||
None => return HttpResponse::Unauthorized().finish(),
|
||||
};
|
||||
|
||||
if verify_hash(&request.password, &query_user.password_hash) {
|
||||
// Create a session
|
||||
let session = Session::default(&query_user.id, &ip_address);
|
||||
let session = Session::default(&query_user.username, &ip_address);
|
||||
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 {
|
||||
log::error!(
|
||||
"Login attempt failure [Email: {}] [IP Address: {}]: {}",
|
||||
email,
|
||||
"Login attempt failure [User: {}] [IP Address: {}]: {}",
|
||||
username,
|
||||
ip_address,
|
||||
err
|
||||
);
|
||||
return ResponseError::error_response(&Error::new(500, err.to_string()));
|
||||
}
|
||||
log::info!(
|
||||
"Successful login attempt [Email: {}] [IP Address: {}]",
|
||||
email,
|
||||
"Successful login attempt [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
let user_response: UserResponse = query_user.into();
|
||||
@@ -118,8 +229,8 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
|
||||
.json(user_response)
|
||||
} else {
|
||||
log::error!(
|
||||
"Invalid login attempt [Email: {}] [IP Address: {}]",
|
||||
email,
|
||||
"Invalid login attempt [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
HttpResponse::Unauthorized()
|
||||
@@ -130,7 +241,7 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Account",
|
||||
tag = "account",
|
||||
responses(
|
||||
(status = 200, description = "Successful Response"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
@@ -141,7 +252,7 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
|
||||
)]
|
||||
#[post("/logout")]
|
||||
async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
|
||||
let email = auth.user.email;
|
||||
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) {
|
||||
@@ -149,8 +260,8 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
|
||||
let session_id = cookie.value().to_string();
|
||||
if let Err(err) = Session::delete(&session_id).await {
|
||||
log::error!(
|
||||
"Logout attempt failure [Email: {}] [IP Address: {}]: {}",
|
||||
email,
|
||||
"Logout attempt failure [User: {}] [IP Address: {}]: {}",
|
||||
username,
|
||||
ip_address,
|
||||
err
|
||||
);
|
||||
@@ -159,8 +270,8 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
|
||||
}
|
||||
None => {
|
||||
log::error!(
|
||||
"Invalid logout attempt [Email: {}] [IP Address: {}]",
|
||||
email,
|
||||
"Invalid logout attempt [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
return ResponseError::error_response(&Error::new(400, "Invalid session".to_string()));
|
||||
@@ -168,8 +279,8 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Successful logout attempt [Email: {}] [IP Address: {}]",
|
||||
email,
|
||||
"Successful logout attempt [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
HttpResponse::Ok()
|
||||
@@ -179,7 +290,7 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Account",
|
||||
tag = "account",
|
||||
responses(
|
||||
(status = 200, description = "Successful Response", body = UserResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
@@ -210,8 +321,8 @@ async fn get_profile(req: HttpRequest) -> HttpResponse {
|
||||
.finish();
|
||||
}
|
||||
};
|
||||
let id = &session.user_id;
|
||||
let query_user = match User::select(&id).await {
|
||||
let username = &session.username;
|
||||
let query_user = match User::select(&username).await {
|
||||
Some(query_user) => query_user,
|
||||
None => {
|
||||
return HttpResponse::Unauthorized()
|
||||
@@ -226,8 +337,8 @@ async fn get_profile(req: HttpRequest) -> HttpResponse {
|
||||
let session_exp_cookie = session.expiration_cookie();
|
||||
|
||||
log::info!(
|
||||
"Successful profile attempt [ID: {}] [IP Address: {}]",
|
||||
id,
|
||||
"Successful profile attempt [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
HttpResponse::Ok()
|
||||
@@ -242,77 +353,8 @@ async fn get_profile(req: HttpRequest) -> HttpResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct TokenRequest {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Account",
|
||||
request_body(
|
||||
content = TokenRequest, content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Successful Response", body = UserResponse),
|
||||
(status = 404, description = "Not Found"),
|
||||
),
|
||||
)]
|
||||
#[post("/profile/confirm")]
|
||||
async fn confirm_profile(request: web::Json<TokenRequest>, 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 {
|
||||
Ok(password_reset) => {
|
||||
if let Err(err) = EmailToken::delete(&password_reset.token).await {
|
||||
return ResponseError::error_response(&err);
|
||||
};
|
||||
password_reset
|
||||
}
|
||||
Err(_) => {
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
};
|
||||
|
||||
match User::select_by_email(&email_token.email).await {
|
||||
Some(user) => {
|
||||
let update_user = UpdateUser {
|
||||
email: None,
|
||||
email_verified: Some(true),
|
||||
password: None,
|
||||
role: None,
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
avatar: None,
|
||||
};
|
||||
|
||||
match update_user.update(&user.id).await {
|
||||
Ok(user) => {
|
||||
let response: UserResponse = user.into();
|
||||
log::info!(
|
||||
"Successful email confirmation attempt [Email: {}] [IP Address: {}]",
|
||||
&email_token.email,
|
||||
ip_address
|
||||
);
|
||||
HttpResponse::Ok().json(response)
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Invalid email confirmation attempt [Email: {}] [IP Address: {}]: {}",
|
||||
&email_token.email,
|
||||
ip_address,
|
||||
err
|
||||
);
|
||||
ResponseError::error_response(&err)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => HttpResponse::NotFound().finish(),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Account",
|
||||
tag = "account",
|
||||
responses(
|
||||
(status = 200, description = "Successful Response"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
@@ -343,13 +385,13 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse {
|
||||
.finish();
|
||||
}
|
||||
};
|
||||
let id = &session.user_id;
|
||||
let username = &session.username;
|
||||
let session_cookie = session.cookie();
|
||||
let session_exp_cookie = session.expiration_cookie();
|
||||
|
||||
log::info!(
|
||||
"Successful session validate attempt [ID: {}] [IP Address: {}]",
|
||||
id,
|
||||
"Successful session validate attempt [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
HttpResponse::Ok()
|
||||
@@ -365,14 +407,14 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct PasswordRequest {
|
||||
struct ChangePassword {
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Account",
|
||||
tag = "account",
|
||||
request_body(
|
||||
content = PasswordRequest, content_type = "application/json"
|
||||
content = ChangePassword, content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Successful Response", body = UserResponse),
|
||||
@@ -384,14 +426,14 @@ struct PasswordRequest {
|
||||
)]
|
||||
#[put("/password")]
|
||||
async fn change_password(
|
||||
request: web::Json<PasswordRequest>,
|
||||
request: web::Json<ChangePassword>,
|
||||
req: HttpRequest,
|
||||
auth: Auth,
|
||||
) -> HttpResponse {
|
||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||
let id = auth.user.id;
|
||||
let username = auth.user.username;
|
||||
|
||||
if let None = User::select(&id).await {
|
||||
if let None = User::select(&username).await {
|
||||
return HttpResponse::Unauthorized().finish();
|
||||
};
|
||||
|
||||
@@ -405,20 +447,20 @@ async fn change_password(
|
||||
avatar: None,
|
||||
};
|
||||
|
||||
match update_user.update(&id).await {
|
||||
match update_user.update(&username).await {
|
||||
Ok(user) => {
|
||||
let response: UserResponse = user.into();
|
||||
log::info!(
|
||||
"Successful password change attempt [ID: {}] [IP Address: {}]",
|
||||
&id,
|
||||
"Successful password change attempt [User: {}] [IP Address: {}]",
|
||||
&username,
|
||||
ip_address
|
||||
);
|
||||
HttpResponse::Ok().json(response)
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Invalid password change attempt [ID: {}] [IP Address: {}]: {}",
|
||||
&id,
|
||||
"Invalid password change attempt [User: {}] [IP Address: {}]: {}",
|
||||
&username,
|
||||
ip_address,
|
||||
err
|
||||
);
|
||||
@@ -428,26 +470,26 @@ async fn change_password(
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct EmailRequest {
|
||||
struct PasswordReset {
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Account",
|
||||
tag = "account",
|
||||
request_body(
|
||||
content = EmailRequest, content_type = "application/json"
|
||||
content = PasswordReset, content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Successful Response"),
|
||||
)
|
||||
)]
|
||||
#[post("/password/reset")]
|
||||
async fn reset_password(request: web::Json<EmailRequest>, req: HttpRequest) -> HttpResponse {
|
||||
async fn reset_password(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 does not exist
|
||||
// Silently return if the user's email does not exist
|
||||
if let None = User::select_by_email(&email).await {
|
||||
return HttpResponse::Ok().finish();
|
||||
};
|
||||
@@ -457,27 +499,34 @@ async fn reset_password(request: web::Json<EmailRequest>, req: HttpRequest) -> H
|
||||
return ResponseError::error_response(&err);
|
||||
}
|
||||
|
||||
if let Err(err) = send_password_reset_email(email, &email_token, &ip_address) {
|
||||
if let Err(err) = send_password_reset_email(email, &email_token, &ip_address).await {
|
||||
return ResponseError::error_response(&Error::new(500, err.to_string()));
|
||||
};
|
||||
HttpResponse::Ok().finish()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct ConfirmPasswordReset {
|
||||
token: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Account",
|
||||
tag = "account",
|
||||
request_body(
|
||||
content = TokenRequest, content_type = "application/json"
|
||||
content = ConfirmPasswordReset, content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Successful Response"),
|
||||
(status = 404, description = "Not Found"),
|
||||
)
|
||||
)]
|
||||
#[post("/password/validate")]
|
||||
async fn validate_reset_password(
|
||||
request: web::Json<TokenRequest>,
|
||||
#[post("/password/reset/confirm")]
|
||||
async fn confirm_password_reset(
|
||||
request: web::Json<ConfirmPasswordReset>,
|
||||
req: HttpRequest,
|
||||
) -> HttpResponse {
|
||||
// TODO
|
||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||
let token = &request.token;
|
||||
|
||||
@@ -500,13 +549,14 @@ pub fn init_routes(config: &mut ServiceConfig) {
|
||||
config.service(
|
||||
scope::scope("/account")
|
||||
.service(register)
|
||||
.service(confirm_email_registration)
|
||||
.service(resend_email_verification)
|
||||
.service(login)
|
||||
.service(logout)
|
||||
.service(get_profile)
|
||||
.service(confirm_profile)
|
||||
.service(session_refresh)
|
||||
.service(change_password)
|
||||
.service(reset_password)
|
||||
.service(validate_reset_password),
|
||||
.service(confirm_password_reset),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use chrono::{DateTime, Utc};
|
||||
use redis::{AsyncCommands, RedisResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::task;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
|
||||
pub const SESSION_COOKIE_NAME: &str = "session";
|
||||
@@ -17,22 +16,22 @@ pub const SESSION_EXPIRATION_COOKIE_NAME: &str = "session_expiration";
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub session_id: String,
|
||||
pub user_id: Uuid,
|
||||
pub username: String,
|
||||
pub ip_address: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn default(user_id: &Uuid, ip_address: &str) -> Self {
|
||||
Self::new(64, user_id, ip_address, Some(DEFAULT_SESSION_TTL))
|
||||
pub fn default(username: &str, ip_address: &str) -> Self {
|
||||
Self::new(64, username, ip_address, Some(DEFAULT_SESSION_TTL))
|
||||
}
|
||||
|
||||
pub fn new(take: usize, user_id: &Uuid, ip_address: &str, ttl: Option<i64>) -> Self {
|
||||
pub fn new(take: usize, username: &str, ip_address: &str, ttl: Option<i64>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
session_id: csprng(take),
|
||||
user_id: user_id.clone(),
|
||||
username: username.to_string(),
|
||||
ip_address: hash(&ip_address).unwrap(),
|
||||
expires_at: match ttl {
|
||||
Some(ttl) => Some(now + chrono::Duration::seconds(ttl)),
|
||||
@@ -79,7 +78,7 @@ impl Session {
|
||||
);
|
||||
};
|
||||
});
|
||||
session = Session::default(&session.user_id, ip_address);
|
||||
session = Session::default(&session.username, ip_address);
|
||||
session.store().await?;
|
||||
Ok(session)
|
||||
}
|
||||
@@ -120,8 +119,8 @@ impl Session {
|
||||
if let Ok(environment) = std::env::var("ENVIRONMENT") {
|
||||
if environment == "development" || environment == "dev" {
|
||||
log::trace!(
|
||||
"Session cookie [User ID: {}]: {}",
|
||||
self.user_id,
|
||||
"Session cookie [User: {}]: {}",
|
||||
self.username,
|
||||
self.session_id
|
||||
);
|
||||
cookie.set_secure(false);
|
||||
@@ -148,8 +147,8 @@ impl Session {
|
||||
if let Ok(environment) = std::env::var("ENVIRONMENT") {
|
||||
if environment == "development" || environment == "dev" {
|
||||
log::trace!(
|
||||
"Session expiration cookie [User ID: {}]: {}",
|
||||
self.user_id,
|
||||
"Session expiration cookie [User: {}]: {}",
|
||||
self.username,
|
||||
self.session_id
|
||||
);
|
||||
cookie.set_secure(false);
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::error::{ApiResult, Error};
|
||||
use crate::metars::Metar;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures_util::try_join;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Postgres, QueryBuilder};
|
||||
use std::collections::HashMap;
|
||||
@@ -81,6 +80,54 @@ impl Default for AirportQuery {
|
||||
}
|
||||
}
|
||||
|
||||
// impl AirportQuery {
|
||||
// pub fn builder() -> AirportQueryBuilder {
|
||||
// AirportQueryBuilder::new()
|
||||
// }
|
||||
// }
|
||||
|
||||
// pub struct AirportQueryBuilder {
|
||||
// inner: AirportQuery,
|
||||
// }
|
||||
//
|
||||
// impl AirportQueryBuilder {
|
||||
// /// start the builder
|
||||
// pub fn new() -> Self {
|
||||
// AirportQueryBuilder {
|
||||
// inner: AirportQuery::default(),
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// pub fn page(mut self, page: u32) -> Self {
|
||||
// self.inner.page = Some(page);
|
||||
// self
|
||||
// }
|
||||
//
|
||||
// pub fn limit(mut self, limit: u32) -> Self {
|
||||
// self.inner.limit = Some(limit);
|
||||
// self
|
||||
// }
|
||||
//
|
||||
// pub fn icaos<T: Into<String>>(mut self, v: T) -> Self {
|
||||
// self.inner.icaos = Some(v.into());
|
||||
// self
|
||||
// }
|
||||
//
|
||||
// pub fn iatas<T: Into<String>>(mut self, v: T) -> Self {
|
||||
// self.inner.iatas = Some(v.into());
|
||||
// self
|
||||
// }
|
||||
//
|
||||
// pub fn metars(mut self, v: bool) -> Self {
|
||||
// self.inner.metars = Some(v);
|
||||
// self
|
||||
// }
|
||||
//
|
||||
// pub fn build(self) -> AirportQuery {
|
||||
// self.inner
|
||||
// }
|
||||
// }
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct Bounds {
|
||||
pub north_east_lat: f32,
|
||||
@@ -208,7 +255,7 @@ impl From<AirportRow> for Airport {
|
||||
}
|
||||
|
||||
impl Airport {
|
||||
pub async fn select(client: &Client, icao: &str, metar: bool) -> Option<Self> {
|
||||
pub async fn select(icao: &str, metar: bool) -> Option<Self> {
|
||||
let pool = db::pool();
|
||||
|
||||
let airport_fut = async {
|
||||
@@ -223,7 +270,7 @@ impl Airport {
|
||||
|
||||
let metar_fut = async {
|
||||
if metar {
|
||||
match Metar::find_all_distinct(client, &vec![icao.to_string()]).await {
|
||||
match Metar::get_all_distinct(&vec![icao.to_uppercase()]).await {
|
||||
Ok(m) => Some(m.into_iter().nth(0)),
|
||||
Err(err) => {
|
||||
log::error!("{}", err);
|
||||
@@ -286,7 +333,7 @@ impl Airport {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn select_all(client: &Client, query: &AirportQuery) -> ApiResult<Vec<Self>> {
|
||||
pub async fn select_all(query: &AirportQuery) -> ApiResult<Vec<Self>> {
|
||||
let pool = db::pool();
|
||||
|
||||
let mut builder =
|
||||
@@ -349,12 +396,12 @@ impl Airport {
|
||||
}
|
||||
|
||||
// Bulk update airport sub-fields
|
||||
let icaos: Vec<String> = airports.iter().map(|a| a.icao.clone()).collect();
|
||||
let icaos: Vec<String> = airports.iter().map(|a| a.icao.to_uppercase()).collect();
|
||||
|
||||
let runway_future = Runway::select_all_map(icaos.clone());
|
||||
let frequency_future = Communication::select_all_map(icaos.clone());
|
||||
let runway_future = Runway::select_all_map(&icaos);
|
||||
let frequency_future = Communication::select_all_map(&icaos);
|
||||
let metar_future = if query.metars.unwrap_or(false) {
|
||||
Some(Metar::find_all_distinct(client, &icaos))
|
||||
Some(Metar::get_all_distinct(&icaos))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -65,7 +65,7 @@ impl Communication {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn select_all_map(icaos: Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
|
||||
pub async fn select_all_map(icaos: &Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
|
||||
let pool = db::pool();
|
||||
|
||||
let frequency_rows: Vec<CommunicationRow> = sqlx::query_as(&format!(
|
||||
|
||||
@@ -64,7 +64,7 @@ impl Runway {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn select_all_map(icaos: Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
|
||||
pub async fn select_all_map(icaos: &Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
|
||||
let pool = db::pool();
|
||||
|
||||
let runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!(
|
||||
|
||||
@@ -3,7 +3,6 @@ use futures_util::stream::StreamExt as _;
|
||||
use crate::airports::{AirportQuery, UpdateAirport};
|
||||
use crate::users::ADMIN_ROLE;
|
||||
use crate::{
|
||||
AppState,
|
||||
account::{Auth, verify_role},
|
||||
airports::Airport,
|
||||
db::Paged,
|
||||
@@ -16,15 +15,15 @@ use utoipa_actix_web::service_config::ServiceConfig;
|
||||
|
||||
#[derive(ToSchema)]
|
||||
#[allow(unused)]
|
||||
struct UploadedFile {
|
||||
struct FileUpload {
|
||||
#[schema(value_type = String, format = Binary)]
|
||||
file: Vec<u8>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Airports",
|
||||
tag = "airport",
|
||||
request_body(
|
||||
content = UploadedFile, content_type = "multipart/form-data"
|
||||
content = FileUpload, content_type = "multipart/form-data"
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Successful import"),
|
||||
@@ -77,7 +76,7 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Airports",
|
||||
tag = "airport",
|
||||
params(
|
||||
AirportQuery
|
||||
),
|
||||
@@ -86,7 +85,7 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
|
||||
),
|
||||
)]
|
||||
#[get("")]
|
||||
async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
|
||||
async fn get_airports(req: HttpRequest) -> HttpResponse {
|
||||
let mut query = match web::Query::<AirportQuery>::from_query(req.query_string()) {
|
||||
Ok(q) => q.into_inner(),
|
||||
Err(err) => {
|
||||
@@ -104,8 +103,7 @@ async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpRespon
|
||||
query.limit = Some(limit);
|
||||
query.page = Some(page);
|
||||
|
||||
let client = &data.client;
|
||||
match Airport::select_all(client, &query).await {
|
||||
match Airport::select_all(&query).await {
|
||||
Ok(airports) => HttpResponse::Ok().json(Paged {
|
||||
data: airports,
|
||||
page,
|
||||
@@ -120,18 +118,14 @@ async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpRespon
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Airports",
|
||||
tag = "airport",
|
||||
responses(
|
||||
(status = 200, description = "", body = Airport),
|
||||
(status = 404, description = ""),
|
||||
),
|
||||
)]
|
||||
#[get("/{icao}")]
|
||||
async fn get_airport(
|
||||
data: web::Data<AppState>,
|
||||
icao: web::Path<String>,
|
||||
req: HttpRequest,
|
||||
) -> HttpResponse {
|
||||
async fn get_airport(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) => {
|
||||
@@ -140,15 +134,14 @@ async fn get_airport(
|
||||
}
|
||||
};
|
||||
|
||||
let client = &data.client;
|
||||
match Airport::select(client, &icao.into_inner(), metar).await {
|
||||
match Airport::select(&icao.into_inner(), metar).await {
|
||||
Some(airport) => HttpResponse::Ok().json(airport),
|
||||
None => HttpResponse::NotFound().finish(),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Airports",
|
||||
tag = "airport",
|
||||
responses(
|
||||
(status = 200, description = "", body = Airport),
|
||||
(status = 401, description = ""),
|
||||
@@ -174,7 +167,7 @@ async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Airports",
|
||||
tag = "airport",
|
||||
responses(
|
||||
(status = 200, description = "", body = Airport),
|
||||
(status = 401, description = ""),
|
||||
@@ -203,7 +196,7 @@ async fn update_airport(
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Airports",
|
||||
tag = "airport",
|
||||
responses(
|
||||
(status = 201, description = ""),
|
||||
(status = 401, description = ""),
|
||||
@@ -228,7 +221,7 @@ async fn delete_airports(auth: Auth) -> HttpResponse {
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "Airports",
|
||||
tag = "airport",
|
||||
responses(
|
||||
(status = 201, description = ""),
|
||||
(status = 401, description = ""),
|
||||
|
||||
92
api/src/http_client.rs
Normal file
92
api/src/http_client.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use crate::error::{ApiResult, Error};
|
||||
use governor::clock::DefaultClock;
|
||||
use governor::state::{InMemoryState, NotKeyed};
|
||||
use governor::{Quota, RateLimiter};
|
||||
use reqwest::header::{IF_NONE_MATCH, RETRY_AFTER};
|
||||
use reqwest::{Certificate, Client, Response, StatusCode};
|
||||
use std::env;
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpClient {
|
||||
client: Client,
|
||||
limiter: Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>,
|
||||
pub default_retry_after: u64,
|
||||
}
|
||||
|
||||
impl HttpClient {
|
||||
pub fn new(default_retry_after: u64) -> ApiResult<Self> {
|
||||
let mut client_builder = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.tls_built_in_root_certs(true);
|
||||
|
||||
if let Ok(val) = env::var("NGINX_SSL_ENABLED") {
|
||||
if val == "true" {
|
||||
let certificate_path = env::var("SSL_CA_PATH")?;
|
||||
let certificate_data = std::fs::read(certificate_path)?;
|
||||
let certificate = Certificate::from_pem(&certificate_data)?;
|
||||
client_builder = client_builder.add_root_certificate(certificate);
|
||||
}
|
||||
}
|
||||
|
||||
let client = client_builder.build()?;
|
||||
|
||||
let quota = Quota::per_second(NonZeroU32::new(15).unwrap());
|
||||
let limiter = RateLimiter::direct(quota);
|
||||
let limiter = Arc::new(limiter);
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
limiter,
|
||||
default_retry_after,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default() -> ApiResult<Self> {
|
||||
Self::new(60)
|
||||
}
|
||||
|
||||
pub async fn get(&self, url: &str, etag: Option<String>) -> ApiResult<Response> {
|
||||
self.limiter.until_ready().await;
|
||||
|
||||
let mut request = self.client.get(url);
|
||||
if let Some(ref etag) = etag {
|
||||
request = request.header(IF_NONE_MATCH, etag);
|
||||
}
|
||||
|
||||
let mut response = request.send().await?;
|
||||
|
||||
// Handle too many requests
|
||||
if response.status() == StatusCode::TOO_MANY_REQUESTS {
|
||||
let retry_after = response
|
||||
.headers()
|
||||
.get(RETRY_AFTER)
|
||||
.and_then(|hdr| hdr.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(self.default_retry_after);
|
||||
|
||||
log::warn!(
|
||||
"Received 429 Too Many Requests, retrying after {}s",
|
||||
retry_after
|
||||
);
|
||||
sleep(Duration::from_secs(retry_after)).await;
|
||||
|
||||
// Retry once more
|
||||
response = self.client.get(url).send().await?;
|
||||
} else if response.status() == StatusCode::NOT_MODIFIED {
|
||||
log::warn!("Received 304 Not modified")
|
||||
}
|
||||
|
||||
if response.status() != 200 {
|
||||
return Err(Error::new(
|
||||
response.status().as_u16(),
|
||||
format!("Request returned status {}", response.status()),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
use crate::account::hash;
|
||||
use crate::http_client::HttpClient;
|
||||
use crate::users::{ADMIN_ROLE, User};
|
||||
use actix_cors::Cors;
|
||||
use actix_web::{App, HttpServer, middleware::Logger, web};
|
||||
use dotenv::from_filename;
|
||||
use reqwest::Certificate;
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
use utoipa::openapi::SecurityRequirement;
|
||||
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
|
||||
use utoipa::openapi::{Contact, SecurityRequirement};
|
||||
use utoipa_actix_web::{AppExt, scope};
|
||||
use utoipa_swagger_ui::{Config, SwaggerUi};
|
||||
use uuid::Uuid;
|
||||
|
||||
mod account;
|
||||
mod airports;
|
||||
mod db;
|
||||
mod error;
|
||||
mod http_client;
|
||||
mod metars;
|
||||
mod scheduler;
|
||||
mod smtp;
|
||||
@@ -24,19 +24,29 @@ mod users;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AppState {
|
||||
client: reqwest::Client,
|
||||
client: Arc<HttpClient>,
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
initialize_environment()?;
|
||||
db::initialize().await?;
|
||||
// scheduler::update_airports();
|
||||
|
||||
let client = Arc::new(HttpClient::default()?);
|
||||
|
||||
let scheduler_client = client.clone();
|
||||
let interval = env::var("METAR_INTERVAL")
|
||||
.unwrap_or("300".to_string())
|
||||
.parse::<u64>()
|
||||
.unwrap_or(300);
|
||||
scheduler::update_metars(scheduler_client, interval);
|
||||
|
||||
// Initialize admin user
|
||||
let admin_username = env::var("ADMIN_USERNAME");
|
||||
let admin_email = env::var("ADMIN_EMAIL");
|
||||
let admin_password = env::var("ADMIN_PASSWORD");
|
||||
if admin_email.is_ok() && admin_password.is_ok() {
|
||||
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() {
|
||||
log::debug!("Creating default administrator");
|
||||
@@ -44,12 +54,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let password_hash = hash(&password)?;
|
||||
if email == "admin@example.com" || password == "changeme" {
|
||||
log::warn!(
|
||||
"Default admin credentials are in use, update the ADMIN_EMAIL and ADMIN_PASSWORD."
|
||||
"Default admin credentials are in use, update the ADMIN_USERNAME, ADMIN_EMAIL, and ADMIN_PASSWORD."
|
||||
);
|
||||
}
|
||||
let admin_user = User {
|
||||
id: Uuid::new_v4(),
|
||||
email,
|
||||
username,
|
||||
email: Some(email),
|
||||
email_verified: true,
|
||||
password_hash,
|
||||
role: ADMIN_ROLE.to_string(),
|
||||
@@ -68,23 +78,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut client_builder = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.tls_built_in_root_certs(true);
|
||||
|
||||
if let Ok(val) = env::var("NGINX_SSL_ENABLED") {
|
||||
if val == "true" {
|
||||
let certificate_path = env::var("SSL_CA_PATH")?;
|
||||
let certificate_data = std::fs::read(certificate_path)?;
|
||||
let certificate = Certificate::from_pem(&certificate_data)?;
|
||||
client_builder = client_builder.add_root_certificate(certificate);
|
||||
}
|
||||
}
|
||||
|
||||
let client = client_builder
|
||||
.build()
|
||||
.expect("Failed to create reqwest client");
|
||||
|
||||
let state = AppState { client };
|
||||
let host = "0.0.0.0";
|
||||
let port = env::var("API_PORT").unwrap_or("5000".to_string());
|
||||
@@ -111,14 +104,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
)
|
||||
.split_for_parts();
|
||||
|
||||
let version = env::var("CARGO_PKG_VERSION").unwrap();
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
api.info.title = "Aviation Data".to_string();
|
||||
api.info.description = None;
|
||||
api.info.description = Some("This documentation describe the Aviation Data API".to_string());
|
||||
api.info.terms_of_service = None;
|
||||
api.info.contact = None;
|
||||
api.info.license = None;
|
||||
api.info.version = version;
|
||||
api.info.version = version.to_string();
|
||||
|
||||
let session_scheme = SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new("session")));
|
||||
let mut components = api.components.take().unwrap_or_default();
|
||||
@@ -126,10 +119,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.security_schemes
|
||||
.insert("session_auth".to_string(), session_scheme);
|
||||
api.components = Some(components);
|
||||
// api.security = Some(vec![SecurityRequirement::new("session_auth", [""])]);
|
||||
api.security = Some(vec![SecurityRequirement::default()]);
|
||||
|
||||
app.service(SwaggerUi::new("/swagger/{_:.*}").url("/api-docs/openapi.json", api))
|
||||
app.service(
|
||||
SwaggerUi::new("/swagger/{_:.*}")
|
||||
.url("/api-docs/openapi.json", api)
|
||||
.config(Config::default().use_base_layout()),
|
||||
)
|
||||
})
|
||||
.bind(format!("{}:{}", host, port))
|
||||
{
|
||||
@@ -150,24 +146,30 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
fn initialize_environment() -> std::io::Result<()> {
|
||||
// Iterate over files in the current directory
|
||||
for entry in std::fs::read_dir(".")? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
fn init_dir(directory: &str) -> std::io::Result<()> {
|
||||
// Iterate over files in the current directory
|
||||
for entry in std::fs::read_dir(directory)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
// Check if the file name starts with ".env" and is a file
|
||||
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if file_name.starts_with(".env") && path.is_file() {
|
||||
// Try to load the file
|
||||
if let Err(err) = from_filename(&file_name) {
|
||||
eprintln!("Failed to load {}: {}", file_name, err);
|
||||
} else {
|
||||
println!("Loaded: {}", file_name);
|
||||
// Check if the file name starts with ".env" and is a file
|
||||
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if file_name.starts_with(".env") && path.is_file() {
|
||||
// Try to load the file
|
||||
if let Err(err) = from_filename(&file_name) {
|
||||
eprintln!("Failed to load {}: {}", file_name, err);
|
||||
} else {
|
||||
println!("Loaded: {}", file_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
init_dir("..")?;
|
||||
init_dir(".")?;
|
||||
|
||||
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,api=info"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ impl MetarCheck {
|
||||
let mut conn = match redis_async_connection().await {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
log::error!("{}", err);
|
||||
log::error!("Unable to get connection for ICAO {}: {}", icao, err);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -44,24 +44,22 @@ impl MetarCheck {
|
||||
Ok(Some(value)) => match serde_json::from_str(&value) {
|
||||
Ok(result) => Some(result),
|
||||
Err(err) => {
|
||||
log::error!("{}", err);
|
||||
log::error!("Unable to get MetarCheck for ICAO {}: {}", icao, err);
|
||||
None
|
||||
}
|
||||
},
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
log::error!("{}", err);
|
||||
log::error!("Error getting MetarCheck for ICAO {}: {}", icao, err);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn insert(&self, seconds: u64) -> ApiResult<()> {
|
||||
pub async fn insert(&self) -> ApiResult<()> {
|
||||
let mut conn = redis_async_connection().await?;
|
||||
let value = serde_json::to_string(&self)?;
|
||||
conn
|
||||
.set_ex::<_, _, ()>(self.icao.as_str(), value, seconds)
|
||||
.await?;
|
||||
conn.set::<_, _, ()>(self.icao.as_str(), value).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
use crate::airports::{Airport, UpdateAirport};
|
||||
use crate::db::redis_async_connection;
|
||||
use crate::error::Error;
|
||||
use crate::http_client::HttpClient;
|
||||
use crate::metars::MetarCheck;
|
||||
use crate::{db, error::ApiResult};
|
||||
use chrono::{DateTime, Datelike, NaiveDate, Utc};
|
||||
use redis::{AsyncCommands, RedisResult};
|
||||
use reqwest::Client;
|
||||
use flate2::read::GzDecoder;
|
||||
use reqwest::header::ETAG;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
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 utoipa::ToSchema;
|
||||
|
||||
static TIME_OFFSET: OnceLock<i64> = OnceLock::new();
|
||||
|
||||
const TABLE_NAME: &str = "metars";
|
||||
const DEFAULT_REFRESH_DURATION: i64 = 3000;
|
||||
|
||||
fn time_offset() -> i64 {
|
||||
*TIME_OFFSET.get_or_init(|| {
|
||||
env::var("API_METAR_TIME_OFFSET")
|
||||
.unwrap_or("1800".to_string())
|
||||
.parse::<i64>()
|
||||
.unwrap_or(1800)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Metar {
|
||||
@@ -292,9 +306,9 @@ impl MetarRow {
|
||||
|
||||
impl Metar {
|
||||
fn parse_multiple(metar_strings: &Vec<&str>) -> ApiResult<Vec<Self>> {
|
||||
let mut metars: Vec<Metar> = vec![];
|
||||
let mut metars: Vec<Self> = vec![];
|
||||
for metar_string in metar_strings {
|
||||
match Metar::parse(metar_string) {
|
||||
match Self::parse(metar_string) {
|
||||
Ok(metar) => metars.push(metar),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse metar string: {}", e);
|
||||
@@ -315,7 +329,7 @@ impl Metar {
|
||||
}
|
||||
|
||||
log::trace!("Parsing METAR data: {}", metar_string);
|
||||
let mut metar: Metar = Metar::default();
|
||||
let mut metar: Self = Self::default();
|
||||
metar.raw_text = metar_string.to_owned();
|
||||
let mut metar_parts: Vec<&str> = metar_string.split_whitespace().collect();
|
||||
if metar_parts.len() < 4 {
|
||||
@@ -461,8 +475,23 @@ impl Metar {
|
||||
let visibility_parts: Vec<&str> = metar_parts[0].split("/").collect();
|
||||
metar_parts.remove(0);
|
||||
let visibility_left = visibility_parts[0];
|
||||
let visibility_right =
|
||||
visibility_parts[1][0..visibility_parts[1].len() - 2].parse::<f64>()?;
|
||||
// Parse the right-hand of visibility, with or without an SM suffix
|
||||
let visibility_right_string = match visibility_parts[1].strip_suffix("SM") {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
if visibility_parts[1].chars().all(|c| c.is_numeric() || c == '.') {
|
||||
visibility_parts[1]
|
||||
} else {
|
||||
log::warn!(
|
||||
"Skipping invalid visibility field '{}' ({})",
|
||||
metar_parts[0],
|
||||
metar_string
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let visibility_right = visibility_right_string.parse::<f64>()?;
|
||||
let visibility = if visibility_left.starts_with("M") {
|
||||
format!(
|
||||
"M{}",
|
||||
@@ -548,11 +577,16 @@ impl Metar {
|
||||
metar_parts.remove(0);
|
||||
}
|
||||
let sky_condition_re =
|
||||
regex::Regex::new(r"^(?:CLR|SKC|NSC|NCD|(?:FEW|SCT|BKN|OVC|VV)([0-9/]{3})?(?:CB|TCU)?)$")
|
||||
regex::Regex::new(r"^(?:CLR|SKC|NSC|NCD|(?:FEW|SCT|BKN|OVC|VV)([0-9/]{3})?(?:CB|TCU)?)(?:///)?$")
|
||||
.unwrap();
|
||||
while !metar_parts.is_empty() && sky_condition_re.is_match(metar_parts[0]) {
|
||||
let sky_condition_string = metar_parts[0];
|
||||
let mut sky_condition_string = metar_parts[0];
|
||||
metar_parts.remove(0);
|
||||
|
||||
if sky_condition_string.ends_with("///") {
|
||||
sky_condition_string = &sky_condition_string[..sky_condition_string.len() - 3];
|
||||
}
|
||||
|
||||
let mut sky_condition = SkyCondition::default();
|
||||
let mut vv_offset = 0;
|
||||
if &sky_condition_string[0..2] == "VV" {
|
||||
@@ -906,9 +940,19 @@ impl Metar {
|
||||
observation_day
|
||||
),
|
||||
)
|
||||
})?
|
||||
.and_hms_opt(observation_hour, observation_minute, 0)
|
||||
.unwrap();
|
||||
})?;
|
||||
let candidate_date = match candidate_date.and_hms_opt(observation_hour, observation_minute, 0) {
|
||||
Some(date) => date,
|
||||
None => {
|
||||
return Err(Error::new(
|
||||
500,
|
||||
format!(
|
||||
"Invalid time for time '{}': hour {}, minute {}",
|
||||
observation_time, observation_hour, observation_minute
|
||||
),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let obs_datetime = if candidate_date > current_time {
|
||||
// Subtract one month. (Handle year rollover carefully.)
|
||||
@@ -928,50 +972,88 @@ impl Metar {
|
||||
),
|
||||
)
|
||||
})?;
|
||||
adjusted_date.and_hms(observation_hour, observation_minute, 0)
|
||||
adjusted_date
|
||||
.and_hms_opt(observation_hour, observation_minute, 0)
|
||||
.unwrap()
|
||||
} else {
|
||||
candidate_date
|
||||
};
|
||||
Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string())
|
||||
}
|
||||
|
||||
async fn get_remote_metars(client: &Client, icaos: &Vec<String>) -> ApiResult<Vec<Metar>> {
|
||||
pub async fn get_cached_remote_metars(
|
||||
client: &HttpClient,
|
||||
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 {
|
||||
Ok(r) => {
|
||||
let new_etag = r
|
||||
.headers()
|
||||
.get(ETAG)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let bytes = r.bytes().await?;
|
||||
let mut gz = GzDecoder::new(Cursor::new(bytes));
|
||||
let mut text = String::new();
|
||||
gz.read_to_string(&mut text)?;
|
||||
|
||||
let mut output: Vec<Metar> = Vec::new();
|
||||
|
||||
for line in text.lines() {
|
||||
// Split off first column
|
||||
let raw_text = line.splitn(2, ',').next().unwrap();
|
||||
match Metar::parse(raw_text) {
|
||||
Ok(m) => output.push(m),
|
||||
Err(err) => {
|
||||
log::warn!("{}", err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
match new_etag {
|
||||
Some(etag) => Ok((output, etag)),
|
||||
None => match etag {
|
||||
Some(etag) => Ok((output, etag)),
|
||||
None => Ok((output, String::new())),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_remote_metars(client: &HttpClient, 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
|
||||
.chunks(10)
|
||||
.map(|chunk| chunk.join(","))
|
||||
.collect::<Vec<String>>();
|
||||
let mut metars: Vec<Metar> = vec![];
|
||||
let mut metars: Vec<Self> = vec![];
|
||||
for icao_chunk in icao_chunks {
|
||||
let url = format!(
|
||||
"{}/metar?ids={}&hours=0&order=id,-obs",
|
||||
"{}/api/data/metar?ids={}&hours=0&order=id,-obs",
|
||||
base_url, icao_chunk
|
||||
);
|
||||
let mut m = match client.get(url).send().await {
|
||||
Ok(r) => {
|
||||
// Check if the status code is 200
|
||||
if r.status() != 200 {
|
||||
return Err(Error::new(
|
||||
500,
|
||||
format!("Request returned status {}", r.status()),
|
||||
));
|
||||
}
|
||||
match r.text().await {
|
||||
Ok(r) => {
|
||||
let metar_chunk = r
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(|m| !m.trim().is_empty())
|
||||
.collect();
|
||||
match Self::parse_multiple(&metar_chunk) {
|
||||
Ok(m) => m,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
let mut m = match client.get(&url, None).await {
|
||||
Ok(r) => match r.text().await {
|
||||
Ok(r) => {
|
||||
let metar_chunk = r
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(|m| !m.trim().is_empty())
|
||||
.collect();
|
||||
match Self::parse_multiple(&metar_chunk) {
|
||||
Ok(m) => m,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
Err(err) => return Err(Error::new(500, format!("METAR parse failed: {}", err))),
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(Error::new(500, format!("METAR parse failed: {}", err))),
|
||||
},
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
metars.append(&mut m);
|
||||
@@ -979,22 +1061,22 @@ impl Metar {
|
||||
Ok(metars)
|
||||
}
|
||||
|
||||
fn from_db(metar_db: MetarRow) -> ApiResult<Metar> {
|
||||
let metar: Metar = serde_json::from_value(metar_db.data)?;
|
||||
fn from_row(row: MetarRow) -> ApiResult<Self> {
|
||||
let metar: Self = serde_json::from_value(row.data)?;
|
||||
Ok(metar)
|
||||
}
|
||||
|
||||
fn to_db(&self) -> ApiResult<MetarRow> {
|
||||
fn to_row(&self) -> ApiResult<MetarRow> {
|
||||
let data = serde_json::to_value(self)?;
|
||||
Ok(MetarRow {
|
||||
icao: self.icao.clone(),
|
||||
icao: self.icao.to_uppercase(),
|
||||
observation_time: self.observation_time,
|
||||
raw_text: self.raw_text.clone(),
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn find_all_distinct(client: &Client, icao_list: &Vec<String>) -> ApiResult<Vec<Self>> {
|
||||
pub async fn get_all_distinct(icao_list: &Vec<String>) -> ApiResult<Vec<Self>> {
|
||||
if icao_list.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -1011,61 +1093,67 @@ impl Metar {
|
||||
.bind(icao_list)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let mut metars = vec![];
|
||||
for metar_row in metar_rows {
|
||||
metars.push(Self::from_row(metar_row)?)
|
||||
}
|
||||
Ok(metars)
|
||||
}
|
||||
|
||||
pub async fn get_or_update_metars(
|
||||
client: &HttpClient,
|
||||
icaos: &Vec<String>,
|
||||
) -> ApiResult<Vec<Self>> {
|
||||
let metars = Self::get_all_distinct(&icaos).await?;
|
||||
let current_time = Utc::now().timestamp();
|
||||
let time_offset = env::var("API_METAR_TIME_OFFSET")
|
||||
.unwrap_or("1800".to_string())
|
||||
.parse::<i64>()
|
||||
.unwrap_or(1800);
|
||||
let short_time_offset: i64 = 300;
|
||||
|
||||
// Setup metars and missing metar structures
|
||||
let mut metars: Vec<Metar> = vec![];
|
||||
let mut updated_metars: Vec<Self> = vec![];
|
||||
let mut missing_metar_icaos: Vec<String> = vec![];
|
||||
let mut found_metar_icaos: HashSet<String> = HashSet::new();
|
||||
let mut requested_icaos: HashSet<String> = HashSet::from_iter(icao_list.clone());
|
||||
let mut requested_icaos: HashSet<String> = HashSet::from_iter(icaos.clone());
|
||||
|
||||
// Iterate over returned database metars
|
||||
for metar_row in metar_rows {
|
||||
let icao = metar_row.icao.clone();
|
||||
// Remove icao from requested icaos
|
||||
for metar in metars {
|
||||
let icao = metar.icao.clone();
|
||||
// Remove found icao from requested ICAOs
|
||||
requested_icaos.remove(&icao);
|
||||
|
||||
// Handle outdated metars
|
||||
if current_time > (metar_row.observation_time.timestamp() + time_offset) {
|
||||
// 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 {
|
||||
Some(c) => current_time - c.updated_at.timestamp(),
|
||||
None => short_time_offset,
|
||||
None => DEFAULT_REFRESH_DURATION,
|
||||
};
|
||||
// If the metar was cached more than short_time_offset minutes ago, refresh it
|
||||
if refresh_seconds >= short_time_offset {
|
||||
|
||||
// If the metar is outdated, add it to the refresh list
|
||||
if refresh_seconds >= DEFAULT_REFRESH_DURATION {
|
||||
log::trace!("{} METAR data is outdated, marked for refresh", &icao);
|
||||
missing_metar_icaos.push(icao.clone());
|
||||
}
|
||||
// Otherwise return outdated data and wait
|
||||
// Otherwise return the outdated data (to be checked on the next cycle)
|
||||
else {
|
||||
log::trace!(
|
||||
"{} METAR data is outdated; refreshing in {} seconds",
|
||||
&icao,
|
||||
short_time_offset - refresh_seconds
|
||||
DEFAULT_REFRESH_DURATION - refresh_seconds
|
||||
);
|
||||
metars.push(Metar::from_db(metar_row)?)
|
||||
updated_metars.push(metar);
|
||||
}
|
||||
}
|
||||
// Otherwise add the metar to the vector
|
||||
// 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(time_offset as u64).await?;
|
||||
metars.push(Metar::from_db(metar_row)?);
|
||||
metar_check.insert().await?;
|
||||
updated_metars.push(metar);
|
||||
}
|
||||
}
|
||||
|
||||
// Add all metars that were not in the returned database metars
|
||||
// Add all METARs that were not in the returned database METARs
|
||||
for icao in &requested_icaos {
|
||||
match MetarCheck::get(icao).await {
|
||||
Some(c) => {
|
||||
if current_time > (c.updated_at.timestamp() + short_time_offset) {
|
||||
if current_time > (c.updated_at.timestamp() + DEFAULT_REFRESH_DURATION) {
|
||||
missing_metar_icaos.push(icao.to_string());
|
||||
}
|
||||
}
|
||||
@@ -1075,6 +1163,7 @@ impl Metar {
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve missing METARs
|
||||
if !missing_metar_icaos.is_empty() {
|
||||
log::trace!(
|
||||
"Retrieving missing METAR data for {:?}",
|
||||
@@ -1087,38 +1176,47 @@ impl Metar {
|
||||
vec![]
|
||||
});
|
||||
|
||||
// Insert missing METARs
|
||||
if remote_metars.len() > 0 {
|
||||
// Insert missing METARs
|
||||
for remote_metar in remote_metars.clone() {
|
||||
remote_metar.insert().await?;
|
||||
found_metar_icaos.insert(remote_metar.icao.to_string());
|
||||
let mut metar_check = MetarCheck::new(remote_metar.icao.clone(), true).await;
|
||||
metar_check.last_metar = Some(remote_metar);
|
||||
metar_check.insert(time_offset as u64).await?;
|
||||
metar_check.insert().await?;
|
||||
}
|
||||
metars.append(&mut remote_metars);
|
||||
updated_metars.append(&mut remote_metars);
|
||||
}
|
||||
|
||||
// Update still missing metars
|
||||
// let mut still_missing_metar_icaos: Vec<String> = vec![];
|
||||
// Update still missing METARs
|
||||
for difference in found_metar_icaos.symmetric_difference(&requested_icaos) {
|
||||
// still_missing_metar_icaos.push(difference.to_string());
|
||||
let metar_check = MetarCheck::new(difference.to_string(), false).await;
|
||||
metar_check.insert(short_time_offset as u64).await?;
|
||||
metar_check.insert().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);
|
||||
if last_metar.observation_time < four_hours_ago {
|
||||
metars.push(last_metar);
|
||||
updated_metars.push(last_metar);
|
||||
}
|
||||
}
|
||||
}
|
||||
// if !still_missing_metar_icaos.is_empty() {
|
||||
// log::trace!("Still missing METAR data from {:?}", still_missing_metar_icaos);
|
||||
// }
|
||||
}
|
||||
|
||||
Ok(metars)
|
||||
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)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
log::warn!("Unable to get cached remote METAR data; {}", err);
|
||||
(vec![], String::new())
|
||||
});
|
||||
for remote_metar in remote_metars.clone() {
|
||||
remote_metar.insert().await?;
|
||||
}
|
||||
|
||||
Ok(etag)
|
||||
}
|
||||
|
||||
pub async fn insert(&self) -> ApiResult<()> {
|
||||
@@ -1127,7 +1225,7 @@ impl Metar {
|
||||
self.icao,
|
||||
self.observation_time
|
||||
);
|
||||
let metar: MetarRow = self.to_db()?;
|
||||
let metar: MetarRow = self.to_row()?;
|
||||
metar.insert().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use crate::AppState;
|
||||
use crate::account::Auth;
|
||||
use crate::metars::Metar;
|
||||
use actix_web::{HttpRequest, HttpResponse, get, web};
|
||||
use actix_web::{HttpRequest, HttpResponse, get, put, web};
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use utoipa_actix_web::scope;
|
||||
use utoipa_actix_web::service_config::ServiceConfig;
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
@@ -13,16 +15,16 @@ struct MetarQuery {
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "METARs",
|
||||
tag = "metar",
|
||||
params(
|
||||
MetarQuery,
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "", body = [Metar]),
|
||||
(status = 200, description = "Successful Response", body = [Metar]),
|
||||
),
|
||||
)]
|
||||
#[get("/metars")]
|
||||
async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
|
||||
#[get("")]
|
||||
async fn find_all(req: HttpRequest) -> HttpResponse {
|
||||
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
|
||||
let icao_option = ¶meters.icaos;
|
||||
if let None = icao_option {
|
||||
@@ -33,10 +35,47 @@ async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
|
||||
Some(i) => i,
|
||||
None => return HttpResponse::UnprocessableEntity().body("Missing icaos parameter"),
|
||||
};
|
||||
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_string()).collect();
|
||||
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect();
|
||||
|
||||
let client = &data.client;
|
||||
let metars = match Metar::find_all_distinct(client, &icaos).await {
|
||||
let metars = match Metar::get_all_distinct(&icaos).await {
|
||||
Ok(a) => a,
|
||||
Err(err) => {
|
||||
error!("{}", err);
|
||||
return err.to_http_response();
|
||||
}
|
||||
};
|
||||
HttpResponse::Ok().json(metars)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "metar",
|
||||
params(
|
||||
MetarQuery,
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Successful Response", body = [Metar]),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(
|
||||
("session_auth" = [])
|
||||
)
|
||||
)]
|
||||
#[put("")]
|
||||
async fn refresh_metars(data: web::Data<AppState>, req: HttpRequest, _auth: Auth) -> HttpResponse {
|
||||
let client = data.client.clone();
|
||||
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
|
||||
let icao_option = ¶meters.icaos;
|
||||
if let None = icao_option {
|
||||
let empty_metars: Vec<Metar> = vec![];
|
||||
return HttpResponse::Ok().json(empty_metars);
|
||||
}
|
||||
let icao_string = match icao_option {
|
||||
Some(i) => i,
|
||||
None => return HttpResponse::UnprocessableEntity().body("Missing icaos parameter"),
|
||||
};
|
||||
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect();
|
||||
|
||||
let metars = match Metar::get_or_update_metars(&client, &icaos).await {
|
||||
Ok(a) => a,
|
||||
Err(err) => {
|
||||
error!("{}", err);
|
||||
@@ -47,5 +86,9 @@ async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
|
||||
}
|
||||
|
||||
pub fn init_routes(config: &mut ServiceConfig) {
|
||||
config.service(find_all);
|
||||
config.service(
|
||||
scope::scope("/metars")
|
||||
.service(find_all)
|
||||
.service(refresh_metars),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,37 @@
|
||||
// use tokio::time::{sleep, Duration};
|
||||
use crate::http_client::HttpClient;
|
||||
use crate::metars::Metar;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time::interval;
|
||||
|
||||
// use crate::airports::{AirportDb, AirportFilter};
|
||||
// use crate::metars::Metar;
|
||||
pub fn update_metars(client: Arc<HttpClient>, seconds: u64) {
|
||||
tokio::spawn(async move {
|
||||
// Create interval ticker
|
||||
let mut interval = interval(Duration::from_secs(seconds));
|
||||
let mut etag = None;
|
||||
|
||||
pub fn update_airports() {
|
||||
// tokio::spawn(async {
|
||||
// let mut airports: Vec<AirportDb> = vec![];
|
||||
// let limit = 100;
|
||||
// loop {
|
||||
// log::debug!("METAR update start");
|
||||
// let total = match AirportDb::count(&AirportFilter::default()).await {
|
||||
// Ok(t) => t,
|
||||
// Err(err) => {
|
||||
// log::warn!("{}", err);
|
||||
// break;
|
||||
// }
|
||||
// };
|
||||
// if total != airports.len() as i64 {
|
||||
// log::debug!("{} cached airports, expected {}", airports.len(), total);
|
||||
// airports = vec![];
|
||||
// let pages = ((total as f32) / (if limit <= 0 { 1 } else { limit } as f32)).ceil() as i32;
|
||||
// for page in 1..(pages + 1) {
|
||||
// match AirportDb::find_all(&AirportFilter::default(), limit, page).await {
|
||||
// Ok(mut a) => airports.append(&mut a),
|
||||
// Err(err) => {
|
||||
// log::warn!("{}", err);
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// log::debug!("Updating {} airport METARS", airports.len());
|
||||
//
|
||||
// let airport_icaos: Vec<String> = airports.iter().map(|a| a.icao.to_string()).collect();
|
||||
// let mut peekable = airport_icaos.into_iter().peekable();
|
||||
// let mut observation_time = chrono::Utc::now().timestamp();
|
||||
//
|
||||
// if peekable.peek().is_none() {
|
||||
// log::debug!("No airports to update, sleeping for 1 hour");
|
||||
// sleep(Duration::from_secs(3600)).await;
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// while peekable.peek().is_some() {
|
||||
// let chunk: Vec<String> = peekable.by_ref().take(limit as usize).collect();
|
||||
// let icao_string = chunk.join(",");
|
||||
// log::warn!("Updating METARS for: {}", &icao_string); // TODO: back to trace after
|
||||
// match Metar::find_all(&[&icao_string]).await {
|
||||
// Ok(metars) => {
|
||||
// // Find the oldest observation time
|
||||
// for metar in metars {
|
||||
// if metar.observation_time.timestamp() < observation_time {
|
||||
// observation_time = metar.observation_time.timestamp();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// Err(err) => {
|
||||
// log::warn!("{}", err);
|
||||
// }
|
||||
// }
|
||||
// // Sleep for 100ms between chunks to avoid rate limiting
|
||||
// sleep(Duration::from_millis(100)).await;
|
||||
// }
|
||||
// log::debug!("METAR update complete");
|
||||
// // Sleep until the earliest observation time is 1 hour old
|
||||
// // Bounded by 1 and 3600 seconds
|
||||
// let now = chrono::Utc::now().timestamp();
|
||||
// let sleep_time = std::cmp::min(std::cmp::max(1, now - (observation_time + 3600)), 3600);
|
||||
// log::debug!("Next update in {} seconds", sleep_time);
|
||||
// sleep(Duration::from_secs(sleep_time as u64)).await;
|
||||
// }
|
||||
// });
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// Record start times
|
||||
let start_monotonic = Instant::now();
|
||||
let start_utc: DateTime<Utc> = Utc::now();
|
||||
log::debug!("METAR update started at {}", start_utc);
|
||||
|
||||
// Run the update
|
||||
match Metar::update_metars(&client, etag.clone()).await {
|
||||
Ok(new_etag) => etag = Some(new_etag),
|
||||
Err(err) => log::error!("METAR update failed: {}", err),
|
||||
}
|
||||
|
||||
let elapsed = start_monotonic.elapsed();
|
||||
let next_utc = Utc::now() + chrono::Duration::from_std(Duration::from_secs(seconds)).unwrap();
|
||||
log::info!(
|
||||
"METAR update finished in {:.2?}; next run at {}",
|
||||
elapsed,
|
||||
next_utc
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
use crate::error::ApiResult;
|
||||
use chrono::{Datelike, Utc};
|
||||
use handlebars::Handlebars;
|
||||
use lettre::message::header::ContentType;
|
||||
use lettre::message::{Mailbox, MultiPart, SinglePart};
|
||||
use lettre::transport::smtp::AsyncSmtpTransportBuilder;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{Address, Message, SmtpTransport, Transport};
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use lettre::{Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
use std::env;
|
||||
use std::sync::OnceLock;
|
||||
use std::{env, fs};
|
||||
use std::time::Duration;
|
||||
|
||||
static MAILER: OnceLock<SmtpTransport> = OnceLock::new();
|
||||
static MAILER: OnceLock<AsyncSmtpTransport<Tokio1Executor>> = OnceLock::new();
|
||||
static FROM_ADDRESS: OnceLock<Mailbox> = OnceLock::new();
|
||||
static REGISTRY: OnceLock<Handlebars> = OnceLock::new();
|
||||
|
||||
fn mailer() -> &'static SmtpTransport {
|
||||
fn mailer() -> &'static AsyncSmtpTransport<Tokio1Executor> {
|
||||
MAILER.get_or_init(|| {
|
||||
let server = env::var("SMTP_SERVER").expect("SMTP_SERVER missing");
|
||||
let username = env::var("SMTP_USERNAME").expect("SMTP_USERNAME missing");
|
||||
let password = env::var("SMTP_PASSWORD").expect("SMTP_PASSWORD missing");
|
||||
let port = env::var("SMTP_PORT").expect("SMTP_PORT missing");
|
||||
let creds = Credentials::new(username, password);
|
||||
SmtpTransport::relay(&server)
|
||||
.expect("invalid SMTP_SERVER")
|
||||
let builder: AsyncSmtpTransportBuilder;
|
||||
if server == "localhost" || server == "127.0.0.1" {
|
||||
builder = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&server);
|
||||
log::warn!("Using a local SMTP server: {}", server);
|
||||
} else {
|
||||
builder = AsyncSmtpTransport::<Tokio1Executor>::relay(&server).expect("invalid SMTP_SERVER");
|
||||
}
|
||||
builder
|
||||
.credentials(creds)
|
||||
.port(port.parse().expect("SMTP_PORT invalid"))
|
||||
.timeout(Some(Duration::from_secs(10)))
|
||||
.build()
|
||||
})
|
||||
}
|
||||
@@ -39,7 +47,7 @@ pub fn registry() -> &'static Handlebars<'static> {
|
||||
REGISTRY.get_or_init(|| Handlebars::new())
|
||||
}
|
||||
|
||||
pub fn send_email(to: &str, subject: &str, header: String, html: String) -> ApiResult<()> {
|
||||
pub async fn send_email(to: &str, subject: &str, header: String, html: String) -> ApiResult<()> {
|
||||
let to_address = to.parse::<Address>()?;
|
||||
let to_mailbox = Mailbox::new(None, to_address);
|
||||
|
||||
@@ -63,6 +71,6 @@ pub fn send_email(to: &str, subject: &str, header: String, html: String) -> ApiR
|
||||
)?;
|
||||
|
||||
// Send the email
|
||||
mailer().send(&email)?;
|
||||
mailer().send(email).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12,24 +12,20 @@ pub struct SystemInfo {
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "System",
|
||||
tag = "system",
|
||||
responses(
|
||||
(status = 200, description = "Successful system info"),
|
||||
)
|
||||
)]
|
||||
#[get("/info")]
|
||||
async fn info() -> HttpResponse {
|
||||
let mut healthy = true;
|
||||
let version = match env::var("CARGO_PKG_VERSION") {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
healthy = false;
|
||||
String::from("unknown")
|
||||
}
|
||||
let healthy = true;
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let info = SystemInfo {
|
||||
version: version.to_string(),
|
||||
healthy,
|
||||
};
|
||||
|
||||
let info = SystemInfo { version, healthy };
|
||||
|
||||
HttpResponse::Ok().json(info)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,20 +2,34 @@ 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 utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const ADMIN_ROLE: &str = "ADMIN";
|
||||
pub const USER_ROLE: &str = "USER";
|
||||
const TABLE_NAME: &str = "users";
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[schema(
|
||||
example = json!(
|
||||
{
|
||||
"email": "user",
|
||||
"email": "user@example.com",
|
||||
"password": "changeme",
|
||||
"firstName": "firstname",
|
||||
"lastName": "lastname"
|
||||
}
|
||||
)
|
||||
)]
|
||||
pub struct RegisterRequest {
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub password: String,
|
||||
#[serde(rename = "firstName")]
|
||||
pub first_name: String,
|
||||
#[serde(rename = "lastName")]
|
||||
pub last_name: String,
|
||||
}
|
||||
|
||||
@@ -23,8 +37,11 @@ impl RegisterRequest {
|
||||
pub fn to_user(self) -> ApiResult<User> {
|
||||
let password_hash = hash(&self.password)?;
|
||||
Ok(User {
|
||||
id: Uuid::new_v4(),
|
||||
email: self.email.to_lowercase(),
|
||||
username: self.username,
|
||||
email: match self.email {
|
||||
Some(email) => Some(email.to_lowercase()),
|
||||
None => None,
|
||||
},
|
||||
email_verified: false,
|
||||
password_hash,
|
||||
role: USER_ROLE.to_string(),
|
||||
@@ -41,31 +58,34 @@ impl RegisterRequest {
|
||||
#[schema(
|
||||
example = json!(
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"username": "admin",
|
||||
"password": "changeme"
|
||||
}
|
||||
)
|
||||
)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UserResponse {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
#[serde(rename = "firstName")]
|
||||
pub first_name: String,
|
||||
#[serde(rename = "lastName")]
|
||||
pub last_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar: Option<String>,
|
||||
#[serde(rename = "emailVerified")]
|
||||
pub email_verified: bool,
|
||||
}
|
||||
|
||||
impl From<User> for UserResponse {
|
||||
fn from(user: User) -> Self {
|
||||
UserResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email_verified: user.email_verified,
|
||||
role: user.role,
|
||||
first_name: user.first_name,
|
||||
@@ -87,7 +107,7 @@ pub struct UpdateUser {
|
||||
}
|
||||
|
||||
impl UpdateUser {
|
||||
pub async fn update(&self, id: &Uuid) -> ApiResult<User> {
|
||||
pub async fn update(&self, username: &str) -> ApiResult<User> {
|
||||
let pool = db::pool();
|
||||
|
||||
let mut query_builder: QueryBuilder<Postgres> =
|
||||
@@ -143,8 +163,8 @@ impl UpdateUser {
|
||||
query_builder.push("updated_at = ");
|
||||
query_builder.push_bind(Utc::now());
|
||||
|
||||
query_builder.push(" WHERE id = ");
|
||||
query_builder.push_bind(id);
|
||||
query_builder.push(" WHERE username = ");
|
||||
query_builder.push_bind(username);
|
||||
query_builder.push(" RETURNING *");
|
||||
|
||||
let query = query_builder.build_query_as::<User>();
|
||||
@@ -156,8 +176,8 @@ impl UpdateUser {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub password_hash: String,
|
||||
pub role: String,
|
||||
@@ -169,19 +189,19 @@ pub struct User {
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub async fn select(id: &Uuid) -> Option<Self> {
|
||||
pub async fn select(username: &str) -> Option<Self> {
|
||||
let pool = db::pool();
|
||||
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
|
||||
r#"
|
||||
SELECT * FROM {} WHERE id = $1
|
||||
SELECT * FROM {} WHERE username = $1
|
||||
"#,
|
||||
TABLE_NAME
|
||||
))
|
||||
.bind(id)
|
||||
.bind(username)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
log::error!("Unable to find user by id '{}': {}", id, err);
|
||||
log::error!("Unable to find user '{}': {}", username, err);
|
||||
None
|
||||
});
|
||||
|
||||
@@ -192,11 +212,11 @@ impl User {
|
||||
let pool = db::pool();
|
||||
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
|
||||
r#"
|
||||
SELECT * FROM {} WHERE email = LOWER($1)
|
||||
SELECT * FROM {} WHERE email = $1
|
||||
"#,
|
||||
TABLE_NAME
|
||||
))
|
||||
.bind(email)
|
||||
.bind(email.to_lowercase())
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
@@ -207,6 +227,7 @@ impl User {
|
||||
user
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn count() -> i64 {
|
||||
let pool = db::pool();
|
||||
|
||||
@@ -226,7 +247,7 @@ impl User {
|
||||
let user: User = sqlx::query_as::<_, Self>(&format!(
|
||||
r#"
|
||||
INSERT INTO {} (
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
email_verified,
|
||||
password_hash,
|
||||
@@ -242,7 +263,7 @@ impl User {
|
||||
"#,
|
||||
TABLE_NAME,
|
||||
))
|
||||
.bind(&self.id)
|
||||
.bind(&self.username)
|
||||
.bind(&self.email)
|
||||
.bind(&self.email_verified)
|
||||
.bind(&self.password_hash)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Change Password
|
||||
type: http
|
||||
seq: 4
|
||||
seq: 6
|
||||
}
|
||||
|
||||
put {
|
||||
@@ -11,7 +11,9 @@ put {
|
||||
}
|
||||
|
||||
body:json {
|
||||
"New Password"
|
||||
{
|
||||
"password": "New Password"
|
||||
}
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
18
bruno/Account/Confirm Password Reset.bru
Normal file
18
bruno/Account/Confirm Password Reset.bru
Normal file
@@ -0,0 +1,18 @@
|
||||
meta {
|
||||
name: Confirm Password Reset
|
||||
type: http
|
||||
seq: 8
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{API_URL}}/account/password/verify
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"token": "token",
|
||||
"password": "New Password"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Get Profile
|
||||
type: http
|
||||
seq: 7
|
||||
seq: 10
|
||||
}
|
||||
|
||||
get {
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Login
|
||||
type: http
|
||||
seq: 2
|
||||
seq: 4
|
||||
}
|
||||
|
||||
post {
|
||||
@@ -12,7 +12,7 @@ post {
|
||||
|
||||
body:json {
|
||||
{
|
||||
"email": "admin@example.com",
|
||||
"username": "user",
|
||||
"password": "changeme"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Logout
|
||||
type: http
|
||||
seq: 3
|
||||
seq: 5
|
||||
}
|
||||
|
||||
post {
|
||||
@@ -12,7 +12,7 @@ post {
|
||||
|
||||
body:json {
|
||||
{
|
||||
"email": "john.doe@gmail.com",
|
||||
"password": "fake_password123"
|
||||
"email": "user@gmail.com",
|
||||
"password": "changeme"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Refresh Session
|
||||
type: http
|
||||
seq: 6
|
||||
seq: 9
|
||||
}
|
||||
|
||||
get {
|
||||
@@ -12,9 +12,10 @@ post {
|
||||
|
||||
body:json {
|
||||
{
|
||||
"email": "john.doe@gmail.com",
|
||||
"password": "fake_password123",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe"
|
||||
"username": "user",
|
||||
"email": "user@example.com",
|
||||
"password": "changeme",
|
||||
"firstName": "John",
|
||||
"lastName": "Doe"
|
||||
}
|
||||
}
|
||||
11
bruno/Account/Resend Email Confirmation.bru
Normal file
11
bruno/Account/Resend Email Confirmation.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Resend Email Confirmation
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{API_URL}}/account/register/resend
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
meta {
|
||||
name: Reset Password
|
||||
type: http
|
||||
seq: 5
|
||||
seq: 7
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{API_URL}}/account/password/reset
|
||||
body: none
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"email": "user@example.com"
|
||||
}
|
||||
}
|
||||
17
bruno/Account/Verify Email Confirmation.bru
Normal file
17
bruno/Account/Verify Email Confirmation.bru
Normal file
@@ -0,0 +1,17 @@
|
||||
meta {
|
||||
name: Verify Email Confirmation
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{API_URL}}/account/register/verify
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"token": "token"
|
||||
}
|
||||
}
|
||||
3
bruno/Account/folder.bru
Normal file
3
bruno/Account/folder.bru
Normal file
@@ -0,0 +1,3 @@
|
||||
meta {
|
||||
name: Account
|
||||
}
|
||||
@@ -25,8 +25,7 @@ services:
|
||||
volumes:
|
||||
- ./ssl:/etc/nginx/ssl/
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
- default
|
||||
<<: *default_restart
|
||||
|
||||
postgres:
|
||||
@@ -43,7 +42,7 @@ services:
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
networks:
|
||||
- backend
|
||||
- default
|
||||
profiles:
|
||||
- backend
|
||||
<<: *default_restart
|
||||
@@ -61,7 +60,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- backend
|
||||
- default
|
||||
profiles:
|
||||
- backend
|
||||
<<: *default_restart
|
||||
@@ -80,7 +79,7 @@ services:
|
||||
- "${MINIO_PORT:-9000}:9000"
|
||||
- "${MINIO_INTERNAL_PORT:-9001}:9001"
|
||||
networks:
|
||||
- backend
|
||||
- default
|
||||
profiles:
|
||||
- backend
|
||||
command: server --console-address ":9001" /data
|
||||
@@ -113,32 +112,50 @@ services:
|
||||
- redis
|
||||
- minio
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
- default
|
||||
profiles:
|
||||
- api
|
||||
<<: *default_restart
|
||||
|
||||
ui-dev:
|
||||
image: gitea.bensherriff.com/bsherriff/aviation-ui:latest
|
||||
container_name: aviation-ui-dev
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file: *env
|
||||
# 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
|
||||
environment:
|
||||
- VITE_NODE_ENV=${VITE_NODE_ENV:-development}
|
||||
MP_MAX_MESSAGES: 5000
|
||||
MP_DATABASE: /data/mailpit.db
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||
ports:
|
||||
- "${UI_PORT:-3000}:3000"
|
||||
- "${MAILPIT_WEB_PORT:-8025}:8025"
|
||||
- "${MAILPIT_SMTP_PORT:-1025}:1025"
|
||||
volumes:
|
||||
- ./ui/src:/app/src
|
||||
- ./ui/public:/app/public
|
||||
- ./ui/styles:/app/styles
|
||||
- mailpit:/data
|
||||
networks:
|
||||
- frontend
|
||||
- default
|
||||
profiles:
|
||||
- frontend
|
||||
command: ["npm", "run", "dev"]
|
||||
- dev
|
||||
<<: *default_restart
|
||||
|
||||
volumes:
|
||||
@@ -146,7 +163,7 @@ volumes:
|
||||
postgres_logs:
|
||||
redis:
|
||||
minio:
|
||||
mailpit:
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
backend:
|
||||
default:
|
||||
|
||||
183
ui/package-lock.json
generated
183
ui/package-lock.json
generated
@@ -65,15 +65,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.26.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
|
||||
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.25.9",
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.0.0"
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -197,9 +197,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
|
||||
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -207,9 +207,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
|
||||
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -227,27 +227,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz",
|
||||
"integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz",
|
||||
"integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/types": "^7.26.9"
|
||||
"@babel/template": "^7.27.1",
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz",
|
||||
"integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==",
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz",
|
||||
"integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.9"
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -289,27 +289,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
|
||||
"integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
|
||||
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
|
||||
"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/parser": "^7.26.9",
|
||||
"@babel/types": "^7.26.9"
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/parser": "^7.27.2",
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -345,14 +342,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz",
|
||||
"integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
|
||||
"integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.25.9",
|
||||
"@babel/helper-validator-identifier": "^7.25.9"
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1571,12 +1568,6 @@
|
||||
"@babel/types": "^7.20.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
@@ -4405,15 +4396,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.0.tgz",
|
||||
"integrity": "sha512-estOHrRlDMKdlQa6Mj32gIks4J+AxNsYoE0DbTTxiMy2mPzZuWSDU+N85/r1IlNR7kGfznF3VCUlvc5IUO+B9g==",
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz",
|
||||
"integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cookie": "^0.6.0",
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0",
|
||||
"turbo-stream": "2.4.0"
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -4493,12 +4482,6 @@
|
||||
"pify": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -4845,6 +4828,51 @@
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
||||
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.4.4",
|
||||
"picomatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/fdir": {
|
||||
"version": "6.4.4",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
|
||||
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -4877,12 +4905,6 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/turbo-stream": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
|
||||
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -5089,15 +5111,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz",
|
||||
"integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==",
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
"picomatch": "^4.0.2",
|
||||
"postcss": "^8.5.3",
|
||||
"rollup": "^4.30.1"
|
||||
"rollup": "^4.34.9",
|
||||
"tinyglobby": "^0.2.13"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
@@ -5160,6 +5185,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/fdir": {
|
||||
"version": "6.4.4",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
|
||||
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
@@ -106,6 +107,7 @@ function App() {
|
||||
]}
|
||||
scrollWheelZoom={true}
|
||||
zoomControl={false}
|
||||
markerZoomAnimation={false}
|
||||
>
|
||||
<AirportDrawer airport={airport} setAirport={setAirport} />
|
||||
<LayersControl>
|
||||
@@ -139,6 +141,7 @@ function App() {
|
||||
/>
|
||||
</MapContainer>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ import {
|
||||
import { Airport, AirportCategory } from '@lib/airport.types.ts';
|
||||
import { getMarkerColor, Metar } from '@lib/metar.types.ts';
|
||||
import { CSSProperties, forwardRef, ReactNode, useEffect, useState } from 'react';
|
||||
import { getMetars } from '@lib/metar.ts';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { IconViewfinder } from '@tabler/icons-react';
|
||||
import { RunwayTable } from '@components/AirportDrawer/RunwayTable.tsx';
|
||||
import { CommunicationTable } from '@components/AirportDrawer/CommunicationTable.tsx';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import type { Map as LeafletMap } from 'leaflet';
|
||||
import { getMetars } from '@lib/metar.ts';
|
||||
|
||||
export function AirportDrawer({
|
||||
airport,
|
||||
@@ -204,11 +204,89 @@ function AirportInfo({ map, airport }: { map: LeafletMap; airport: Airport }) {
|
||||
}
|
||||
|
||||
function WeatherInfo({ metar }: { metar?: Metar }) {
|
||||
if (metar) {
|
||||
return <>{metar.raw_text}</>;
|
||||
} else {
|
||||
return <>No METAR observation available</>;
|
||||
if (!metar) {
|
||||
return <>No METAR observation available/</>
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Text size={'xs'} color={'dimmed'} mt={'xs'}>
|
||||
Raw METAR
|
||||
</Text>
|
||||
<Text size={'sm'} mb={'md'}>
|
||||
{metar.raw_text}
|
||||
</Text>
|
||||
|
||||
<Group mb={'xs'}>
|
||||
{metar.report_modifier && <Badge>{metar.report_modifier}</Badge>}
|
||||
{metar.becoming_change && <Badge>TEMPO/BCMG</Badge>}
|
||||
{metar.temporary_change && <Badge>TEMPO</Badge>}
|
||||
{metar.no_significant_change && <Badge>No Change</Badge>}
|
||||
</Group>
|
||||
|
||||
<Text size={'xs'} color={'dimmed'}>
|
||||
Observation Time
|
||||
</Text>
|
||||
<Text size={'sm'} mb={'md'}>
|
||||
{new Date(metar.observation_time).toLocaleString()}
|
||||
</Text>
|
||||
|
||||
{metar.wind_dir_degrees && metar.wind_speed_kt != null && (
|
||||
<Text mb="sm">
|
||||
<strong>Wind:</strong> {metar.wind_dir_degrees}° at {metar.wind_speed_kt} kt
|
||||
{metar.wind_gust_kt && `, gusts ${metar.wind_gust_kt} kt`}
|
||||
{metar.variable_wind_dir_degrees && ` (variable ${metar.variable_wind_dir_degrees})`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{metar.visibility_statute_mi && (
|
||||
<Text mb="sm">
|
||||
<strong>Visibility:</strong> {metar.visibility_statute_mi} statute miles
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(metar.temp_c != null || metar.dew_point_c != null) && (
|
||||
<Text mb="sm">
|
||||
<strong>Temp / Dew Point:</strong> {metar.temp_c}°C / {metar.dew_point_c}°C
|
||||
{metar.estimated_humidity != null && ` (${metar.estimated_humidity}% RH)`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(metar.altimeter_in_hg != null || metar.sea_level_pressure_mb != null) && (
|
||||
<Text mb="sm">
|
||||
<strong>Pressure:</strong>
|
||||
{metar.altimeter_in_hg != null && ` Alt ${metar.altimeter_in_hg} inHg`}
|
||||
{metar.sea_level_pressure_mb != null && `, SLP ${metar.sea_level_pressure_mb} mb`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{metar.weather_phenomena.length > 0 && (
|
||||
<Text mb="sm">
|
||||
<strong>Weather:</strong> {metar.weather_phenomena.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{metar.sky_condition.length > 0 && (
|
||||
<Text mb="sm">
|
||||
<strong>Sky:</strong>{' '}
|
||||
{metar.sky_condition
|
||||
.map((s) => `${s.sky_cover}${s.cloud_base_ft_agl ? ` at ${s.cloud_base_ft_agl} ft` : ''}`)
|
||||
.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(metar.max_temp_c != null && metar.min_temp_c != null) && (
|
||||
<Text mb="sm">
|
||||
<strong>Max / Min:</strong> {metar.max_temp_c}°C / {metar.min_temp_c}°C
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{metar.density_altutude != null && (
|
||||
<Text mb="sm">
|
||||
<strong>Density Altitude:</strong> {metar.density_altutude} ft
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function airportCategoryToText(category: AirportCategory): string {
|
||||
|
||||
@@ -5,8 +5,7 @@ import debounce from 'lodash.debounce';
|
||||
import { getAirports } from '@lib/airport.ts';
|
||||
import AirportMarker from '@components/AirportMarker.tsx';
|
||||
import { LayerInfo } from '@/App.tsx';
|
||||
|
||||
const EXPANSION_FACTOR = 0.5;
|
||||
import { LatLng } from 'leaflet';
|
||||
|
||||
export default function AirportLayer({
|
||||
setAirport,
|
||||
@@ -18,21 +17,13 @@ export default function AirportLayer({
|
||||
selectedLayer: LayerInfo;
|
||||
}) {
|
||||
const [airports, setAirports] = useState<Airport[]>([]);
|
||||
const lastBoundsRef = useRef<{ ne: any; sw: any } | null>(null);
|
||||
const lastBoundsRef = useRef<{ ne: LatLng; sw: LatLng } | null>(null);
|
||||
|
||||
const debouncedLoad = useRef(
|
||||
debounce(async (map: any) => {
|
||||
const b = map.getBounds();
|
||||
const north = b.getNorth(),
|
||||
south = b.getSouth();
|
||||
const east = b.getEast(),
|
||||
west = b.getWest();
|
||||
const latDelta = (north - south) * EXPANSION_FACTOR;
|
||||
const lonDelta = (east - west) * EXPANSION_FACTOR;
|
||||
|
||||
// expanded bbox
|
||||
const ne = { lat: north + latDelta, lon: east + lonDelta };
|
||||
const sw = { lat: south - latDelta, lon: west - lonDelta };
|
||||
const bounds = map.getBounds();
|
||||
const ne = bounds.getNorthEast();
|
||||
const sw = bounds.getSouthWest();
|
||||
lastBoundsRef.current = { ne, sw };
|
||||
|
||||
try {
|
||||
@@ -58,7 +49,7 @@ export default function AirportLayer({
|
||||
return () => {
|
||||
debouncedLoad.cancel();
|
||||
};
|
||||
}, [map]);
|
||||
}, [map, debouncedLoad]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
26
ui/src/components/Footer/Footer.module.css
Normal file
26
ui/src/components/Footer/Footer.module.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.footer {
|
||||
background: #32495f;
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
|
||||
|
||||
@media (max-width: $mantine-breakpoint-xs) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
@media (max-width: $mantine-breakpoint-xs) {
|
||||
margin-top: var(--mantine-spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: light-dark(var(--mantine-color-white));
|
||||
}
|
||||
53
ui/src/components/Footer/index.tsx
Normal file
53
ui/src/components/Footer/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import classes from './Footer.module.css';
|
||||
import { Divider, Group, Text } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { systemInfo } from '@lib/system.ts';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
|
||||
const links = [
|
||||
{ link: `/swagger/`, newTab: true, label: 'API Docs' },
|
||||
{ link: '/cookies', label: 'Cookies' },
|
||||
{ link: '/privacy', label: 'Privacy' },
|
||||
{ link: '/terms', label: 'Terms' },
|
||||
{ link: '/contact', label: 'Contact' }
|
||||
];
|
||||
|
||||
export function Footer() {
|
||||
const [version, setVersion] = useState('0.0.0');
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const items = links.map((link) => (
|
||||
<a className={classes.link} key={link.label} href={link.link} target={link.newTab ? `_blank` : ''}>
|
||||
<Text size='sm'>{link.label}</Text>
|
||||
</a>
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
systemInfo().then((info) => {
|
||||
if (info != undefined) {
|
||||
setVersion(info.version);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={classes.footer}>
|
||||
<Group className={classes.inner}>
|
||||
<Group>
|
||||
<Text size='sm'>
|
||||
API{' '}
|
||||
<a className={classes.link} href={'https://gitea.bensherriff.com/bsherriff/aviation'} target={'_blank'}>
|
||||
v{version}
|
||||
</a>
|
||||
</Text>
|
||||
<Divider orientation={'vertical'} />
|
||||
<Text size='sm'>© {new Date().getFullYear()} Aviation Data</Text>
|
||||
</Group>
|
||||
{!isMobile && (
|
||||
<Group gap='xs' justify='flex-end' wrap='nowrap'>
|
||||
{items}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,46 +17,46 @@ import Cookies from 'js-cookie';
|
||||
interface HeaderModalProps {
|
||||
type?: string;
|
||||
toggle: (input: string | undefined) => void;
|
||||
login: ({ email, password }: { email: string; password: string }) => Promise<boolean>;
|
||||
login: ({ username, password }: { username: string; password: string }) => Promise<boolean>;
|
||||
register: ({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
username,
|
||||
password
|
||||
}: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function HeaderModal({ type, toggle, login, register }: HeaderModalProps) {
|
||||
function passwordValidator(value: string) {
|
||||
if (value.trim().length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
if (value.trim().length < 6) {
|
||||
return 'Password must be at least 6 characters';
|
||||
}
|
||||
if (value.trim().length >= 128) {
|
||||
return 'Password must be at most 128 characters';
|
||||
}
|
||||
if (!/(\d)/.test(value)) {
|
||||
return 'Password must contain at least one number';
|
||||
}
|
||||
if (!/[a-z]/.test(value)) {
|
||||
return 'Password must contain at least one lowercase letter';
|
||||
}
|
||||
if (!/[A-Z]/.test(value)) {
|
||||
return 'Password must contain at least one uppercase letter';
|
||||
}
|
||||
if (!/[!@#$%^&*]/.test(value)) {
|
||||
return 'Password must contain at least one special character';
|
||||
}
|
||||
// if (!/(\d)/.test(value)) {
|
||||
// return 'Password must contain at least one number';
|
||||
// }
|
||||
// if (!/[a-z]/.test(value)) {
|
||||
// return 'Password must contain at least one lowercase letter';
|
||||
// }
|
||||
// if (!/[A-Z]/.test(value)) {
|
||||
// return 'Password must contain at least one uppercase letter';
|
||||
// }
|
||||
// if (!/[!@#$%^&*]/.test(value)) {
|
||||
// return 'Password must contain at least one special character';
|
||||
// }
|
||||
return null;
|
||||
}
|
||||
|
||||
function emailValidator(value: string) {
|
||||
if (value.trim().length == 0) {
|
||||
return 'Email is required';
|
||||
return null;
|
||||
}
|
||||
if (!/^\S+@\S+$/.test(value)) {
|
||||
return 'Invalid email';
|
||||
@@ -68,12 +68,14 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
|
||||
initialValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
username: '',
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
validate: {
|
||||
firstName: (value) => (value.trim().length > 0 ? null : 'First name is required'),
|
||||
lastName: (value) => (value.trim().length > 0 ? null : 'Last name is required'),
|
||||
username: (value) => (value.trim().length > 0 ? null : 'Username is required'),
|
||||
email: emailValidator,
|
||||
password: passwordValidator
|
||||
}
|
||||
@@ -81,7 +83,7 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
|
||||
|
||||
const loginForm = useForm({
|
||||
initialValues: {
|
||||
email: Cookies.get('email') || '',
|
||||
username: Cookies.get('username') || '',
|
||||
password: '',
|
||||
remember: Cookies.get('remember') === 'true'
|
||||
}
|
||||
@@ -150,14 +152,20 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
|
||||
{...registerForm.getInputProps('lastName')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Email'
|
||||
placeholder='you@example.com'
|
||||
label='Username'
|
||||
placeholder='Your username'
|
||||
required
|
||||
{...registerForm.getInputProps('username')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Email'
|
||||
description={'Optional for email verification and updates'}
|
||||
placeholder='you@example.com'
|
||||
{...registerForm.getInputProps('email')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
description='Passwords must be at least 8 characters long, contain at least one number, one uppercase letter, one lowercase letter, and one special character.'
|
||||
// description='Passwords must be at least 8 characters long, contain at least one number, one uppercase letter, one lowercase letter, and one special character.'
|
||||
placeholder='Your password'
|
||||
required
|
||||
mt='md'
|
||||
@@ -184,9 +192,9 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
|
||||
onSubmit={loginForm.onSubmit(async (values) => {
|
||||
Cookies.set('remember', 'true', { expires: 365 });
|
||||
if (values.remember) {
|
||||
Cookies.set('email', values.email, { expires: 365 });
|
||||
Cookies.set('username', values.username, { expires: 365 });
|
||||
} else {
|
||||
Cookies.remove('email');
|
||||
Cookies.remove('username');
|
||||
}
|
||||
const success = await login(values);
|
||||
if (success) {
|
||||
@@ -194,7 +202,12 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
|
||||
}
|
||||
})}
|
||||
>
|
||||
<TextInput label='Email' placeholder='you@example.com' required {...loginForm.getInputProps('email')} />
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Your username'
|
||||
required
|
||||
{...loginForm.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
placeholder='Your password'
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP
|
||||
<Avatar src={profilePicture ? URL.createObjectURL(profilePicture) : undefined} />
|
||||
<div tabIndex={-1} style={{ flex: 1, userSelect: 'none' }}>
|
||||
<Text size='sm' fw={500}>
|
||||
{user.first_name} {user.last_name}
|
||||
{user.firstName} {user.lastName}
|
||||
</Text>
|
||||
<Text c='dimmed' size='xs' style={{ textTransform: 'uppercase' }}>
|
||||
{user.role}
|
||||
@@ -62,7 +62,7 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP
|
||||
)}
|
||||
</FileButton>
|
||||
<Text ta='center' fz='lg' fw={500} mt='sm'>
|
||||
{user.first_name} {user.last_name}
|
||||
{user.firstName} {user.lastName}
|
||||
</Text>
|
||||
<Text ta='center' fz='sm' c='dimmed' style={{ textTransform: 'uppercase' }}>
|
||||
{user.role}
|
||||
|
||||
@@ -36,12 +36,12 @@ export function Header() {
|
||||
// </a>
|
||||
// ));
|
||||
|
||||
async function loginUser({ email, password }: { email: string; password: string }): Promise<boolean> {
|
||||
const loginResponse = await login(email, password);
|
||||
async function loginUser({ username, password }: { username: string; password: string }): Promise<boolean> {
|
||||
const loginResponse = await login(username, password);
|
||||
if (loginResponse) {
|
||||
setUser(loginResponse);
|
||||
notifications.show({
|
||||
title: `Welcome back ${loginResponse.first_name}!`,
|
||||
title: `Welcome back ${loginResponse.firstName}!`,
|
||||
message: `You have been logged in.`,
|
||||
color: 'green',
|
||||
autoClose: 2000,
|
||||
@@ -69,12 +69,14 @@ export function Header() {
|
||||
async function registerUser({
|
||||
firstName,
|
||||
lastName,
|
||||
username,
|
||||
email,
|
||||
password
|
||||
}: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
password: string;
|
||||
}): Promise<boolean> {
|
||||
const id = notifications.show({
|
||||
@@ -85,19 +87,20 @@ export function Header() {
|
||||
withCloseButton: false
|
||||
});
|
||||
const registerResponse = await register({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
username: username,
|
||||
email: email,
|
||||
password: password
|
||||
});
|
||||
if (registerResponse) {
|
||||
const loginResponse = await login(email, password);
|
||||
const loginResponse = await login(username, password);
|
||||
if (loginResponse) {
|
||||
setUser(loginResponse);
|
||||
notifications.update({
|
||||
id,
|
||||
title: `Account created`,
|
||||
message: `Welcome ${loginResponse.first_name}!`,
|
||||
message: `Welcome ${loginResponse.firstName}!`,
|
||||
color: 'green',
|
||||
autoClose: 2000,
|
||||
loading: false
|
||||
|
||||
@@ -12,7 +12,7 @@ export function Profile() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
Todo: profile {user?.first_name}
|
||||
Todo: profile {user?.firstName}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getRequest, postRequest } from '.';
|
||||
import { RegisterUser, User } from './account.types';
|
||||
|
||||
export async function login(email: string, password: string): Promise<User | undefined> {
|
||||
const response = await postRequest('account/login', { email, password });
|
||||
export async function login(username: string, password: string): Promise<User | undefined> {
|
||||
const response = await postRequest('account/login', { username, password });
|
||||
if (response?.status === 200) {
|
||||
return response.json();
|
||||
} else {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
export interface RegisterUser {
|
||||
email: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
email_verified: boolean;
|
||||
username: string;
|
||||
emailVerified: boolean;
|
||||
role: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
profile_picture?: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
profilePicture?: string;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function getAirports({
|
||||
}: GetAirportsParameters): Promise<GetAirportsResponse> {
|
||||
const response = await getRequest('airports', {
|
||||
bounds: bounds
|
||||
? `${bounds?.northEast.lat},${bounds?.northEast.lon},${bounds?.southWest.lat},${bounds?.southWest.lon}`
|
||||
? `${bounds?.northEast.lat},${bounds?.northEast.lng},${bounds?.southWest.lat},${bounds?.southWest.lng}`
|
||||
: undefined,
|
||||
categories: categories ?? undefined,
|
||||
icaos: icaos ?? undefined,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Metar } from './metar.types';
|
||||
import { LatLng } from 'leaflet';
|
||||
|
||||
export enum AirportCategory {
|
||||
SMALL = 'small_airport',
|
||||
@@ -12,13 +13,8 @@ export enum AirportCategory {
|
||||
}
|
||||
|
||||
export interface Bounds {
|
||||
northEast: Coordinate;
|
||||
southWest: Coordinate;
|
||||
}
|
||||
|
||||
export interface Coordinate {
|
||||
lat: number;
|
||||
lon: number;
|
||||
northEast: LatLng;
|
||||
southWest: LatLng;
|
||||
}
|
||||
|
||||
export interface Airport {
|
||||
|
||||
2
ui/src/lib/constants.ts
Normal file
2
ui/src/lib/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// @ts-expect-error The window.__CONFIG__ only exists in production
|
||||
export const API_URL = window.__CONFIG__?.API_URL || import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
|
||||
@@ -1,10 +1,9 @@
|
||||
// @ts-expect-error The window.__CONFIG__ only exists in production
|
||||
const baseUrl = window.__CONFIG__?.API_URL || import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
|
||||
import { API_URL } from '@lib/constants.ts';
|
||||
|
||||
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
|
||||
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
|
||||
const urlParams = new URLSearchParams(params);
|
||||
const url = urlParams && urlParams.size > 0 ? `${baseUrl}/${endpoint}?${urlParams}` : `${baseUrl}/${endpoint}`;
|
||||
const url = urlParams && urlParams.size > 0 ? `${API_URL}/${endpoint}?${urlParams}` : `${API_URL}/${endpoint}`;
|
||||
return await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
@@ -17,7 +16,7 @@ interface PostOptions {
|
||||
}
|
||||
|
||||
export async function postRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
|
||||
const url = `${baseUrl}/${endpoint}`;
|
||||
const url = `${API_URL}/${endpoint}`;
|
||||
let response;
|
||||
if (body && (!options?.type || options.type === 'json')) {
|
||||
response = await fetch(url, {
|
||||
@@ -39,7 +38,7 @@ export async function postRequest(endpoint: string, body?: any, options?: PostOp
|
||||
}
|
||||
|
||||
export async function putRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
|
||||
const url = `${baseUrl}/${endpoint}`;
|
||||
const url = `${API_URL}/${endpoint}`;
|
||||
let response;
|
||||
if (body && (!options?.type || options.type === 'json')) {
|
||||
response = await fetch(url, {
|
||||
@@ -61,7 +60,7 @@ export async function putRequest(endpoint: string, body?: any, options?: PostOpt
|
||||
}
|
||||
|
||||
export async function deleteRequest(endpoint: string): Promise<Response> {
|
||||
const url = `${baseUrl}/${endpoint}`;
|
||||
const url = `${API_URL}/${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Metar } from '@lib/metar.types.ts';
|
||||
import { getRequest } from '@lib/index.ts';
|
||||
import { getRequest, putRequest } from '@lib/index.ts';
|
||||
|
||||
export async function getMetars({ icaos, force }: { icaos: string[]; force?: boolean }): Promise<Metar[]> {
|
||||
export async function getMetars({ icaos }: { icaos: string[] }): Promise<Metar[]> {
|
||||
const response = await getRequest('metars', {
|
||||
icaos: icaos,
|
||||
force: force
|
||||
icaos: icaos
|
||||
});
|
||||
return response?.json() || {};
|
||||
}
|
||||
|
||||
export async function refreshMetars({ icaos }: { icaos: string[] }): Promise<Metar[]> {
|
||||
const response = await putRequest('metars', {
|
||||
icaos: icaos
|
||||
});
|
||||
return response?.json() || {};
|
||||
}
|
||||
|
||||
11
ui/src/lib/system.ts
Normal file
11
ui/src/lib/system.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getRequest } from '@lib/index.ts';
|
||||
import { SystemInfo } from '@lib/system.types.ts';
|
||||
|
||||
export async function systemInfo(): Promise<SystemInfo | undefined> {
|
||||
const response = await getRequest('system/info');
|
||||
if (response?.status === 200) {
|
||||
return response.json();
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
4
ui/src/lib/system.types.ts
Normal file
4
ui/src/lib/system.types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface SystemInfo {
|
||||
version: string;
|
||||
healthy: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user