29 Commits

Author SHA1 Message Date
28dc464ec5 Fixed metar visibility and sky condition bugs 2025-05-28 19:14:10 -04:00
6ad2afe6dd Cleanup 2025-05-23 09:20:08 -04:00
ed98140d22 Metar overhaul, added footer, updated schemas 2025-05-19 20:22:44 -04:00
2ecb82ae63 API version 0.1.1 2025-05-15 09:23:25 -04:00
3674623691 Working on emails, updated swagger, added geometry column to airports 2025-05-15 09:16:36 -04:00
e46e8ab9b4 Working on email templating, updating with swagger 2025-05-14 20:40:38 -04:00
1e3c75624a Update frequencies to communications, fixed control icons 2025-05-14 08:28:43 -04:00
019fb77373 Updated readme 2025-05-13 23:05:17 -04:00
abfa6b534c Updates to account, ui, etc 2025-05-13 22:57:29 -04:00
a273d4134b Update Airport admin page 2025-05-12 20:43:28 -04:00
dc1a6de6c6 Working on adsb 2025-05-03 21:22:07 -04:00
1d4b8338cc Change from list to info 2025-04-30 22:06:06 -04:00
973054517c Updated list command to handle errors 2025-04-30 21:54:40 -04:00
aa38d3c29c Format adsb code 2025-04-30 21:21:53 -04:00
ebc1f30f24 Tweaks to database for users 2025-04-23 20:09:35 -04:00
3b5514e825 Updated adsb stuff 2025-04-23 19:20:30 -04:00
1ce5e61ae3 Updated rust to 2024 edition 2025-04-22 23:11:19 -04:00
916abdf8ac Temp got sim to work with tcp device by stripping out a lot of the logic. 2025-04-22 23:05:09 -04:00
fdb53f0b7f Formatting 2025-04-22 22:23:34 -04:00
95e4b8abf3 Adding base for adsb 2025-04-22 22:10:43 -04:00
06f9a96498 Working on drawer 2025-04-20 22:18:24 -04:00
19ed8ef2ca Tweaking find all metars 2025-04-20 15:35:22 -04:00
d714287fd9 Removed frontend from docker-pull target 2025-04-20 13:16:21 -04:00
4a200b3f94 Updated metar checking 2025-04-20 13:14:40 -04:00
20d5bf26de Working on drawer and header 2025-04-17 18:20:54 -04:00
3aa8954626 Working on metars, updating ui drawer 2025-04-16 22:43:03 -04:00
81335f1b48 Messed with metar stuff 2025-04-16 00:02:59 -04:00
385c04ff98 Add push make target 2025-04-15 23:01:06 -04:00
f5446ac0eb Working on queries to get latest metar airports first 2025-04-15 22:48:05 -04:00
110 changed files with 26744 additions and 22044 deletions

41
.env
View File

@@ -2,16 +2,16 @@ RUST_LOG=warn,api=info
NGINX_HOST=localhost NGINX_HOST=localhost
NGINX_SSL_ENABLED=false NGINX_SSL_ENABLED=false
NGINX_PROTOCOL=http
NGINX_HTTP_PORT=8080 NGINX_HTTP_PORT=8080
NGINX_HTTPS_PORT=8443 NGINX_HTTPS_PORT=8443
# Set to 'localhost' or 'host.docker.internal' or '172.17.0.1' # Set to 'localhost' or 'host.docker.internal' or '172.17.0.1'
NGINX_INTERNAL_HOST=host.docker.internal NGINX_INTERNAL_HOST=host.docker.internal
EXTERNAL_URL=http://localhost:8080
POSTGRES_HOST=localhost POSTGRES_HOST=localhost
POSTGRES_USER=aviation POSTGRES_USER=aviation
POSTGRES_PASSWORD=CHANGEME POSTGRES_PASSWORD=changeme
POSTGRES_NAME=aviation POSTGRES_DB=aviation_db
POSTGRES_PORT=5432 POSTGRES_PORT=5432
REDIS_HOST=localhost REDIS_HOST=localhost
@@ -19,28 +19,47 @@ REDIS_PORT=6379
MINIO_HOST=localhost MINIO_HOST=localhost
MINIO_ROOT_USER=aviation MINIO_ROOT_USER=aviation
MINIO_ROOT_PASSWORD=CHANGEME MINIO_ROOT_PASSWORD=changeme
MINIO_BUCKET=aviation MINIO_BUCKET=aviation
MINIO_PROTOCOL=http MINIO_PROTOCOL=http
MINIO_PORT=9000 MINIO_PORT=9000
MINIO_INTERNAL_PORT=9001 MINIO_INTERNAL_PORT=9001
MINIO_BROWSER_REDIRECT_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTP_PORT}/minio/ MINIO_BROWSER_REDIRECT_URL=${EXTERNAL_URL}/minio/
UI_PORT=3000 UI_PORT=3000
API_PORT=5000 API_PORT=5000
API_METAR_TIME_OFFSET=3000 API_METAR_TIME_OFFSET=1800
SSL_CA_NAME=ca SSL_CA_NAME=ca
SSL_CA_PATH=../ssl/${SSL_CA_NAME}.pem SSL_CA_PATH=../ssl/${SSL_CA_NAME}.pem
SSL_CERT_PATH=../ssl/localhost.crt SSL_CERT_PATH=../ssl/localhost.crt
SSL_CERT_KEY_PATH=../ssl/localhost.key SSL_CERT_KEY_PATH=../ssl/localhost.key
VITE_API_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTP_PORT}/api SMTP_USERNAME=smtp-user
SMTP_PASSWORD=smtp-password
SMTP_FROM=noreply@example.com
SMTP_SERVER=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 VITE_DEFAULT_LIMIT=200
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS:${NGINX_HOST} __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=${NGINX_HOST}
ENVIRONMENT=development ENVIRONMENT=development
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=CHANGEME
AVIATION_WEATHER_URL=https://aviationweather.gov/api/data ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme
TEMPLATE_DIR=../templates
METAR_INTERVAL=300
MAILPIT_WEB_PORT=8025
MAILPIT_SMTP_PORT=1025
AVIATION_WEATHER_URL=https://aviationweather.gov

1
.gitignore vendored
View File

@@ -8,7 +8,6 @@
node_modules node_modules
target/ target/
dist/ dist/
Cargo.lock
ssl/ ssl/
.DS_Store .DS_Store

View File

@@ -12,10 +12,10 @@ help: ## This info
@cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@echo @echo
format: format-api format-ui ## Format code format: format-api format-ui format-adsb ## Format code
psql: ## Connect to the PSQL DB psql: ## Connect to the PSQL DB
@docker exec -it aviation-postgres psql -U ${POSTGRES_USER} -P pager=off @docker exec -it aviation-postgres psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -P pager=off
################# #################
# API Commands # # API Commands #
@@ -24,12 +24,22 @@ psql: ## Connect to the PSQL DB
format-api: ## Format code format-api: ## Format code
@cd api && cargo fmt @cd api && cargo fmt
build-api: ## Build the project build-api: ## Build the API project
@cd api && cargo build @cd api && cargo build
run-api: ## Run the API project run-api: ## Run the API project
@cd api && cargo run -p api @cd api && cargo run -p api
##################
# ADS-B Commands #
##################
format-adsb: ## Format code
@cd adsb && cargo fmt
build-adsb: ## Build the ADS-B project
@cd adsb && cargo build --release
################# #################
# UI Commands # # UI Commands #
################# #################
@@ -66,24 +76,24 @@ down-backend: backend-down
run: ## Run the api run: ## Run the api
@cd api && cargo run @cd api && cargo run
frontend-up: ## Start Docker containers dev-up: ## Start Docker containers
@docker compose --profile frontend up -d @docker compose --profile dev up -d
up-frontend: frontend-up up-dev: dev-up
frontend-down: ## Stop Docker containers dev-down: ## Stop Docker containers
@docker compose --profile frontend down @docker compose --profile dev down
down-frontend: frontend-down down-dev: dev-down
docker-prune: ## Prune the docker system docker-prune: ## Prune the docker system
@docker system prune -a @docker system prune -a
docker-clean: ## Stop the docker containers and remove volumes 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-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-up: ## Start the docker container
@docker compose --profile backend --profile api up -d @docker compose --profile backend --profile api up -d
@@ -112,6 +122,10 @@ push: registry=$(if $(r),$(r),gitea.bensherriff.com/bsherriff)
push: platform=$(if $(p),$(p),linux/amd64,linux/arm64) push: platform=$(if $(p),$(p),linux/amd64,linux/arm64)
push: image=${registry}/aviation-${folder}:${version} push: image=${registry}/aviation-${folder}:${version}
push: ## Build and push a specific docker image (`make push f=httpd`) 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 \ docker buildx build \
-f ${folder}/Dockerfile \ -f ${folder}/Dockerfile \
--platform ${platform} \ --platform ${platform} \
@@ -122,6 +136,9 @@ push: ## Build and push a specific docker image (`make push f=httpd`)
--build-arg VCS_REF=$$(git rev-parse HEAD) \ --build-arg VCS_REF=$$(git rev-parse HEAD) \
. .
docker-pull:
@docker compose --profile api --profile backend pull
cert: domain=$(if $(d),$(d),${NGINX_HOST}) cert: domain=$(if $(d),$(d),${NGINX_HOST})
cert: ## Generate a cert for the given domain cert: ## Generate a cert for the given domain
./scripts/generate_cert.sh ${domain} ./scripts/generate_cert.sh ${domain}

View File

@@ -3,12 +3,17 @@
<h1 align="center">Aviation Data</h1> <h1 align="center">Aviation Data</h1>
</div> </div>
[Swagger Docs](https://aviation.bensherriff.com/swagger/#/)
## Makefile ## Makefile
* `make` or `make help` to list all commands * `make` or `make help` to list all commands
* `make docker-up` to start all containers * `make docker-up` to start all containers
* `make docker-refresh` to start the background services
* `make docker-clean` to stop and delete all containers, volumes, and networks related * `make docker-clean` to stop and delete all containers, volumes, and networks related
to the application to the application
**WARNING**: Running `make docker-clean` or `make docker-refresh` will wipe the database, redis, and minio data
## Setup ## Setup
1. Override any environment variables in `.env.local` 1. Override any environment variables in `.env.local`
@@ -20,7 +25,16 @@ to the application
* Running just `make cert` will generate `localhost` certificates * Running just `make cert` will generate `localhost` certificates
4. Run the application with `make up` 4. Run the application with `make up`
### Development Environment
Start background services with `make docker-refresh`
* Note: when `ENVIRONMENT` is not set to `production` (i.e., set to `development`),
the nginx container will function only as a reverse proxy - the UI must be run through `make run-ui`
Start the UI through `make run-ui` and the API through `make run-api`
### Production Environment ### Production Environment
Start with `make docker-up`
The most likely to change environment variables are the following: The most likely to change environment variables are the following:
* `UI_PORT` * `UI_PORT`
* `API_PORT` * `API_PORT`
@@ -28,6 +42,8 @@ The most likely to change environment variables are the following:
* `POSTGRES_PASSWORD` - Please change in production environments * `POSTGRES_PASSWORD` - Please change in production environments
* `MINIO_HOST` - Match to the `NGINX_HOST` value (see below) * `MINIO_HOST` - Match to the `NGINX_HOST` value (see below)
* `MINIO_ROOT_PASSWORD` - Please change in production environments * `MINIO_ROOT_PASSWORD` - Please change in production environments
* `MINIO_BROWSER_REDIRECT_URL` - Change to the FQDN of the URL that is reachable through the internet.
For example: `https://aviation.bensherriff.com/minio/`
* `NGINX_HOST` - The IP address of the system * `NGINX_HOST` - The IP address of the system
* `NGINX_INTERNAL_HOST` - Typically `host.docker.internal` or `172.17.0.1` * `NGINX_INTERNAL_HOST` - Typically `host.docker.internal` or `172.17.0.1`
to allow communication within the docker network to allow communication within the docker network
@@ -35,7 +51,7 @@ to allow communication within the docker network
* `ADMIN_EMAIL` - Please change in production environments * `ADMIN_EMAIL` - Please change in production environments
* `ADMIN_PASSWORD` - Please change in production environments * `ADMIN_PASSWORD` - Please change in production environments
* `VITE_API_URL` - Change to the FQDN of the URL that is reachable through the internet. * `VITE_API_URL` - Change to the FQDN of the URL that is reachable through the internet.
For example: `https://aviation.bensherriff.com` For example: `https://aviation.bensherriff.com/api`
* `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` - Change to the domain of the `VITE_API_URL`. * `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` - Change to the domain of the `VITE_API_URL`.
For example: `aviation.bensherriff.com` For example: `aviation.bensherriff.com`
@@ -69,5 +85,15 @@ The following resources were used to help decode METARS.
### OpenMapTiles ### OpenMapTiles
[Generate Vector Tiles](https://openmaptiles.org/docs/generate/generate-openmaptiles/) [Generate Vector Tiles](https://openmaptiles.org/docs/generate/generate-openmaptiles/)
### ADS-B
- https://blog.exploit.org/ads-b-guide-demodulation-and-decoding/
- https://mode-s.org/1090mhz/index.html
- https://planewave.github.io/posts/rtlsdr/
- http://jasonplayne.com:8080/#
### Other data ### Other data
- https://www.faa.gov/air_traffic/weather/asos - https://www.faa.gov/air_traffic/weather/asos
## Tests
`cargo test metars::model::tests::test_parse_time -- --exact --nocapture
`

475
adsb/Cargo.lock generated Normal file
View 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"

11
adsb/Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "adsb"
version = "0.1.0"
edition = "2024"
[dependencies]
rusb = "0.9.4"
clap = { version = "4.5.37", features = ["derive"] }
log = "0.4.27"
env_logger = "0.11.8"
ctrlc = "3.4.6"

10
adsb/README.md Normal file
View File

@@ -0,0 +1,10 @@
# ADSB
Debug using `export LIBUSB_DEBUG=4`
`lsusb -v -d 0bda:2832`
## Simulation Mode
`cargo run -- --connect`
## Decode
`cargo run -- --decode 8D4840D6202CC371C32CE0576098`

3
adsb/rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]

3
adsb/rustfmt.toml Normal file
View File

@@ -0,0 +1,3 @@
indent_style = "Block"
reorder_imports = false
tab_spaces = 2

62
adsb/src/constants.rs Normal file
View File

@@ -0,0 +1,62 @@
use std::fmt::Display;
use std::time::Duration;
#[derive(Debug)]
pub struct DeviceInfo {
/// Vendor ID
pub vid: u16,
/// Product ID
pub pid: u16,
}
impl Display for DeviceInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "VID: 0x{:04X} PID: 0x{:04X}", self.vid, self.pid)
}
}
// Devices
pub const DEVICE_RTL2832U: DeviceInfo = DeviceInfo {
vid: 0x0BDA,
pid: 0x2832,
};
pub const TIMEOUT: Duration = Duration::from_secs(1);
pub const FIR_LENGTH: usize = 16;
// Request Types
pub const REQ_CTRL_OUT: u8 =
rusb::constants::LIBUSB_ENDPOINT_OUT | rusb::constants::LIBUSB_REQUEST_TYPE_VENDOR;
// Blocks
pub const BLOCK_DEMOD: u16 = 0;
pub const BLOCK_USB: u16 = 1;
pub const BLOCK_SYS: u16 = 2;
pub const BLOCK_TUN: u16 = 3;
pub const BLOCK_ROM: u16 = 4;
pub const BLOCK_IRB: u16 = 5;
pub const BLOCK_IIC: u16 = 6;
// Registers
pub const DEMOD_CTL: u16 = 0x3000;
pub const DEMOD_CTL_1: u16 = 0x300b;
// USB
pub const USB_EPA_CTL: u16 = 0x2148;
pub const USB_SYSCTL: u16 = 0x2000;
pub const USB_EPA_MAXPKT: u16 = 0x2158;
/// ADS-B downlink frequency (1090 MHz)
pub const ADSB_FREQUENCY_HZ: u32 = 1_090_000_000;
/// RTL-SDR sample rate in samples/second.
pub const SAMPLE_RATE_HZ: u32 = 2_048_000;
pub const DEFAULT_FIR: &'static [i32; FIR_LENGTH] = &[
-54, -36, -41, -40, -32, -14, 14, 53, 101, 156, 215, 273, 327, 372, 404, 421,
];
// pub const DEFAULT_BUFFER_LENGTH: usize = 4096;
pub const DEFAULT_BUFFER_LENGTH: usize = 64;
pub const DEFAULT_RTL_XTAL_FREQ: u32 = 28_800_000;
pub const MIN_RTL_XTAL_FREQ: u32 = DEFAULT_RTL_XTAL_FREQ - 1000;
pub const MAX_RTL_XTAL_FREQ: u32 = DEFAULT_RTL_XTAL_FREQ + 1000;

550
adsb/src/device.rs Normal file
View File

@@ -0,0 +1,550 @@
use std::borrow::Cow;
use std::fmt::Display;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use rusb::{
Context, Device, DeviceDescriptor, DeviceHandle, DeviceList, Direction, TransferType, UsbContext,
};
use crate::error::{Error, Result};
use crate::constants::{
ADSB_FREQUENCY_HZ, BLOCK_SYS, BLOCK_USB, DEFAULT_BUFFER_LENGTH, DEFAULT_FIR,
DEFAULT_RTL_XTAL_FREQ, DEMOD_CTL, DEMOD_CTL_1, REQ_CTRL_OUT, SAMPLE_RATE_HZ, TIMEOUT,
USB_EPA_CTL, USB_EPA_MAXPKT, USB_SYSCTL,
};
/// rusb/libusb implementation of `RtlSdrDevice`
pub struct RtlSdrDevice {
/// Device handle
handle: DeviceHandle<Context>,
endpoint: Endpoint,
frequency: u32,
rate: u32,
}
impl RtlSdrDevice {
/// Display RTL SDR information
pub fn info(vid: u16, pid: u16) {
let device_list = match DeviceList::new() {
Ok(d) => d,
Err(err) => {
eprintln!("Unable to get device list: {:?}", err);
return;
}
};
for device in device_list.iter() {
match device.device_descriptor() {
Ok(device_desc) => {
if vid != device_desc.vendor_id() || pid != device_desc.product_id() {
continue;
}
println!(
"Bus: {:03}, Device: {:03} VID: 0x{:04X}, PID: 0x{:04X}",
device.bus_number(),
device.address(),
device_desc.vendor_id(),
device_desc.product_id()
);
match device.open() {
Ok(handle) => {
println!("{}", device_info(&handle, &device_desc, " ", true));
}
Err(err) => {
eprintln!(" Unable to open device: {:?}", err);
continue;
}
};
}
Err(err) => {
eprintln!("Unable to get device descriptor: {:?}", err);
continue;
}
};
}
}
/// Open the RTL SDR device and return a wrapper
pub fn open(vid: u16, pid: u16) -> Result<Self> {
// Create a new libusb context
let ctx = Context::new().map_err(|_| Error::new("Unable to create libusb context"))?;
for device in ctx.devices()?.iter() {
let device_desc = match device.device_descriptor() {
Ok(d) => d,
Err(_) => continue,
};
if device_desc.vendor_id() == vid && device_desc.product_id() == pid {
let handle = device.open()?;
log::debug!("{}", device_info(&handle, &device_desc, "", false));
// Find the endpoint
let endpoint = match Endpoint::find(&device_desc, &device, TransferType::Bulk) {
Some(e) => e,
None => return Err(Error::new("Unable to find endpoint on device")),
};
log::debug!("Found readable endpoint: {}", endpoint.to_string());
let mut sdr = Self::new(handle, endpoint);
sdr.initialize()?;
return Ok(sdr);
}
}
Err(Error::new("No valid device found"))
}
/// Close the RTL SDR device
pub fn close(&self) -> Result<()> {
log::debug!("Closing device...");
self.attach_kernel_driver(self.endpoint.interface);
self.handle.release_interface(self.endpoint.interface)?;
Ok(())
}
/// Process the USB data
pub fn process(&mut self, running: Arc<AtomicBool>) -> Result<()> {
log::debug!(
"Reading from active configuration: {}",
self.handle.active_configuration()?
);
// Read endpoint
let mut buffer = [0u8; DEFAULT_BUFFER_LENGTH];
while running.load(Ordering::SeqCst) {
let s = self.read(&mut buffer)?;
log::debug!("Read: {}", s);
}
self.close()
}
fn new(handle: DeviceHandle<Context>, endpoint: Endpoint) -> Self {
Self {
handle,
endpoint,
frequency: 0,
rate: 0,
}
}
fn read(&self, buffer: &mut [u8; DEFAULT_BUFFER_LENGTH]) -> Result<String> {
let length = match self.endpoint.transfer_type {
TransferType::Interrupt => self
.handle
.read_interrupt(self.endpoint.address, buffer, TIMEOUT)
.map_err(|err| Error::new(format!("Unable to read interrupt from endpoint: {:?}", err)))?,
TransferType::Bulk => self
.handle
.read_bulk(self.endpoint.address, buffer, TIMEOUT)
.map_err(|err| Error::new(format!("Unable to read bulk from endpoint: {:?}", err)))?,
_ => 0,
};
log::trace!("Received {} bytes", length);
let s = match String::from_utf8_lossy(&buffer[..length]) {
Cow::Borrowed(s) => s.to_string(),
Cow::Owned(s) => s,
};
Ok(s.to_string())
}
fn initialize(&mut self) -> Result<()> {
// Configure the device for the endpoint
self.set_active_configuration(self.endpoint.config)?;
self.claim_interface(self.endpoint.interface)?;
self.set_alternate_setting(self.endpoint.interface, self.endpoint.setting)?;
self.detach_kernel_driver(self.endpoint.interface);
self.test_write()?;
self.initialize_baseband()?;
self.set_i2c_repeater(true)?;
// Reset the internal USB buffer
self.reset_buffer()?;
// Set the center-frequency in Hz
self.set_center_frequency(ADSB_FREQUENCY_HZ)?;
// Set the sample rate
self.set_sample_rate(SAMPLE_RATE_HZ)?;
Ok(())
}
fn set_active_configuration(&self, configuration: u8) -> Result<()> {
self
.handle
.set_active_configuration(configuration)
.map_err(|err| Error::new(format!("Failed to set active configuration: {:?}", err)))
}
fn claim_interface(&self, interface: u8) -> Result<()> {
self
.handle
.claim_interface(interface)
.map_err(|err| Error::new(format!("Failed to claim interface: {:?}", err)))
}
fn set_alternate_setting(&self, interface: u8, setting: u8) -> Result<()> {
self
.handle
.set_alternate_setting(interface, setting)
.map_err(|err| Error::new(format!("Failed to set alternate setting: {:?}", err)))
}
/// Attempt to write a test message, and reset the device on a failure
fn test_write(&self) -> Result<()> {
log::trace!("Testing write to device...");
let length = ctrl_write_register(&self.handle, BLOCK_USB, USB_SYSCTL, 0x89, 1)?;
if length == 0 {
log::info!("Resetting device");
self
.handle
.reset()
.map_err(|err| Error::new(format!("Failed to reset device: {:?}", err)))?;
} else {
log::trace!("Test write was successful");
}
Ok(())
}
fn reset_buffer(&self) -> Result<()> {
log::trace!("Resetting buffer...");
ctrl_write_register(&self.handle, BLOCK_USB, USB_EPA_CTL, 0x1002, 2)
.map_err(|err| Error::new(format!("Failed to reset the internal buffer: {:?}", err)))?;
ctrl_write_register(&self.handle, BLOCK_USB, USB_EPA_CTL, 0x0000, 2)
.map_err(|err| Error::new(format!("Failed to reset the internal buffer: {:?}", err)))?;
Ok(())
}
fn reset_demod(&self) -> Result<()> {
log::trace!("Resetting demod...");
demod_ctrl_write_register(&self.handle, 1, 0x01, 0x14, 1)
.map_err(|err| Error::new(format!("Failed to reset the internal demod: {:?}", err)))?;
demod_ctrl_write_register(&self.handle, 1, 0x01, 0x10, 1)
.map_err(|err| Error::new(format!("Failed to reset the internal demod: {:?}", err)))?;
Ok(())
}
fn initialize_baseband(&self) -> Result<()> {
// Initialize the USB
ctrl_write_register(&self.handle, BLOCK_USB, USB_SYSCTL, 0x09, 1)?;
ctrl_write_register(&self.handle, BLOCK_USB, USB_EPA_MAXPKT, 0x0002, 2)?;
ctrl_write_register(&self.handle, BLOCK_USB, USB_EPA_CTL, 0x1002, 2)?;
// Power on demod
ctrl_write_register(&self.handle, BLOCK_SYS, DEMOD_CTL_1, 0x22, 1)?;
ctrl_write_register(&self.handle, BLOCK_SYS, DEMOD_CTL, 0xe8, 1)?;
// Reset demod
self.reset_demod()?;
// Disable spectrum inversion and adjust channel rejection
ctrl_write_register(&self.handle, 1, 0x15, 0x00, 1)?;
ctrl_write_register(&self.handle, 1, 0x16, 0x00, 2)?;
// Clear DDC shift and IF registers
for i in 0..5 {
demod_ctrl_write_register(&self.handle, 1, 0x16 + i, 0x00, 1)?;
}
self.set_fir(DEFAULT_FIR)?;
// info!("Enable SDR mode, disable DAGC (bit 5)");
demod_ctrl_write_register(&self.handle, 0, 0x19, 0x05, 1)?;
// info!("Init FSM state-holding register");
demod_ctrl_write_register(&self.handle, 1, 0x93, 0xf0, 1)?;
demod_ctrl_write_register(&self.handle, 1, 0x94, 0x0f, 1)?;
// Disable AGC (en_dagc, bit 0) (seems to have no effect)
demod_ctrl_write_register(&self.handle, 1, 0x11, 0x00, 1)?;
// Disable RF and IF AGC loop
demod_ctrl_write_register(&self.handle, 1, 0x04, 0x00, 1)?;
// Disable PID filter
demod_ctrl_write_register(&self.handle, 0, 0x61, 0x60, 1)?;
// opt_adc_iq = 0, default ADC_I/ADC_Q datapath
demod_ctrl_write_register(&self.handle, 0, 0x06, 0x80, 1)?;
// Enable Zero-IF mode, DC cancellation, and IQ estimation/compensation
demod_ctrl_write_register(&self.handle, 1, 0xb1, 0x1b, 1)?;
// Disable 4.096 MHz clock output on pin TP_CK0
demod_ctrl_write_register(&self.handle, 0, 0x0d, 0x83, 1)?;
Ok(())
}
fn set_center_frequency(&mut self, frequency: u32) -> Result<()> {
log::trace!("Setting center_frequency to {}Hz", frequency);
self.frequency = frequency;
Ok(())
}
fn set_sample_rate(&mut self, rate: u32) -> Result<()> {
log::trace!("Setting sample_rate to {}Hz", rate);
if rate <= 225_000 || rate > 3_200_000 || (rate > 300000 && rate <= 900000) {
return Err(Error::new(format!("Invalid sample rate: {} Hz", rate)));
}
let rsamp_ratio =
((DEFAULT_RTL_XTAL_FREQ as u128 * 2_u128.pow(22) / rate as u128) & 0x0ffffffc) as u128;
log::trace!(
"Sample rate: {}, xtal: {}, rsamp_ratio: {}",
rate,
DEFAULT_RTL_XTAL_FREQ,
rsamp_ratio
);
let real_resamp_ratio = rsamp_ratio | ((rsamp_ratio & 0x08000000) << 1);
let real_rate =
(DEFAULT_RTL_XTAL_FREQ as u128 * 2_u128.pow(22)) as f64 / real_resamp_ratio as f64;
if rate as f64 != real_rate {
log::trace!("Exact sample rate is {} Hz", real_rate);
}
self.rate = real_rate as u32;
let mut tmp: u16 = (rsamp_ratio >> 16) as u16;
demod_ctrl_write_register(&self.handle, 1, 0x9f, tmp, 2)?;
tmp = (rsamp_ratio & 0xffff) as u16;
demod_ctrl_write_register(&self.handle, 1, 0xa1, tmp, 2)?;
Ok(())
}
fn set_fir(&self, fir: &[i32; 16]) -> Result<()> {
log::trace!("Setting fir to {:?}", fir);
const TMP_LEN: usize = 20;
let mut tmp: [u8; TMP_LEN] = [0; TMP_LEN];
// First 8 values are i8
for i in 0..8 {
let val = fir[i];
if val < -128 || val > 127 {
panic!("i8 FIR coefficient out of bounds! {}", val);
}
tmp[i] = val as u8;
}
// Next 12 are i12, so don't line up with byte boundaries and need to unpack
// 12 i12 values from 4 pairs of bytes in fir. Example:
// fir: 4b5, 7f8, 3e8, 619
// tmp: 4b, 57, f8, 3e, 86, 19
for i in (0..8).step_by(2) {
let val0 = fir[8 + i];
let val1 = fir[8 + i + 1];
if val0 < -2048 || val0 > 2047 {
panic!("i12 FIR coefficient out of bounds: {}", val0)
} else if val1 < -2048 || val1 > 2047 {
panic!("i12 FIR coefficient out of bounds: {}", val1)
}
tmp[8 + i * 3 / 2] = (val0 >> 4) as u8;
tmp[8 + i * 3 / 2 + 1] = ((val0 << 4) | ((val1 >> 8) & 0x0f)) as u8;
tmp[8 + i * 3 / 2 + 2] = val1 as u8;
}
for i in 0..TMP_LEN {
demod_ctrl_write_register(&self.handle, 1, 0x1c + i as u16, tmp[i] as u16, 1)?;
}
Ok(())
}
fn set_i2c_repeater(&self, enabled: bool) -> Result<()> {
let value = match enabled {
true => 0x18,
false => 0x10,
};
demod_ctrl_write_register(&self.handle, 1, 0x01, value, 1)?;
Ok(())
}
fn detach_kernel_driver(&self, interface: u8) {
// Detach the kernel driver if applicable
if let Ok(true) = self.handle.kernel_driver_active(interface) {
log::trace!("Detaching active kernel driver");
self.handle.detach_kernel_driver(interface).ok();
}
}
fn attach_kernel_driver(&self, interface: u8) {
// Attach the kernel driver if applicable
if let Ok(true) = self.handle.kernel_driver_active(interface) {
log::trace!("Attaching active kernel driver");
self.handle.attach_kernel_driver(interface).ok();
}
}
}
#[derive(Debug)]
pub struct Endpoint {
config: u8,
interface: u8,
setting: u8,
address: u8,
transfer_type: TransferType,
}
impl Display for Endpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Config: {}, Interface: {}, Setting: {}, Address: 0x{:04X}, Transfer Type: {:?}",
self.config, self.interface, self.setting, self.address, self.transfer_type
)
}
}
impl Endpoint {
fn find<T: UsbContext>(
device_desc: &DeviceDescriptor,
device: &Device<T>,
transfer_type: TransferType,
) -> Option<Self> {
for n in 0..device_desc.num_configurations() {
let config_desc = match device.config_descriptor(n) {
Ok(c) => c,
Err(_) => continue,
};
for interface in config_desc.interfaces() {
for interface_desc in interface.descriptors() {
for endpoint_desc in interface_desc.endpoint_descriptors() {
if endpoint_desc.direction() == Direction::In
&& endpoint_desc.transfer_type() == transfer_type
{
return Some(Endpoint {
config: config_desc.number(),
interface: interface_desc.interface_number(),
setting: interface_desc.setting_number(),
address: endpoint_desc.address(),
transfer_type,
});
}
}
}
}
}
None
}
}
fn device_info<T: UsbContext>(
handle: &DeviceHandle<T>,
device_desc: &DeviceDescriptor,
offset: &str,
full: bool,
) -> String {
let languages = match handle.read_languages(TIMEOUT) {
Ok(l) => l,
Err(err) => {
return format!("{} Unable to get languages: {:?}", offset, err);
}
};
let descriptor_type = device_desc.descriptor_type();
let mut output = String::new();
if full {
output = format!("{}Device Descriptor ({})\n", offset, descriptor_type);
}
if !languages.is_empty() {
for language in languages {
let manufacturer = handle
.read_manufacturer_string(language, device_desc, TIMEOUT)
.unwrap_or_else(|err| err.to_string());
let product = handle
.read_product_string(language, device_desc, TIMEOUT)
.unwrap_or_else(|err| err.to_string());
let serial_number = handle
.read_serial_number_string(language, device_desc, TIMEOUT)
.unwrap_or_else(|err| err.to_string());
output.push_str(&format!(
"{}{}Manufacturer: {}, Product: {}, Serial Number: {}",
offset, offset, manufacturer, product, serial_number
));
if full {
let length = device_desc.length();
let version = format!(
" v{}.{}.{}",
device_desc.usb_version().major(),
device_desc.usb_version().minor(),
device_desc.usb_version().sub_minor()
);
output.push_str(&format!(
"\n{}{}Length: {}, USB:{}\n",
offset, offset, length, version,
));
let class = device_desc.class_code();
let sub_class = device_desc.sub_class_code();
let protocol = device_desc.protocol_code();
let max_packet_size = device_desc.max_packet_size();
output.push_str(&format!(
"{}{}Class: {:#04x}, Subclass: {:#04x}, Protocol: {:#04x}, Max Packet Size: {}",
offset, offset, class, sub_class, protocol, max_packet_size
))
}
}
}
output
}
fn ctrl_write_register<T: UsbContext>(
handle: &DeviceHandle<T>,
block: u16,
address: u16,
value: u16,
length: usize,
) -> rusb::Result<usize> {
assert!(length == 1 || length == 2);
let data: [u8; 2] = value.to_be_bytes();
let buffer = if length == 1 { &data[1..2] } else { &data };
let index = (block << 8) | 0x10;
log::trace!(
"Received block {}, address 0x{:04X}, value 0x{:04X}, length {} \
- writing control register: {} 0x{:04X} 0x{:04X} {:?}",
block,
address,
value,
length,
REQ_CTRL_OUT,
address,
index,
buffer
);
handle.write_control(REQ_CTRL_OUT, 0x00, address, index, buffer, TIMEOUT)
}
fn demod_ctrl_write_register<T: UsbContext>(
handle: &DeviceHandle<T>,
page: u16,
address: u16,
value: u16,
length: usize,
) -> rusb::Result<usize> {
assert!(length == 1 || length == 2);
let data: [u8; 2] = value.to_be_bytes();
let buffer = if length == 1 { &data[1..2] } else { &data };
let index = 0x10 | page;
let address = (address << 8) | 0x20;
log::trace!(
"Received page {}, address 0x{:04X}, value 0x{:04X}, length {} \
- writing control register: {} 0x{:04X} 0x{:04X} {:?}",
page,
address,
value,
length,
REQ_CTRL_OUT,
address,
index,
buffer
);
handle.write_control(REQ_CTRL_OUT, 0x00, address, index, buffer, TIMEOUT)
}

44
adsb/src/error.rs Normal file
View File

@@ -0,0 +1,44 @@
use std::{fmt, result};
pub type Result<T> = result::Result<T, Error>;
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Error {
RusbError(rusb::Error),
Other(String),
}
impl Error {
pub fn new<S: Into<String>>(msg: S) -> Self {
Error::Other(msg.into())
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> result::Result<(), fmt::Error> {
match self {
Error::RusbError(err) => write!(f, "USB Error: {}", err),
Error::Other(err) => write!(f, "{}", err),
}
}
}
impl std::error::Error for Error {}
impl From<rusb::Error> for Error {
fn from(err: rusb::Error) -> Self {
Error::RusbError(err)
}
}
impl From<ctrlc::Error> for Error {
fn from(err: ctrlc::Error) -> Self {
Error::Other(err.to_string())
}
}
impl From<std::str::Utf8Error> for Error {
fn from(err: std::str::Utf8Error) -> Self {
Error::Other(err.to_string())
}
}

473
adsb/src/frame.rs Normal file
View File

@@ -0,0 +1,473 @@
use crate::hex_to_bytes;
use std::fmt::Display;
use crate::error::{Result, Error};
#[derive(Debug)]
pub struct ADSBFrame {
pub raw_frame: String,
/// Downlink format (DF, 5 bits)
pub downlink_format: u8,
/// Transponder capability (CA, 3 bits)
pub capability: Capability,
/// Unique aircraft number (ICAO, 24 bits)
pub icao: String,
/// Message (ME, 56 bits)
pub message: ADSBMessage,
/// Parity/Interrogator ID/Checksum (PI, 24 bits)
pub parity: u32,
}
impl ADSBFrame {
/// Parse exactly 14 bytes (112 bits) of raw ADS-B ES data into its fields
///
/// [ DF:5 ][ CA:3 ][ ICAO:24 ][ ME:56 ][ PI:24 ]
pub fn decode(frame: &[u8]) -> Result<ADSBFrame> {
if frame.len() != 14 {
return Err(Error::new(format!(
"expected 14 bytes, received {}",
frame.len()
)));
}
let mut raw_frame = "".to_string();
for byte in frame {
raw_frame.push_str(&format!("{:02x}", byte).to_uppercase());
}
// Decode the downlink format by discarding the lower 3 bits
let downlink_format = &frame[0] >> 3;
if downlink_format != 17 {
return Err(Error::new(format!(
"downlink format {} is not currently supported",
downlink_format
)));
}
// Decode the capability by masking off everything but the lower 3 bits
let capability_value = &frame[0] & 0b0000_0111;
let capability = Capability::try_from(capability_value)?;
let icao = Self::decode_icao(&frame[1..=3])?;
let message = ADSBMessage::decode(&frame[4..=10])?;
let parity = Self::decode_parity(&frame[11..])?;
Ok(Self {
raw_frame,
downlink_format,
capability,
icao,
message,
parity,
})
}
pub fn encode(&self) -> Result<Vec<u8>> {
Ok(hex_to_bytes(&self.raw_frame)?)
}
fn decode_icao(data: &[u8]) -> Result<String> {
if data.len() != 3 {
return Err(Error::new(format!(
"ICAO must be 3 bytes, received {}",
data.len()
)));
}
let s = data
.iter()
.map(|b| format!("{:02X}", b))
.collect::<String>();
Ok(s)
}
fn decode_parity(data: &[u8]) -> Result<u32> {
if data.len() != 3 {
return Err(Error::new(format!(
"parity must be 3 bytes, received {}",
data.len()
)));
}
let p = ((data[0] as u32) << 16) | ((data[1] as u32) << 8) | (data[2] as u32);
Ok(p)
}
}
impl Display for ADSBFrame {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Frame: {}\
\nDF: {}\
\nCA: {:?}\
\nICAO: {}\
\nME: {:?}\
\nPI: {}",
self.raw_frame, self.downlink_format, &self.capability, self.icao, &self.message, self.parity
)
}
}
/// Transponder Capability (CA) codes from the ADS-B spec
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
/// 0: Level 1 transponder
Level1,
/// 1-3: Reserved
Reserved(u8),
/// 4: Level 2+ transponder, ground (can set CA=7)
Level2OnGround,
/// 5: Level 2+ transponder, airborne (can set CA=7)
Level2Airborne,
/// 6: Level 2+ transponder, either ground or airborne (can set CA=7)
Level2Either,
/// 7: Downlink Request = 0, or Flight Status = 2,3,4,5
DownlinkRequestOrFlightStatus,
}
impl TryFrom<u8> for Capability {
type Error = Error;
fn try_from(value: u8) -> Result<Self> {
let capability = match value {
0 => Capability::Level1,
1..=3 => Capability::Reserved(value),
4 => Capability::Level2OnGround,
5 => Capability::Level2Airborne,
6 => Capability::Level2Either,
7 => Capability::DownlinkRequestOrFlightStatus,
_ => {
return Err(Error::new(format!("invalid CA value: {}", value)));
}
};
Ok(capability)
}
}
// fn get_bits(data: &[u8], from: usize, len: usize) -> u32 {
// let mut val = 0;
// for bit in 0..len {
// let idx = from + bit;
// let byte = data[idx / 8];
// let shift = 7 - (idx % 8);
// let bit_val = ((byte >> shift) & 0x01) as u32;
// val = (val << 1) | bit_val;
// }
// val
// }
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ADSBMessage {
AircraftIdentification(AircraftIdentification),
SurfacePosition(SurfacePosition),
AirbornePosition(AirbornePosition),
AirborneVelocities(AirborneVelocities),
Reserved(u8),
AircraftStatus(AircraftStatus),
TargetState(TargetState),
AircraftOperationStatus(AircraftOperationStatus),
}
impl ADSBMessage {
pub fn decode(data: &[u8]) -> Result<ADSBMessage> {
if data.len() != 7 {
return Err(Error::new(format!(
"ME field must be 7 bytes, received {}",
data.len()
)));
}
// First 5 bits is the type code
let type_code = data[0] >> 3;
let message = match type_code {
1..=4 => {
ADSBMessage::AircraftIdentification(AircraftIdentification::decode(type_code, data)?)
}
5..=8 => ADSBMessage::SurfacePosition(SurfacePosition::decode(data)?),
9..=18 => ADSBMessage::AirbornePosition(AirbornePosition::decode(type_code, data)?),
19 => ADSBMessage::AirborneVelocities(AirborneVelocities::decode(data)?),
20..=22 => ADSBMessage::AirbornePosition(AirbornePosition::decode(type_code, data)?),
23..=27 => ADSBMessage::Reserved(type_code),
28 => ADSBMessage::AircraftStatus(AircraftStatus::decode(data)?),
29 => ADSBMessage::TargetState(TargetState::decode(data)?),
31 => ADSBMessage::AircraftOperationStatus(AircraftOperationStatus::decode(data)?),
_ => {
return Err(Error::new(format!(
"unsupported ADS-B type_code {}",
type_code
)));
}
};
Ok(message)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AircraftIdentification {
type_code: u8,
emitter_category: u8,
wake_vortex_category: WakeVortexCategory,
callsign: String,
}
impl AircraftIdentification {
pub fn decode(type_code: u8, data: &[u8]) -> Result<Self> {
// Byte 0: [ TC(5 bits) | emitter_category (3 bits) ]
let emitter_category = data[0] & 0x07;
// 56 bit buffer for message
let mut bits: u64 = 0;
for &b in data {
bits = (bits << 8) | b as u64;
}
let mut callsign = String::with_capacity(8);
for i in 0..8 {
let shift = 48 - 6 * (i + 1);
let raw6 = ((bits >> shift) & 0x3F) as u8;
let ch = match raw6 {
1..=26 => (b'A' + (raw6 - 1)) as char,
48..=57 => (b'0' + (raw6 - 48)) as char,
32 => ' ',
_ => continue,
};
callsign.push(ch);
}
// trim any trailing spaces
let callsign = callsign.trim_end().to_string();
Ok(Self {
type_code,
emitter_category,
wake_vortex_category: WakeVortexCategory::from_tc_ca(type_code, emitter_category),
callsign,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WakeVortexCategory {
NoInfo,
SurfaceEmergencyVehicle,
SurfaceServiceVehicle,
GroundObstruction,
Glider,
LighterThanAir,
Parachutist,
Ultralight,
Reserved,
UnmannedAerialVehicle,
SpaceVehicle,
Light,
Medium1,
Medium2,
HighVortex,
Heavy,
HighPerformance,
Rotorcraft,
Unknown,
}
impl WakeVortexCategory {
pub fn from_tc_ca(type_code: u8, emitter_category: u8) -> Self {
match (type_code, emitter_category) {
(_, 0) => WakeVortexCategory::NoInfo,
(2, 1) => WakeVortexCategory::SurfaceEmergencyVehicle,
(2, 3) => WakeVortexCategory::SurfaceServiceVehicle,
(2, 4..=7) => WakeVortexCategory::GroundObstruction,
(3, 1) => WakeVortexCategory::Glider,
(3, 2) => WakeVortexCategory::LighterThanAir,
(3, 3) => WakeVortexCategory::Parachutist,
(3, 4) => WakeVortexCategory::Ultralight,
(3, 5) => WakeVortexCategory::Reserved,
(3, 6) => WakeVortexCategory::UnmannedAerialVehicle,
(3, 7) => WakeVortexCategory::SpaceVehicle,
(4, 1) => WakeVortexCategory::Light,
(4, 2) => WakeVortexCategory::Medium1,
(4, 3) => WakeVortexCategory::Medium2,
(4, 4) => WakeVortexCategory::HighVortex,
(4, 5) => WakeVortexCategory::Heavy,
(4, 6) => WakeVortexCategory::HighPerformance,
(4, 7) => WakeVortexCategory::Rotorcraft,
_ => WakeVortexCategory::Unknown,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SurfacePosition {}
impl SurfacePosition {
pub fn decode(_data: &[u8]) -> Result<Self> {
Ok(Self {})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AirbornePosition {}
impl AirbornePosition {
pub fn decode(_type_code: u8, _data: &[u8]) -> Result<Self> {
Ok(Self {})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AirborneVelocities {}
impl AirborneVelocities {
pub fn decode(_data: &[u8]) -> Result<Self> {
Ok(Self {})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AircraftStatus {}
impl AircraftStatus {
pub fn decode(_data: &[u8]) -> Result<Self> {
Ok(Self {})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetState {}
impl TargetState {
pub fn decode(_data: &[u8]) -> Result<Self> {
Ok(Self {})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AircraftOperationStatus {}
impl AircraftOperationStatus {
pub fn decode(_data: &[u8]) -> Result<Self> {
Ok(Self {})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decode_df_17_aircraft_information() {
let input = [
0x8D, 0x48, 0x40, 0xD6, 0x20, 0x2C, 0xC3, 0x71, 0xC3, 0x1C, 0x32, 0xCE, 0x05, 0x76,
];
let frame = ADSBFrame::decode(&input).unwrap();
assert_eq!(frame.downlink_format, 17);
assert_eq!(frame.capability, Capability::Level2Airborne);
assert_eq!(frame.icao, "4840D6");
match frame.message {
ADSBMessage::AircraftIdentification(ref id) => {
assert_eq!(id.type_code, 4);
assert_eq!(id.emitter_category, 0);
assert_eq!(id.wake_vortex_category, WakeVortexCategory::NoInfo);
assert_eq!(id.callsign, "KLM10102");
}
_ => panic!("expected AircraftIdentification"),
}
assert_eq!(frame.parity, 13501814);
let input = [
0x8D, 0x48, 0x40, 0xD6, 0x20, 0x2C, 0xC3, 0x71, 0xC3, 0x2C, 0xE0, 0x57, 0x60, 0x98,
];
let frame = ADSBFrame::decode(&input).unwrap();
assert_eq!(frame.downlink_format, 17);
assert_eq!(frame.capability, Capability::Level2Airborne);
assert_eq!(frame.icao, "4840D6");
match frame.message {
ADSBMessage::AircraftIdentification(ref id) => {
assert_eq!(id.type_code, 4);
assert_eq!(id.emitter_category, 0);
assert_eq!(id.wake_vortex_category, WakeVortexCategory::NoInfo);
assert_eq!(id.callsign, "KLM1023");
}
_ => panic!("expected AircraftIdentification"),
}
assert_eq!(frame.parity, 5726360);
let input = [
0x8D, 0x7C, 0x71, 0x81, 0x21, 0x5D, 0x01, 0xA0, 0x82, 0x08, 0x20, 0x4D, 0x8B, 0xF1,
];
let frame = ADSBFrame::decode(&input).unwrap();
assert_eq!(frame.downlink_format, 17);
assert_eq!(frame.capability, Capability::Level2Airborne);
assert_eq!(frame.icao, "7C7181");
match frame.message {
ADSBMessage::AircraftIdentification(ref id) => {
assert_eq!(id.type_code, 4);
assert_eq!(id.emitter_category, 1);
assert_eq!(id.wake_vortex_category, WakeVortexCategory::Light);
assert_eq!(id.callsign, "WPF");
}
_ => panic!("expected AircraftIdentification"),
}
assert_eq!(frame.parity, 5082097);
let input = [
0x8D, 0x7C, 0x77, 0x45, 0x22, 0x61, 0x51, 0xA0, 0x82, 0x08, 0x20, 0x5C, 0xE9, 0xC2,
];
let frame = ADSBFrame::decode(&input).unwrap();
assert_eq!(frame.downlink_format, 17);
assert_eq!(frame.capability, Capability::Level2Airborne);
assert_eq!(frame.icao, "7C7745");
match frame.message {
ADSBMessage::AircraftIdentification(ref id) => {
assert_eq!(id.type_code, 4);
assert_eq!(id.emitter_category, 2);
assert_eq!(id.wake_vortex_category, WakeVortexCategory::Medium1);
assert_eq!(id.callsign, "XUF");
}
_ => panic!("expected AircraftIdentification"),
}
assert_eq!(frame.parity, 6089154);
let input = [
0x8D, 0x7C, 0x80, 0xAD, 0x23, 0x58, 0xF6, 0xB1, 0xE3, 0x5C, 0x60, 0xFF, 0x19, 0x25,
];
let frame = ADSBFrame::decode(&input).unwrap();
assert_eq!(frame.downlink_format, 17);
assert_eq!(frame.capability, Capability::Level2Airborne);
assert_eq!(frame.icao, "7C80AD");
match frame.message {
ADSBMessage::AircraftIdentification(ref id) => {
assert_eq!(id.type_code, 4);
assert_eq!(id.emitter_category, 3);
assert_eq!(id.wake_vortex_category, WakeVortexCategory::Medium2);
assert_eq!(id.callsign, "VOZ1851");
}
_ => panic!("expected AircraftIdentification"),
}
assert_eq!(frame.parity, 16718117);
let input = [
0x8D, 0x7C, 0x14, 0x65, 0x25, 0x44, 0x60, 0x74, 0xDF, 0x58, 0x20, 0x73, 0x8E, 0x90,
];
let frame = ADSBFrame::decode(&input).unwrap();
assert_eq!(frame.downlink_format, 17);
assert_eq!(frame.capability, Capability::Level2Airborne);
assert_eq!(frame.icao, "7C1465");
match frame.message {
ADSBMessage::AircraftIdentification(ref id) => {
assert_eq!(id.type_code, 4);
assert_eq!(id.emitter_category, 5);
assert_eq!(id.wake_vortex_category, WakeVortexCategory::Heavy);
assert_eq!(id.callsign, "QFA475");
}
_ => panic!("expected AircraftIdentification"),
}
assert_eq!(frame.parity, 7573136);
}
#[test]
fn test_decode_df_17_operation_status() {
let input = [
0x8D, 0x89, 0x65, 0xD2, 0xF8, 0x21, 0x00, 0x02, 0x00, 0x49, 0xB8, 0x94, 0xA4, 0x5F,
];
let frame = ADSBFrame::decode(&input).unwrap();
dbg!(frame);
}
}

44
adsb/src/hex.rs Normal file
View File

@@ -0,0 +1,44 @@
use crate::error::Error;
pub fn hex_to_bytes(s: &str) -> crate::error::Result<Vec<u8>> {
let bytes = s.as_bytes();
if bytes.len() % 2 != 0 {
return Err(Error::new(format!(
"hex string must have even length, got {}",
bytes.len()
)));
}
let mut out = Vec::with_capacity(bytes.len() / 2);
for chunk in bytes.chunks(2) {
let hi = match hex_val(chunk[0]) {
Some(hi) => hi,
None => {
return Err(Error::new(format!(
"invalid hex char '{}'",
chunk[0] as char
)));
}
};
let lo = match hex_val(chunk[1]) {
Some(lo) => lo,
None => {
return Err(Error::new(format!(
"invalid hex char '{}'",
chunk[1] as char
)));
}
};
out.push((hi << 4) | lo);
}
Ok(out)
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}

98
adsb/src/main.rs Normal file
View File

@@ -0,0 +1,98 @@
mod constants;
mod device;
mod error;
mod frame;
mod hex;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::device::RtlSdrDevice;
use clap::Parser;
use crate::constants::DEVICE_RTL2832U;
use crate::frame::ADSBFrame;
use crate::hex::hex_to_bytes;
#[derive(Parser, Debug)]
#[command(author, version, about = "An ADS-B Receiver")]
struct ReceiverArgs {
/// Hex-string to decode
#[arg(short = 'd', long)]
decode: Option<String>,
/// Connect to the USB device
#[arg(short = 'c', long, action)]
connect: bool,
/// Display ADS-B/Mode-S receiver info
#[arg(short = 'i', long, action)]
info: bool,
/// Enable debug logging
#[arg(short = 'D', long, action = clap::ArgAction::Count)]
debug: u8,
}
fn main() {
let args = ReceiverArgs::parse();
let default_filter = match args.debug {
0 => "warn,adsb=info", // no -D
1 => "warn,adsb=debug", // -D
_ => "trace,adsb=trace", // -DD or more
};
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", default_filter));
let device_info = DEVICE_RTL2832U;
// Handle connection
if args.connect {
log::info!("Connecting to {:?}", device_info);
let mut device = match RtlSdrDevice::open(device_info.vid, device_info.pid) {
Ok(d) => d,
Err(err) => {
log::error!("Unable to open RTL SDR device: {:?}", err);
return;
}
};
log::debug!("Connected to {:?}", device_info.to_string());
let running = Arc::new(AtomicBool::new(true));
if let Err(err) = ctrlc::set_handler({
let running = running.clone();
move || running.store(false, Ordering::SeqCst)
}) {
log::error!("Error setting Ctrl-C handler: {}", err);
running.store(false, Ordering::SeqCst);
};
if let Err(err) = device.process(running) {
log::error!("Failed to read from device: {}", err);
if let Err(err) = device.close() {
log::error!("Failed to close device: {}", err);
};
};
}
// Display dongle info
else if args.info {
RtlSdrDevice::info(device_info.vid, device_info.pid);
}
// Handle decode mode
else if let Some(mut hex_string) = args.decode {
if let Some(stripped) = hex_string.strip_prefix("0x") {
hex_string = stripped.to_string();
}
let buffer = match hex_to_bytes(&hex_string) {
Ok(buffer) => buffer,
Err(err) => {
eprintln!("Unable to convert hex to bytes: {:?}", err);
return;
}
};
if let Ok(frame) = ADSBFrame::decode(&buffer) {
println!("{:?}", frame);
};
} else {
eprintln!("No connection specified");
}
}

1406
api/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,40 @@
[package] [package]
name = "api" name = "api"
version = "0.1.0" version = "0.1.2"
edition = "2021" edition = "2024"
authors = ["Ben Sherriff <ben@bensherriff.com>"] authors = ["Ben Sherriff <ben@bensherriff.com>"]
repository = "https://gitea.bensherriff.com/bsherriff/aviation" repository = "https://gitea.bensherriff.com/bsherriff/aviation"
readme = "../README.md" readme = "../README.md"
license = "GPL-3.0-or-later"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
actix-web = "4.10.2" actix-web = "4.10.2"
actix-cors = "0.7.1" actix-cors = "0.7.1"
actix-web-httpauth = "0.8.2"
actix-multipart = "0.7.2" actix-multipart = "0.7.2"
chrono = { version = "0.4.40", features = ["serde"] } chrono = { version = "0.4.41", features = ["serde"] }
dotenv = "0.15.0" dotenv = "0.15.0"
sqlx = { version = "0.8.3", features = ["runtime-tokio", "postgres", "chrono", "uuid"] } sqlx = { version = "0.8.5", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
env_logger = "0.11.8" env_logger = "0.11.8"
reqwest = "0.12.15" reqwest = "0.12.15"
serde = {version = "1.0.219", features = ["derive"]} serde = {version = "1.0.219", features = ["derive"]}
serde_json = "1.0.140" serde_json = "1.0.140"
tokio = { version = "1.44.2", features = ["macros", "rt", "time"] } tokio = { version = "1.45.0", features = ["macros", "rt", "time"] }
uuid = { version = "1.16.0", features = ["serde", "v4"] } uuid = { version = "1.16.0", features = ["serde", "v4"] }
log = "0.4.27" log = "0.4.27"
argon2 = "0.5.3" argon2 = "0.5.3"
redis = { version = "0.29.5", features = ["tokio-comp", "connection-manager", "r2d2", "json"] } redis = { version = "0.31.0", features = ["tokio-comp", "connection-manager", "r2d2", "json"] }
regex = "1.11.1" regex = "1.11.1"
futures-util = "0.3.31" futures-util = "0.3.31"
rust-s3 = "0.35.1" rust-s3 = "0.35.1"
rand = "0.9.0" rand = "0.9.1"
rand_chacha = "0.9.0" rand_chacha = "0.9.0"
geo-types = "0.7.15"
byteorder = "1.5.0"
futures = "0.3.31" futures = "0.3.31"
moka = { version = "0.12.10", features = ["future"] } utoipa = { version = "5.3.1", features = ["chrono", "uuid", "actix_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] }
utoipa-actix-web = "0.1.2"
webpki-roots = "1.0.0"
lettre = { version = "0.11.16", features = ["builder", "smtp-transport", "tokio1-native-tls"] }
handlebars = "6.3.2"
governor = "0.10.0"
flate2 = "1.1.1"

View File

@@ -12,9 +12,11 @@ CREATE TABLE IF NOT EXISTS airports (
elevation_ft REAL NOT NULL, elevation_ft REAL NOT NULL,
longitude REAL NOT NULL, longitude REAL NOT NULL,
latitude REAL NOT NULL, latitude REAL NOT NULL,
geometry GEOMETRY(POINT, 4326) NOT NULL,
has_tower BOOLEAN DEFAULT false, has_tower BOOLEAN DEFAULT false,
has_beacon BOOLEAN DEFAULT false, has_beacon BOOLEAN DEFAULT false,
public BOOLEAN DEFAULT false public BOOLEAN DEFAULT false,
metar_observation_time TIMESTAMPTZ
); );
CREATE INDEX ON airports (iata); CREATE INDEX ON airports (iata);
@@ -24,11 +26,12 @@ CREATE INDEX ON airports (category);
CREATE INDEX ON airports (iso_country); CREATE INDEX ON airports (iso_country);
CREATE INDEX ON airports (iso_region); CREATE INDEX ON airports (iso_region);
CREATE INDEX ON airports (municipality); CREATE INDEX ON airports (municipality);
CREATE INDEX ON airports (longitude, latitude); CREATE INDEX ON airports USING GIST(geometry);
CREATE INDEX ON airports (metar_observation_time);
CREATE TABLE IF NOT EXISTS runways ( CREATE TABLE IF NOT EXISTS runways (
id UUID PRIMARY KEY NOT NULL, id UUID PRIMARY KEY NOT NULL,
icao TEXT NOT NULL, icao TEXT NOT NULL REFERENCES airports(icao) ON DELETE CASCADE,
runway_id TEXT NOT NULL, runway_id TEXT NOT NULL,
length_ft REAL NOT NULL, length_ft REAL NOT NULL,
width_ft REAL NOT NULL, width_ft REAL NOT NULL,
@@ -36,17 +39,20 @@ CREATE TABLE IF NOT EXISTS runways (
); );
CREATE INDEX ON runways (icao); CREATE INDEX ON runways (icao);
CREATE INDEX ON runways (surface); CREATE INDEX ON runways (runway_id);
CREATE TABLE IF NOT EXISTS frequencies ( CREATE TABLE IF NOT EXISTS communications (
id UUID PRIMARY KEY NOT NULL, id UUID PRIMARY KEY NOT NULL,
icao TEXT NOT NULL, icao TEXT NOT NULL REFERENCES airports(icao) ON DELETE CASCADE,
frequency_id TEXT NOT NULL, frequency_id TEXT NOT NULL,
frequency_mhz REAL NOT NULL name TEXT,
frequencies_mhz REAL[] NOT NULL,
phone TEXT
); );
CREATE INDEX ON frequencies (icao); CREATE INDEX ON communications (icao);
CREATE INDEX ON frequencies (frequency_mhz); CREATE INDEX ON communications (frequency_id);
CREATE INDEX ON communications (name);
CREATE TABLE IF NOT EXISTS metars ( CREATE TABLE IF NOT EXISTS metars (
icao TEXT NOT NULL, icao TEXT NOT NULL,
@@ -59,11 +65,14 @@ CREATE TABLE IF NOT EXISTS metars (
CREATE INDEX ON metars (observation_time DESC); CREATE INDEX ON metars (observation_time DESC);
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
email TEXT PRIMARY KEY NOT NULL, username TEXT PRIMARY KEY NOT NULL,
email TEXT,
email_verified BOOLEAN NOT NULL DEFAULT false,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
first_name TEXT NOT NULL, first_name TEXT NOT NULL,
last_name TEXT NOT NULL, last_name TEXT NOT NULL,
avatar TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );

View File

@@ -1,3 +1,3 @@
indent_style = "Block" indent_style = "Block"
reorder_imports = false reorder_imports = true
tab_spaces = 2 tab_spaces = 2

View File

@@ -1,10 +1,10 @@
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload, http}; use super::{SESSION_COOKIE_NAME, Session};
use serde::{Serialize, Deserialize};
use crate::{error::Error, users::User}; use crate::{error::Error, users::User};
use super::{Session, SESSION_COOKIE_NAME}; use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Auth { pub struct Auth {
@@ -34,13 +34,13 @@ impl FromRequest for Auth {
return Err(Error::new(401, "API Key does not exist".to_string()).into()); return Err(Error::new(401, "API Key does not exist".to_string()).into());
} }
}; };
match User::select(&api_key.email).await { match User::select(&api_key.username).await {
Some(user) => Ok(Auth { Some(user) => Ok(Auth {
session_id: None, session_id: None,
api_key: Some(key_id), api_key: Some(key_id),
user, user,
}), }),
None => Err(Error::new(404, format!("User {} not found", api_key.email)).into()), None => Err(Error::new(404, format!("User {} not found", api_key.username)).into()),
} }
}; };
return Box::pin(fut); return Box::pin(fut);
@@ -79,13 +79,13 @@ impl FromRequest for Auth {
// Verify the session // Verify the session
let fut = async move { let fut = async move {
match Session::verify(&session_id, &ip_address).await { match Session::verify(&session_id, &ip_address).await {
Ok(session) => match User::select(&session.email).await { Ok(session) => match User::select(&session.username).await {
Some(user) => Ok(Auth { Some(user) => Ok(Auth {
session_id: Some(session_id), session_id: Some(session_id),
api_key: None, api_key: None,
user, user,
}), }),
None => Err(Error::new(404, format!("User {} not found", session.email)).into()), None => Err(Error::new(404, format!("User {} not found", session.username)).into()),
}, },
Err(err) => Err(err.into()), Err(err) => Err(err.into()),
} }

View File

@@ -0,0 +1,161 @@
use crate::account::{csprng, hash};
use crate::db::redis_async_connection;
use crate::error::{ApiResult, Error};
use crate::smtp;
use chrono::{Datelike, Utc};
use redis::{AsyncCommands, RedisResult};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::{env, fs};
#[derive(Debug, Serialize, Deserialize)]
pub struct EmailToken {
pub email: String,
pub token: String,
pub ip_address: String,
}
impl EmailToken {
pub fn new(email: String, token: String, ip_address: &str) -> Self {
Self {
email,
token,
ip_address: hash(&ip_address).unwrap(),
}
}
pub async fn store(&self, ttl_secs: i64) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let key = self.token.clone();
let value = serde_json::to_string(self)?;
let now = Utc::now();
let expires_at = now + chrono::Duration::seconds(ttl_secs);
let ttl = expires_at.timestamp() - now.timestamp();
let result: RedisResult<()> = conn.set_ex(key, &value, ttl as u64).await;
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn get(token: &str) -> ApiResult<Self> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<Option<String>> = conn.get(token).await;
match result {
Ok(Some(value)) => Ok(serde_json::from_str(&value)?),
Ok(None) => Err(Error::new(404, format!("Missing email token {}", token))),
Err(err) => Err(err.into()),
}
}
pub async fn delete(token: &str) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<()> = conn.del(token).await;
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
}
#[derive(Serialize)]
pub struct SimpleEmailCtx {
pub logo_url: String,
pub link: String,
pub domain: String,
pub year: i32,
}
pub async fn send_password_reset_email(
email: &str,
email_token: &EmailToken,
ip_address: &str,
) -> ApiResult<()> {
let base_url = env::var("EXTERNAL_URL")?;
let link = format!("{base_url}/profile/reset?token={}", email_token.token);
let subject = "Reset your password";
let plain = format!(
"Hello,\n\n\
We received a password reset request. Click the link below:\n\n\
{link}\n\n\
This link expires in 24 hours. If you didn't request this, please ignore.\n\n\
Cheers,\n\
The Aviation Data Team",
link = link
);
let ctx = SimpleEmailCtx {
logo_url: format!("{}/logo.svg", base_url),
link: link.clone(),
domain: base_url,
year: Utc::now().year(),
};
let template_dir = env::var("TEMPLATE_DIR")?;
let tpl_path = Path::new(&template_dir).join("password_reset.html");
let template_html = fs::read_to_string(&tpl_path)?;
let html = smtp::registry()
.render_template(&template_html, &ctx)
.unwrap();
match smtp::send_email(&email, subject, plain, html).await {
Ok(_) => Ok(()),
Err(err) => {
log::error!(
"Invalid password reset attempt [Email: {}] [IP Address: {}]: {}",
email,
ip_address,
err
);
Err(err.into())
}
}
}
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";
let plain = format!(
"Hello,\n\n\
Thanks for registering! Click the link below to confirm your email address:\n\n\
{link}\n\n\
If you didnt sign up for an Aviation Data account, please ignore this.\n\n\
Cheers,\n\
The Aviation Data Team",
link = link
);
let ctx = SimpleEmailCtx {
logo_url: format!("{}/logo.svg", base_url),
link: link.clone(),
domain: base_url,
year: Utc::now().year(),
};
let template_dir = env::var("TEMPLATE_DIR")?;
let tpl_path = Path::new(&template_dir).join("confirm_email.html");
let template_html = fs::read_to_string(&tpl_path)?;
let html = smtp::registry()
.render_template(&template_html, &ctx)
.unwrap();
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(())
}

View File

@@ -1,20 +1,22 @@
use argon2::{ use argon2::{
password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHash, PasswordHasher, PasswordVerifier, Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
password_hash::{SaltString, rand_core::OsRng},
}; };
use rand::distr::Alphanumeric; use rand::distr::Alphanumeric;
use rand::prelude::*; use rand::prelude::*;
use rand_chacha::ChaCha20Rng; use rand_chacha::ChaCha20Rng;
mod auth; mod auth;
mod email_token;
mod model;
mod routes; mod routes;
mod session; mod session;
pub use auth::*; pub use auth::*;
pub use session::*;
pub use routes::init_routes; pub use routes::init_routes;
pub use session::*;
use crate::error::{Error, ApiResult}; use crate::error::{ApiResult, Error};
pub fn csprng(take: usize) -> String { pub fn csprng(take: usize) -> String {
// Generate a CSPRNG 128-bit (16 byte) ID using alphanumeric characters (a-z, A-Z, 0-9) // Generate a CSPRNG 128-bit (16 byte) ID using alphanumeric characters (a-z, A-Z, 0-9)

24
api/src/account/model.rs Normal file
View 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,
}
}
}

View File

@@ -1,16 +1,32 @@
use actix_web::{post, web, HttpResponse, ResponseError, HttpRequest, put, get};
use crate::{ use crate::{
account::{verify_hash, Session, SESSION_COOKIE_NAME}, account::{SESSION_COOKIE_NAME, Session, verify_hash},
error::Error, error::Error,
users::{LoginRequest, RegisterRequest, User, UserResponse}, users::{LoginRequest, RegisterRequest, User, UserResponse},
}; };
use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web};
use serde::Deserialize;
use utoipa::ToSchema;
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
use crate::account::Auth; use crate::account::email_token::{EmailToken, send_confirm_email, send_password_reset_email};
use crate::account::{Auth, csprng};
use crate::users::UpdateUser; use crate::users::UpdateUser;
#[utoipa::path(
tag = "account",
request_body(
content = RegisterRequest, content_type = "application/json"
),
responses(
(status = 200, description = "Successful Response", body = UserResponse),
(status = 409, description = "Conflict"),
)
)]
#[post("/register")] #[post("/register")]
async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse { async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse {
let register_user = user.into_inner(); let register_user = user.into_inner();
let username = register_user.username.clone();
let email = register_user.email.clone(); let email = register_user.email.clone();
let ip_address = req.peer_addr().unwrap().ip().to_string(); let ip_address = req.peer_addr().unwrap().ip().to_string();
let insert_user: User = match register_user.to_user() { let insert_user: User = match register_user.to_user() {
@@ -22,75 +38,221 @@ async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpRes
Ok(user) => { Ok(user) => {
let user_response: UserResponse = user.into(); let user_response: UserResponse = user.into();
log::info!( log::info!(
"Successful user registration [Email: {}] [IP Address: {}]", "Successful user registration [User: {}] [IP Address: {}]",
email, username,
ip_address ip_address
); );
// Send confirmation email
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);
};
});
}
HttpResponse::Created().json(user_response) HttpResponse::Created().json(user_response)
} }
Err(err) => { Err(err) => {
// Obfuscate the service error message to prevent leaking database details // Obfuscate the service error message to prevent leaking database details
if err.status == 409 { if err.status == 409 {
log::warn!( log::warn!(
"Duplicate user registration attempt [Email: {}] [IP Address: {}]", "Duplicate user registration attempt [User: {}] [IP Address: {}]",
email, username,
ip_address ip_address
); );
HttpResponse::Conflict().finish() HttpResponse::Conflict().finish()
} else { } else {
log::error!("attemptFailed to register user [Email: {}]: {}", email, err); log::error!("Failed to register user [User: {}]: {}", username, err);
ResponseError::error_response(&err) ResponseError::error_response(&err)
} }
} }
} }
} }
#[post("/login")] #[derive(Debug, Deserialize, ToSchema)]
async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse { struct ConfirmEmail {
let email = &request.email; token: String,
}
#[utoipa::path(
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(); let ip_address = req.peer_addr().unwrap().ip().to_string();
let query_user = match User::select(&email).await { 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"
),
responses(
(status = 200, description = "Successful Response", body = UserResponse),
),
)]
#[post("/login")]
async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
let username = &request.username;
let ip_address = req.peer_addr().unwrap().ip().to_string();
let query_user = match User::select(&username).await {
Some(query_user) => query_user, Some(query_user) => query_user,
None => return HttpResponse::Unauthorized().finish(), None => return HttpResponse::Unauthorized().finish(),
}; };
if verify_hash(&request.password, &query_user.password_hash) { if verify_hash(&request.password, &query_user.password_hash) {
// Create a session // Create a session
let session = Session::default(&email, &ip_address); let session = Session::default(&query_user.username, &ip_address);
let session_cookie = session.cookie(); let session_cookie = session.cookie();
let session_exp_cookie = session.expiration_cookie();
// Save the session to the database // Save the session to the database
if let Err(err) = session.store().await { if let Err(err) = session.store().await {
log::error!( log::error!(
"Login attempt failure [Email: {}] [IP Address: {}]: {}", "Login attempt failure [User: {}] [IP Address: {}]: {}",
email, username,
ip_address, ip_address,
err err
); );
return ResponseError::error_response(&Error::new(500, err.to_string())); return ResponseError::error_response(&Error::new(500, err.to_string()));
} }
log::info!( log::info!(
"Successful login attempt [Email: {}] [IP Address: {}]", "Successful login attempt [User: {}] [IP Address: {}]",
email, username,
ip_address ip_address
); );
let user_response: UserResponse = query_user.into(); let user_response: UserResponse = query_user.into();
HttpResponse::Ok() HttpResponse::Ok()
.cookie(session_cookie) .cookie(session_cookie)
.cookie(session_exp_cookie)
.json(user_response) .json(user_response)
} else { } else {
log::error!( log::error!(
"Invalid login attempt [Email: {}] [IP Address: {}]", "Invalid login attempt [User: {}] [IP Address: {}]",
email, username,
ip_address ip_address
); );
HttpResponse::Unauthorized().finish() HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish()
} }
} }
#[utoipa::path(
tag = "account",
responses(
(status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[post("/logout")] #[post("/logout")]
async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse { 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(); let ip_address = req.peer_addr().unwrap().ip().to_string();
// Delete the session from the store // Delete the session from the store
match req.cookie(SESSION_COOKIE_NAME) { match req.cookie(SESSION_COOKIE_NAME) {
@@ -98,8 +260,8 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
let session_id = cookie.value().to_string(); let session_id = cookie.value().to_string();
if let Err(err) = Session::delete(&session_id).await { if let Err(err) = Session::delete(&session_id).await {
log::error!( log::error!(
"Logout attempt failure [Email: {}] [IP Address: {}]: {}", "Logout attempt failure [User: {}] [IP Address: {}]: {}",
email, username,
ip_address, ip_address,
err err
); );
@@ -108,8 +270,8 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
} }
None => { None => {
log::error!( log::error!(
"Invalid logout attempt [Email: {}] [IP Address: {}]", "Invalid logout attempt [User: {}] [IP Address: {}]",
email, username,
ip_address ip_address
); );
return ResponseError::error_response(&Error::new(400, "Invalid session".to_string())); return ResponseError::error_response(&Error::new(400, "Invalid session".to_string()));
@@ -117,15 +279,92 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
} }
log::info!( log::info!(
"Successful logout attempt [Email: {}] [IP Address: {}]", "Successful logout attempt [User: {}] [IP Address: {}]",
email, username,
ip_address ip_address
); );
HttpResponse::Ok().cookie(Session::empty_cookie()).finish() HttpResponse::Ok()
.cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish()
} }
#[get("/session")] #[utoipa::path(
async fn validate_session(req: HttpRequest) -> HttpResponse { tag = "account",
responses(
(status = 200, description = "Successful Response", body = UserResponse),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[get("/profile")]
async fn get_profile(req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Verify a session cookie exists
match req.cookie(SESSION_COOKIE_NAME) {
// Validate the session
Some(cookie) => {
let session_id = cookie.value().to_string();
let session = match Session::get(&session_id).await {
Ok(session) => session,
Err(_) => {
log::error!(
"Invalid profile attempt [Session: {}] [IP Address: {}]",
session_id,
ip_address
);
return HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish();
}
};
let username = &session.username;
let query_user = match User::select(&username).await {
Some(query_user) => query_user,
None => {
return HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish();
}
};
let user_response: UserResponse = query_user.into();
let session_cookie = session.cookie();
let session_exp_cookie = session.expiration_cookie();
log::info!(
"Successful profile attempt [User: {}] [IP Address: {}]",
username,
ip_address
);
HttpResponse::Ok()
.cookie(session_cookie)
.cookie(session_exp_cookie)
.json(user_response)
}
None => HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish(),
}
}
#[utoipa::path(
tag = "account",
responses(
(status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[post("/session")]
async fn session_refresh(req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string(); let ip_address = req.peer_addr().unwrap().ip().to_string();
// Verify a session cookie exists // Verify a session cookie exists
match req.cookie(SESSION_COOKIE_NAME) { match req.cookie(SESSION_COOKIE_NAME) {
@@ -134,7 +373,7 @@ async fn validate_session(req: HttpRequest) -> HttpResponse {
let session_id = cookie.value().to_string(); let session_id = cookie.value().to_string();
let session = match Session::replace(&session_id, &ip_address).await { let session = match Session::replace(&session_id, &ip_address).await {
Ok(session) => session, Ok(session) => session,
Err(err) => { Err(_) => {
log::error!( log::error!(
"Invalid session validate attempt [Session: {}] [IP Address: {}]", "Invalid session validate attempt [Session: {}] [IP Address: {}]",
session_id, session_id,
@@ -142,93 +381,182 @@ async fn validate_session(req: HttpRequest) -> HttpResponse {
); );
return HttpResponse::Unauthorized() return HttpResponse::Unauthorized()
.cookie(Session::empty_cookie()) .cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish(); .finish();
} }
}; };
let email = &session.email; let username = &session.username;
let query_user = match User::select(&email).await {
Some(query_user) => query_user,
None => {
return HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.finish()
}
};
let user_response: UserResponse = query_user.into();
let session_cookie = session.cookie(); let session_cookie = session.cookie();
let session_exp_cookie = session.expiration_cookie();
log::info!( log::info!(
"Successful session validate attempt [Email: {}] [IP Address: {}]", "Successful session validate attempt [User: {}] [IP Address: {}]",
email, username,
ip_address ip_address
); );
HttpResponse::Ok() HttpResponse::Ok()
.cookie(session_cookie) .cookie(session_cookie)
.json(user_response) .cookie(session_exp_cookie)
.finish()
} }
None => HttpResponse::Unauthorized() None => HttpResponse::Unauthorized()
.cookie(Session::empty_cookie()) .cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish(), .finish(),
} }
} }
#[derive(Debug, Deserialize, ToSchema)]
struct ChangePassword {
password: String,
}
#[utoipa::path(
tag = "account",
request_body(
content = ChangePassword, content_type = "application/json"
),
responses(
(status = 200, description = "Successful Response", body = UserResponse),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[put("/password")] #[put("/password")]
async fn change_password( async fn change_password(
password: web::Json<String>, request: web::Json<ChangePassword>,
req: HttpRequest, req: HttpRequest,
auth: Auth, auth: Auth,
) -> HttpResponse { ) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string(); let ip_address = req.peer_addr().unwrap().ip().to_string();
let email = auth.user.email; let username = auth.user.username;
if let None = User::select(&email).await { if let None = User::select(&username).await {
return HttpResponse::Unauthorized().finish(); return HttpResponse::Unauthorized().finish();
}; };
let update_user = UpdateUser { let update_user = UpdateUser {
email: None, email: None,
password: Some(password.into_inner()), email_verified: None,
password: Some(request.password.clone()),
role: None, role: None,
first_name: None, first_name: None,
last_name: None, last_name: None,
avatar: None,
}; };
match update_user.update(&email).await { match update_user.update(&username).await {
Ok(user) => { Ok(user) => {
let response: UserResponse = user.into(); let response: UserResponse = user.into();
log::info!( log::info!(
"Successful password change attempt [Email: {}] [IP Address: {}]", "Successful password change attempt [User: {}] [IP Address: {}]",
&email, &username,
ip_address ip_address
); );
HttpResponse::Ok().json(response) HttpResponse::Ok().json(response)
} }
Err(err) => { Err(err) => {
log::error!( log::error!(
"Invalid password change attempt [Email: {}] [IP Address: {}]: {}", "Invalid password change attempt [User: {}] [IP Address: {}]: {}",
&email, &username,
ip_address, ip_address,
err err
); );
ResponseError::error_response(&Error::new(500, err.to_string())) ResponseError::error_response(&err)
} }
} }
} }
#[post("/password-reset")] #[derive(Debug, Deserialize, ToSchema)]
async fn password_reset(req: HttpRequest, _auth: Auth) -> HttpResponse { struct PasswordReset {
let _ip_address = req.peer_addr().unwrap().ip().to_string(); email: String,
}
#[utoipa::path(
tag = "account",
request_body(
content = PasswordReset, content_type = "application/json"
),
responses(
(status = 200, description = "Successful Response"),
)
)]
#[post("/password/reset")]
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's email does not exist
if let None = User::select_by_email(&email).await {
return HttpResponse::Ok().finish();
};
let email_token = EmailToken::new(email.clone(), token, &ip_address);
if let Err(err) = email_token.store(86400).await {
return ResponseError::error_response(&err);
}
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() HttpResponse::Ok().finish()
} }
pub fn init_routes(config: &mut web::ServiceConfig) { #[derive(Debug, Deserialize, ToSchema)]
struct ConfirmPasswordReset {
token: String,
password: String,
}
#[utoipa::path(
tag = "account",
request_body(
content = ConfirmPasswordReset, content_type = "application/json"
),
responses(
(status = 200, description = "Successful Response"),
(status = 404, description = "Not Found"),
)
)]
#[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;
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(err) => {
return HttpResponse::NotFound().json(err);
}
};
HttpResponse::Ok().finish()
}
pub fn init_routes(config: &mut ServiceConfig) {
config.service( config.service(
web::scope("account") scope::scope("/account")
.service(register) .service(register)
.service(confirm_email_registration)
.service(resend_email_verification)
.service(login) .service(login)
.service(logout) .service(logout)
.service(get_profile)
.service(session_refresh)
.service(change_password) .service(change_password)
.service(validate_session), .service(reset_password)
.service(confirm_password_reset),
); );
} }

View File

@@ -1,36 +1,37 @@
use actix_web::cookie::{time::Duration, Cookie}; use super::{csprng, hash, verify_hash};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use redis::{AsyncCommands, RedisResult};
use tokio::task;
use crate::{ use crate::{
db::redis_async_connection, db::redis_async_connection,
error::{Error, ApiResult}, error::{ApiResult, Error},
}; };
use super::{csprng, hash, verify_hash}; use actix_web::cookie::{Cookie, time::Duration};
use chrono::{DateTime, Utc};
use redis::{AsyncCommands, RedisResult};
use serde::{Deserialize, Serialize};
use tokio::task;
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
pub const SESSION_COOKIE_NAME: &str = "session"; pub const SESSION_COOKIE_NAME: &str = "session";
pub const SESSION_EXPIRATION_COOKIE_NAME: &str = "session_expiration";
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Session { pub struct Session {
pub session_id: String, pub session_id: String,
pub email: String, pub username: String,
pub ip_address: String, pub ip_address: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>, pub expires_at: Option<DateTime<Utc>>,
} }
impl Session { impl Session {
pub fn default(email: &str, ip_address: &str) -> Self { pub fn default(username: &str, ip_address: &str) -> Self {
Self::new(64, email, ip_address, Some(DEFAULT_SESSION_TTL)) Self::new(64, username, ip_address, Some(DEFAULT_SESSION_TTL))
} }
pub fn new(take: usize, email: &str, 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(); let now = Utc::now();
Self { Self {
session_id: csprng(take), session_id: csprng(take),
email: email.to_string(), username: username.to_string(),
ip_address: hash(&ip_address).unwrap(), ip_address: hash(&ip_address).unwrap(),
expires_at: match ttl { expires_at: match ttl {
Some(ttl) => Some(now + chrono::Duration::seconds(ttl)), Some(ttl) => Some(now + chrono::Duration::seconds(ttl)),
@@ -77,7 +78,7 @@ impl Session {
); );
}; };
}); });
session = Session::default(&session.email, ip_address); session = Session::default(&session.username, ip_address);
session.store().await?; session.store().await?;
Ok(session) Ok(session)
} }
@@ -118,8 +119,8 @@ impl Session {
if let Ok(environment) = std::env::var("ENVIRONMENT") { if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" { if environment == "development" || environment == "dev" {
log::trace!( log::trace!(
"Development cookie [Email: {}]: {}", "Session cookie [User: {}]: {}",
self.email, self.username,
self.session_id self.session_id
); );
cookie.set_secure(false); cookie.set_secure(false);
@@ -130,6 +131,33 @@ impl Session {
cookie cookie
} }
pub fn expiration_cookie(&self) -> Cookie {
let expires_at = match self.expires_at {
Some(expires_at) => expires_at.timestamp(),
None => DEFAULT_SESSION_TTL,
};
let ttl = expires_at - Utc::now().timestamp();
let mut cookie = Cookie::build(SESSION_EXPIRATION_COOKIE_NAME, expires_at.to_string())
.path("/")
.max_age(Duration::seconds(ttl))
.secure(true)
.http_only(false)
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
log::trace!(
"Session expiration cookie [User: {}]: {}",
self.username,
self.session_id
);
cookie.set_secure(false);
}
}
cookie
}
pub fn empty_cookie() -> Cookie<'static> { pub fn empty_cookie() -> Cookie<'static> {
let mut cookie = Cookie::build(SESSION_COOKIE_NAME, "") let mut cookie = Cookie::build(SESSION_COOKIE_NAME, "")
.path("/") .path("/")
@@ -147,4 +175,21 @@ impl Session {
cookie cookie
} }
pub fn empty_expiration_cookie() -> Cookie<'static> {
let mut cookie = Cookie::build(SESSION_EXPIRATION_COOKIE_NAME, "")
.path("/")
.max_age(Duration::seconds(-1))
.secure(true)
.http_only(false)
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
cookie.set_secure(false);
}
}
cookie
}
} }

View File

@@ -1,5 +1,5 @@
mod model; pub mod model;
mod routes; pub mod routes;
pub use model::*; pub use model::*;
pub use routes::init_routes; pub use routes::init_routes;

View File

@@ -1,19 +1,24 @@
use std::collections::HashMap;
use std::str::FromStr;
use futures_util::try_join;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use crate::airports::{ use crate::airports::{
AirportCategory, Frequency, FrequencyRow, Runway, RunwayRow, UpdateFrequency, UpdateRunway, AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication,
UpdateRunway,
}; };
use crate::db; use crate::db;
use crate::error::{ApiResult, Error}; use crate::error::{ApiResult, Error};
use crate::metars::Metar; use crate::metars::Metar;
use chrono::{DateTime, Utc};
use futures_util::try_join;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use std::collections::HashMap;
use std::str::FromStr;
use utoipa::{IntoParams, ToSchema};
const TABLE_NAME: &str = "airports"; const TABLE_NAME: &str = "airports";
const DEFAULT_COLUMNS: &str = "icao, iata, local, name, category, iso_country, \
iso_region, municipality, elevation_ft, longitude, latitude, has_tower, has_beacon,\
public, metar_observation_time";
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct Airport { pub struct Airport {
pub icao: String, pub icao: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -33,13 +38,14 @@ pub struct Airport {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub has_beacon: Option<bool>, pub has_beacon: Option<bool>,
pub runways: Vec<Runway>, pub runways: Vec<Runway>,
pub frequencies: Vec<Frequency>, pub communications: Vec<Communication>,
pub public: bool, pub public: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub latest_metar: Option<Metar>, pub latest_metar: Option<Metar>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct AirportQuery { pub struct AirportQuery {
pub page: Option<u32>, pub page: Option<u32>,
pub limit: Option<u32>, pub limit: Option<u32>,
@@ -74,7 +80,55 @@ impl Default for AirportQuery {
} }
} }
#[derive(Debug, Deserialize)] // 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 struct Bounds {
pub north_east_lat: f32, pub north_east_lat: f32,
pub north_east_lon: f32, pub north_east_lon: f32,
@@ -121,9 +175,10 @@ struct AirportRow {
pub has_tower: Option<bool>, pub has_tower: Option<bool>,
pub has_beacon: Option<bool>, pub has_beacon: Option<bool>,
pub public: bool, pub public: bool,
pub metar_observation_time: Option<DateTime<Utc>>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateAirport { pub struct UpdateAirport {
pub icao: Option<String>, pub icao: Option<String>,
pub iata: Option<String>, pub iata: Option<String>,
@@ -139,8 +194,9 @@ pub struct UpdateAirport {
pub has_tower: Option<bool>, pub has_tower: Option<bool>,
pub has_beacon: Option<bool>, pub has_beacon: Option<bool>,
pub runways: Option<Vec<UpdateRunway>>, pub runways: Option<Vec<UpdateRunway>>,
pub frequencies: Option<Vec<UpdateFrequency>>, pub communications: Option<Vec<UpdateCommunication>>,
pub public: Option<bool>, pub public: Option<bool>,
pub latest_metar_observation: Option<DateTime<Utc>>,
} }
impl Into<AirportRow> for Airport { impl Into<AirportRow> for Airport {
@@ -160,6 +216,10 @@ impl Into<AirportRow> for Airport {
has_tower: self.has_tower, has_tower: self.has_tower,
has_beacon: self.has_beacon, has_beacon: self.has_beacon,
public: self.public, public: self.public,
metar_observation_time: match self.latest_metar {
Some(m) => Some(m.observation_time),
None => None,
},
} }
} }
} }
@@ -187,7 +247,7 @@ impl From<AirportRow> for Airport {
has_tower: airport.has_tower, has_tower: airport.has_tower,
has_beacon: airport.has_beacon, has_beacon: airport.has_beacon,
runways: vec![], runways: vec![],
frequencies: vec![], communications: vec![],
public: airport.public, public: airport.public,
latest_metar: None, latest_metar: None,
} }
@@ -195,11 +255,14 @@ impl From<AirportRow> for Airport {
} }
impl 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 pool = db::pool();
let airport_fut = async { let airport_fut = async {
sqlx::query_as(&format!("SELECT * FROM {} WHERE icao = $1", TABLE_NAME)) sqlx::query_as(&format!(
"SELECT {} FROM {} WHERE icao = $1",
DEFAULT_COLUMNS, TABLE_NAME
))
.bind(icao) .bind(icao)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
@@ -207,7 +270,7 @@ impl Airport {
let metar_fut = async { let metar_fut = async {
if metar { if metar {
match Metar::find_all(client, &vec![icao.to_string()], &false).await { match Metar::get_all_distinct(&vec![icao.to_uppercase()]).await {
Ok(m) => Some(m.into_iter().nth(0)), Ok(m) => Some(m.into_iter().nth(0)),
Err(err) => { Err(err) => {
log::error!("{}", err); log::error!("{}", err);
@@ -220,10 +283,10 @@ impl Airport {
}; };
let runways_fut = Runway::select_all(icao); let runways_fut = Runway::select_all(icao);
let frequencies_fut = Frequency::select_all(icao); let communications_fut = Communication::select_all(icao);
let (airport_result, runways_result, frequencies_result, metar_result) = let (airport_result, runways_result, communications_result, metar_result) =
tokio::join!(airport_fut, runways_fut, frequencies_fut, metar_fut); tokio::join!(airport_fut, runways_fut, communications_fut, metar_fut);
let airport_row: Option<AirportRow> = match airport_result { let airport_row: Option<AirportRow> = match airport_result {
Ok(opt) => opt, Ok(opt) => opt,
@@ -241,11 +304,11 @@ impl Airport {
} }
}; };
let frequencies: Vec<Frequency> = match frequencies_result { let communications: Vec<Communication> = match communications_result {
Ok(f) => f, Ok(f) => f,
Err(err) => { Err(err) => {
log::error!( log::error!(
"Error retrieving frequencies for airport '{}': {}", "Error retrieving communications for airport '{}': {}",
icao, icao,
err err
); );
@@ -264,17 +327,17 @@ impl Airport {
airport_row.map(|row| { airport_row.map(|row| {
let mut airport: Airport = row.into(); let mut airport: Airport = row.into();
airport.runways = runways; airport.runways = runways;
airport.frequencies = frequencies; airport.communications = communications;
airport.latest_metar = metar; airport.latest_metar = metar;
airport 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 pool = db::pool();
let mut builder = QueryBuilder::<Postgres>::new("SELECT * FROM "); let mut builder =
builder.push(TABLE_NAME); QueryBuilder::<Postgres>::new(format!("SELECT {} FROM {}", DEFAULT_COLUMNS, TABLE_NAME));
let mut has_where = false; let mut has_where = false;
Self::push_condition_array(&mut builder, &mut has_where, "icao", &query.icaos); Self::push_condition_array(&mut builder, &mut has_where, "icao", &query.icaos);
@@ -302,8 +365,8 @@ impl Airport {
Self::push_condition_like(&mut builder, &mut has_where, "name", &query.name); Self::push_condition_like(&mut builder, &mut has_where, "name", &query.name);
Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds)?; Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds)?;
// Order by AircraftCategory builder.push(" ORDER BY (metar_observation_time IS NULL) ASC, ");
builder.push(" ORDER BY CASE category "); builder.push(" CASE category ");
builder.push(" WHEN 'large_airport' THEN 1 "); builder.push(" WHEN 'large_airport' THEN 1 ");
builder.push(" WHEN 'medium_airport' THEN 2 "); builder.push(" WHEN 'medium_airport' THEN 2 ");
builder.push(" WHEN 'small_airport' THEN 3 "); builder.push(" WHEN 'small_airport' THEN 3 ");
@@ -333,12 +396,12 @@ impl Airport {
} }
// Bulk update airport sub-fields // 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 runway_future = Runway::select_all_map(&icaos);
let frequency_future = Frequency::select_all_map(icaos.clone()); let frequency_future = Communication::select_all_map(&icaos);
let metar_future = if query.metars.unwrap_or(false) { let metar_future = if query.metars.unwrap_or(false) {
Some(Metar::find_all(client, &icaos, &false)) Some(Metar::get_all_distinct(&icaos))
} else { } else {
None None
}; };
@@ -366,7 +429,7 @@ impl Airport {
for airport in airports.iter_mut() { for airport in airports.iter_mut() {
airport.runways = runway_map.get(&airport.icao).cloned().unwrap_or_default(); airport.runways = runway_map.get(&airport.icao).cloned().unwrap_or_default();
airport.frequencies = frequency_map airport.communications = frequency_map
.get(&airport.icao) .get(&airport.icao)
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
@@ -421,29 +484,31 @@ impl Airport {
let pool = db::pool(); let pool = db::pool();
let mut all_runway_rows: Vec<RunwayRow> = Vec::new(); let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
let mut all_frequency_rows: Vec<FrequencyRow> = Vec::new(); let mut all_frequency_rows: Vec<CommunicationRow> = Vec::new();
for runway in &self.runways { for runway in &self.runways {
all_runway_rows.push(Runway::into(runway, &self.icao)); all_runway_rows.push(Runway::into(runway, &self.icao));
} }
for frequency in &self.frequencies { for frequency in &self.communications {
all_frequency_rows.push(Frequency::into(frequency, &self.icao)); all_frequency_rows.push(Communication::into(frequency, &self.icao));
} }
Runway::insert_all(&all_runway_rows).await?; Runway::insert_all(&all_runway_rows).await?;
Frequency::insert_all(&all_frequency_rows).await?; Communication::insert_all(&all_frequency_rows).await?;
let airport: AirportRow = sqlx::query_as(&format!( let airport: AirportRow = sqlx::query_as(&format!(
r#" r#"
INSERT INTO {} ( INSERT INTO {} (
icao, iata, local, name, category, iso_country, iso_region, municipality, icao, iata, local, name, category, iso_country, iso_region, municipality,
elevation_ft, longitude, latitude, has_tower, has_beacon, public elevation_ft, longitude, latitude, geometry, has_tower, has_beacon, public
) )
VALUES ( VALUES (
$1, $2, $3, $4, $5, $6, $7, $1, $2, $3, $4, $5, $6, $7, $8,
$8, $9, $10, $11, $12, $13, $14 $9, $10, $11,
ST_SetSRID(ST_MakePoint($10, $11), 4326),
$12, $13, $14
) )
RETURNING * RETURNING {}
"#, "#,
TABLE_NAME, TABLE_NAME, DEFAULT_COLUMNS
)) ))
.bind(self.icao.to_string()) .bind(self.icao.to_string())
.bind(&self.iata) .bind(&self.iata)
@@ -469,27 +534,25 @@ impl Airport {
let pool = db::pool(); let pool = db::pool();
let chunk_size = 1000; let chunk_size = 1000;
let mut all_runway_rows: Vec<RunwayRow> = Vec::new(); let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
let mut all_frequency_rows: Vec<FrequencyRow> = Vec::new(); let mut all_frequency_rows: Vec<CommunicationRow> = Vec::new();
let airport_rows: Vec<AirportRow> = airports let airport_rows: Vec<AirportRow> = airports
.into_iter() .into_iter()
.map(|airport| { .map(|airport| {
for runway in &airport.runways { for runway in &airport.runways {
all_runway_rows.push(Runway::into(runway, &airport.icao)); all_runway_rows.push(Runway::into(runway, &airport.icao));
} }
for frequency in &airport.frequencies { for frequency in &airport.communications {
all_frequency_rows.push(Frequency::into(frequency, &airport.icao)); all_frequency_rows.push(Communication::into(frequency, &airport.icao));
} }
airport.into() airport.into()
}) })
.collect(); .collect();
Runway::insert_all(&all_runway_rows).await?;
Frequency::insert_all(&all_frequency_rows).await?;
for chunk in airport_rows.chunks(chunk_size) { for chunk in airport_rows.chunks(chunk_size) {
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new( let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(
"INSERT INTO airports (icao, iata, local, name, category, \ "INSERT INTO airports (icao, iata, local, name, category, \
iso_country, iso_region, municipality, elevation_ft, \ iso_country, iso_region, municipality, elevation_ft, \
longitude, latitude, has_tower, has_beacon, public) ", longitude, latitude, geometry, has_tower, has_beacon, public) ",
); );
query_builder.push_values(chunk, |mut b, row| { query_builder.push_values(chunk, |mut b, row| {
b.push_bind(&row.icao) b.push_bind(&row.icao)
@@ -503,6 +566,11 @@ impl Airport {
.push_bind(row.elevation_ft) .push_bind(row.elevation_ft)
.push_bind(row.longitude) .push_bind(row.longitude)
.push_bind(row.latitude) .push_bind(row.latitude)
.push_unseparated(", ST_SetSRID(ST_MakePoint(")
.push_bind_unseparated(row.longitude)
.push_unseparated(", ")
.push_bind_unseparated(row.latitude)
.push_unseparated("), 4326)")
.push_bind(row.has_tower) .push_bind(row.has_tower)
.push_bind(row.has_beacon) .push_bind(row.has_beacon)
.push_bind(row.public); .push_bind(row.public);
@@ -512,11 +580,27 @@ impl Airport {
query.execute(pool).await?; query.execute(pool).await?;
} }
Runway::insert_all(&all_runway_rows).await?;
Communication::insert_all(&all_frequency_rows).await?;
Ok(()) Ok(())
} }
// TODO // TODO
pub async fn update(_icao: &str, _airport: &UpdateAirport) -> ApiResult<()> { pub async fn update(icao: &str, airport: &UpdateAirport) -> ApiResult<()> {
let pool = db::pool();
let mut query_builder: QueryBuilder<Postgres> =
QueryBuilder::new(format!("UPDATE {} SET ", TABLE_NAME));
if let Some(latest_metar_observation) = airport.latest_metar_observation {
query_builder.push("metar_observation_time = ");
query_builder.push_bind(latest_metar_observation);
}
query_builder.push(" WHERE icao = ").push_bind(icao);
let query = query_builder.build();
query.execute(pool).await?;
Ok(()) Ok(())
} }
@@ -557,7 +641,7 @@ impl Airport {
column: &str, column: &str,
field: &'a Option<String>, field: &'a Option<String>,
) { ) {
if let Some(ref value_str) = field { if let Some(value_str) = field {
// Split on commas, trim whitespace, and drop empties. // Split on commas, trim whitespace, and drop empties.
let values: Vec<&str> = value_str let values: Vec<&str> = value_str
.split(',') .split(',')
@@ -586,7 +670,7 @@ impl Airport {
field: &'a Option<String>, field: &'a Option<String>,
) { ) {
// Query column like // Query column like
if let Some(ref value) = field { if let Some(value) = field {
if !*has_where { if !*has_where {
builder.push(" WHERE "); builder.push(" WHERE ");
*has_where = true; *has_where = true;
@@ -607,7 +691,7 @@ impl Airport {
field: &'a Option<String>, field: &'a Option<String>,
) -> ApiResult<()> { ) -> ApiResult<()> {
// Query bounds // Query bounds
if let Some(ref bounds_string) = field { if let Some(bounds_string) = field {
if !*has_where { if !*has_where {
builder.push(" WHERE "); builder.push(" WHERE ");
*has_where = true; *has_where = true;
@@ -617,15 +701,15 @@ impl Airport {
let bounds = Bounds::parse(bounds_string)?; let bounds = Bounds::parse(bounds_string)?;
builder builder
.push("(") .push("(")
.push("latitude BETWEEN ") .push("geometry && ST_MakeEnvelope(")
.push_bind(bounds.south_west_lat)
.push(" AND ")
.push_bind(bounds.north_east_lat)
.push(" AND ")
.push("longitude BETWEEN ")
.push_bind(bounds.south_west_lon) .push_bind(bounds.south_west_lon)
.push(" AND ") .push(", ")
.push_bind(bounds.south_west_lat)
.push(", ")
.push_bind(bounds.north_east_lon) .push_bind(bounds.north_east_lon)
.push(", ")
.push_bind(bounds.north_east_lat)
.push(", 4326)")
.push(")"); .push(")");
} }
Ok(()) Ok(())

View File

@@ -1,8 +1,9 @@
use serde::{Deserialize, Serialize};
use std::fmt::Display; use std::fmt::Display;
use std::str::FromStr; use std::str::FromStr;
use serde::{Deserialize, Serialize}; use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum AirportCategory { pub enum AirportCategory {
#[serde(rename = "small_airport")] #[serde(rename = "small_airport")]
Small, Small,

View File

@@ -0,0 +1,131 @@
use crate::db;
use crate::error::ApiResult;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use std::collections::HashMap;
use utoipa::ToSchema;
use uuid::Uuid;
const TABLE_NAME: &str = "communications";
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Communication {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub frequencies_mhz: Vec<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
}
#[derive(Debug, Deserialize, sqlx::FromRow)]
pub struct CommunicationRow {
pub id: Uuid,
pub icao: String,
pub frequency_id: String,
pub name: Option<String>,
pub frequencies_mhz: Vec<f32>,
pub phone: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateCommunication {
#[serde(skip_serializing_if = "Option::is_none")]
pub icao: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequencies_mhz: Option<Vec<f32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
}
impl From<CommunicationRow> for Communication {
fn from(frequency: CommunicationRow) -> Self {
Self {
id: frequency.frequency_id.clone(),
name: frequency.name.clone(),
frequencies_mhz: frequency.frequencies_mhz,
phone: frequency.phone.clone(),
}
}
}
impl Communication {
pub fn into(frequency: &Communication, icao: &str) -> CommunicationRow {
CommunicationRow {
id: Uuid::new_v4(),
icao: icao.to_string(),
frequency_id: frequency.id.clone(),
name: frequency.name.clone(),
frequencies_mhz: frequency.frequencies_mhz.clone(),
phone: frequency.phone.clone(),
}
}
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!(
r#"SELECT * FROM {} WHERE icao = ANY($1)"#,
TABLE_NAME
))
.bind(&icaos)
.fetch_all(pool)
.await?;
let mut frequency_map: HashMap<String, Vec<Self>> = HashMap::new();
for frequency_row in frequency_rows {
let icao = frequency_row.icao.clone();
let frequency = frequency_row.into();
frequency_map
.entry(icao.to_string())
.or_default()
.push(frequency);
}
Ok(frequency_map)
}
pub async fn select_all(icao: &str) -> ApiResult<Vec<Self>> {
let pool = db::pool();
let frequency_row: Vec<CommunicationRow> = sqlx::query_as(&format!(
r#"
SELECT * FROM {} WHERE icao = $1
"#,
TABLE_NAME
))
.bind(icao)
.fetch_all(pool)
.await?;
Ok(frequency_row.into_iter().map(From::from).collect())
}
pub async fn insert_all(communications: &Vec<CommunicationRow>) -> ApiResult<()> {
let pool = db::pool();
let chunk_size = 1000;
for chunk in communications.chunks(chunk_size) {
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(&format!(
"INSERT INTO {} (id, icao, frequency_id, name, frequencies_mhz, phone) ",
TABLE_NAME
));
query_builder.push_values(chunk, |mut b, row| {
b.push_bind(&row.id)
.push_bind(&row.icao)
.push_bind(&row.frequency_id)
.push_bind(&row.name)
.push_bind(&row.frequencies_mhz)
.push_bind(&row.phone);
});
let query = query_builder.build();
query.execute(pool).await?;
}
Ok(())
}
}

View File

@@ -1,115 +0,0 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use uuid::Uuid;
use crate::db;
use crate::error::ApiResult;
const TABLE_NAME: &str = "frequencies";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Frequency {
#[serde(rename = "id")]
pub frequency_id: String,
pub frequency_mhz: f32,
}
#[derive(Debug, Deserialize, sqlx::FromRow)]
pub struct FrequencyRow {
pub id: Uuid,
pub icao: String,
pub frequency_id: String,
pub frequency_mhz: f32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateFrequency {
#[serde(skip_serializing_if = "Option::is_none")]
pub icao: Option<String>,
#[serde(rename = "id", skip_serializing_if = "Option::is_none")]
pub frequency_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequency_mhz: Option<f32>,
}
impl From<FrequencyRow> for Frequency {
fn from(frequency: FrequencyRow) -> Self {
Self {
frequency_id: frequency.frequency_id.clone(),
frequency_mhz: frequency.frequency_mhz,
}
}
}
impl Frequency {
pub fn into(frequency: &Frequency, icao: &str) -> FrequencyRow {
FrequencyRow {
id: Uuid::new_v4(),
icao: icao.to_string(),
frequency_id: frequency.frequency_id.clone(),
frequency_mhz: frequency.frequency_mhz.clone(),
}
}
pub async fn select_all_map(icaos: Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
let pool = db::pool();
let frequency_rows: Vec<FrequencyRow> = sqlx::query_as(&format!(
r#"SELECT * FROM {} WHERE icao = ANY($1)"#,
TABLE_NAME
))
.bind(&icaos)
.fetch_all(pool)
.await?;
let mut frequency_map: HashMap<String, Vec<Self>> = HashMap::new();
for frequency_row in frequency_rows {
let icao = frequency_row.icao.clone();
let frequency = frequency_row.into();
frequency_map
.entry(icao.to_string())
.or_default()
.push(frequency);
}
Ok(frequency_map)
}
pub async fn select_all(icao: &str) -> ApiResult<Vec<Self>> {
let pool = db::pool();
let frequency_row: Vec<FrequencyRow> = sqlx::query_as(&format!(
r#"
SELECT * FROM {} WHERE icao = $1
"#,
TABLE_NAME
))
.bind(icao)
.fetch_all(pool)
.await?;
Ok(frequency_row.into_iter().map(From::from).collect())
}
pub async fn insert_all(frequencies: &Vec<FrequencyRow>) -> ApiResult<()> {
let pool = db::pool();
let chunk_size = 1000;
for chunk in frequencies.chunks(chunk_size) {
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(&format!(
"INSERT INTO {} (id, icao, frequency_id, frequency_mhz) ",
TABLE_NAME
));
query_builder.push_values(chunk, |mut b, row| {
b.push_bind(&row.id)
.push_bind(&row.icao)
.push_bind(&row.frequency_id)
.push_bind(&row.frequency_mhz);
});
let query = query_builder.build();
query.execute(pool).await?;
}
Ok(())
}
}

View File

@@ -1,9 +1,9 @@
mod airport; mod airport;
mod airport_category; mod airport_category;
mod frequency; mod communication;
mod runway; mod runway;
pub use airport::*; pub use airport::*;
pub use airport_category::*; pub use airport_category::*;
pub use frequency::*; pub use communication::*;
pub use runway::*; pub use runway::*;

View File

@@ -1,13 +1,14 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use uuid::Uuid;
use crate::db; use crate::db;
use crate::error::ApiResult; use crate::error::ApiResult;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use std::collections::HashMap;
use utoipa::ToSchema;
use uuid::Uuid;
const TABLE_NAME: &str = "runways"; const TABLE_NAME: &str = "runways";
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Runway { pub struct Runway {
#[serde(rename = "id")] #[serde(rename = "id")]
pub runway_id: String, pub runway_id: String,
@@ -26,7 +27,7 @@ pub struct RunwayRow {
pub surface: String, pub surface: String,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateRunway { pub struct UpdateRunway {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub icao: Option<String>, pub icao: Option<String>,
@@ -63,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 pool = db::pool();
let runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!( let runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!(

View File

@@ -1,16 +1,38 @@
use futures_util::stream::StreamExt as _; use futures_util::stream::StreamExt as _;
use crate::{
airports::Airport,
db::Paged,
account::{Auth, verify_role},
AppState,
};
use actix_multipart::Multipart;
use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError};
use crate::airports::{AirportQuery, UpdateAirport}; use crate::airports::{AirportQuery, UpdateAirport};
use crate::users::ADMIN_ROLE; use crate::users::ADMIN_ROLE;
use crate::{
account::{Auth, verify_role},
airports::Airport,
db::Paged,
};
use actix_multipart::Multipart;
use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web};
use utoipa::ToSchema;
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
#[derive(ToSchema)]
#[allow(unused)]
struct FileUpload {
#[schema(value_type = String, format = Binary)]
file: Vec<u8>,
}
#[utoipa::path(
tag = "airport",
request_body(
content = FileUpload, content_type = "multipart/form-data"
),
responses(
(status = 200, description = "Successful import"),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[post("/import")] #[post("/import")]
async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse { async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
if let Err(err) = verify_role(&auth, ADMIN_ROLE) { if let Err(err) = verify_role(&auth, ADMIN_ROLE) {
@@ -53,8 +75,17 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
HttpResponse::Ok().finish() HttpResponse::Ok().finish()
} }
#[utoipa::path(
tag = "airport",
params(
AirportQuery
),
responses(
(status = 200, description = "", body = [Airport]),
),
)]
#[get("")] #[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()) { let mut query = match web::Query::<AirportQuery>::from_query(req.query_string()) {
Ok(q) => q.into_inner(), Ok(q) => q.into_inner(),
Err(err) => { Err(err) => {
@@ -72,8 +103,7 @@ async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpRespon
query.limit = Some(limit); query.limit = Some(limit);
query.page = Some(page); query.page = Some(page);
let client = &data.client; match Airport::select_all(&query).await {
match Airport::select_all(client, &query).await {
Ok(airports) => HttpResponse::Ok().json(Paged { Ok(airports) => HttpResponse::Ok().json(Paged {
data: airports, data: airports,
page, page,
@@ -87,12 +117,15 @@ async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpRespon
} }
} }
#[utoipa::path(
tag = "airport",
responses(
(status = 200, description = "", body = Airport),
(status = 404, description = ""),
),
)]
#[get("/{icao}")] #[get("/{icao}")]
async fn get_airport( async fn get_airport(icao: web::Path<String>, req: HttpRequest) -> HttpResponse {
data: web::Data<AppState>,
icao: web::Path<String>,
req: HttpRequest,
) -> HttpResponse {
let metar = match web::Query::<AirportQuery>::from_query(req.query_string()) { let metar = match web::Query::<AirportQuery>::from_query(req.query_string()) {
Ok(q) => q.metars.unwrap_or_else(|| false), Ok(q) => q.metars.unwrap_or_else(|| false),
Err(err) => { Err(err) => {
@@ -101,13 +134,23 @@ async fn get_airport(
} }
}; };
let client = &data.client; match Airport::select(&icao.into_inner(), metar).await {
match Airport::select(client, &icao.into_inner(), metar).await {
Some(airport) => HttpResponse::Ok().json(airport), Some(airport) => HttpResponse::Ok().json(airport),
None => HttpResponse::NotFound().finish(), None => HttpResponse::NotFound().finish(),
} }
} }
#[utoipa::path(
tag = "airport",
responses(
(status = 200, description = "", body = Airport),
(status = 401, description = ""),
(status = 409, description = ""),
),
security(
("session_auth" = [])
)
)]
#[post("")] #[post("")]
async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse { async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) { let _ = match verify_role(&auth, ADMIN_ROLE) {
@@ -123,6 +166,16 @@ async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse
} }
} }
#[utoipa::path(
tag = "airport",
responses(
(status = 200, description = "", body = Airport),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[put("/{icao}")] #[put("/{icao}")]
async fn update_airport( async fn update_airport(
icao: web::Path<String>, icao: web::Path<String>,
@@ -142,6 +195,16 @@ async fn update_airport(
} }
} }
#[utoipa::path(
tag = "airport",
responses(
(status = 201, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[delete("")] #[delete("")]
async fn delete_airports(auth: Auth) -> HttpResponse { async fn delete_airports(auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) { let _ = match verify_role(&auth, ADMIN_ROLE) {
@@ -157,6 +220,16 @@ async fn delete_airports(auth: Auth) -> HttpResponse {
} }
} }
#[utoipa::path(
tag = "airport",
responses(
(status = 201, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[delete("/{icao}")] #[delete("/{icao}")]
async fn delete_airport(icao: web::Path<String>, auth: Auth) -> HttpResponse { async fn delete_airport(icao: web::Path<String>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) { let _ = match verify_role(&auth, ADMIN_ROLE) {
@@ -172,9 +245,9 @@ async fn delete_airport(icao: web::Path<String>, auth: Auth) -> HttpResponse {
} }
} }
pub fn init_routes(config: &mut web::ServiceConfig) { pub fn init_routes(config: &mut ServiceConfig) {
config.service( config.service(
web::scope("airports") scope::scope("/airports")
.service(import_airports) .service(import_airports)
.service(get_airports) .service(get_airports)
.service(get_airport) .service(get_airport)

View File

@@ -1,11 +1,11 @@
use crate::error::ApiResult; use crate::error::ApiResult;
use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection, RedisResult}; use redis::{Client as RedisClient, RedisResult, aio::MultiplexedConnection as RedisConnection};
use s3::{Bucket, Region, creds::Credentials, BucketConfiguration, request::ResponseData}; use s3::{Bucket, BucketConfiguration, Region, creds::Credentials, request::ResponseData};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions;
use sqlx::{Pool, Postgres};
use std::sync::OnceLock; use std::sync::OnceLock;
use std::time::Duration; use std::time::Duration;
use sqlx::{Pool, Postgres};
use sqlx::postgres::PgPoolOptions;
static POOL: OnceLock<Pool<Postgres>> = OnceLock::new(); static POOL: OnceLock<Pool<Postgres>> = OnceLock::new();
static REDIS: OnceLock<RedisClient> = OnceLock::new(); static REDIS: OnceLock<RedisClient> = OnceLock::new();
@@ -18,7 +18,7 @@ pub async fn initialize() -> ApiResult<()> {
let password = std::env::var("POSTGRES_PASSWORD").expect("POSTGRES_PASSWORD must be set"); let password = std::env::var("POSTGRES_PASSWORD").expect("POSTGRES_PASSWORD must be set");
let host: String = std::env::var("POSTGRES_HOST").expect("POSTGRES_HOST must be set"); let host: String = std::env::var("POSTGRES_HOST").expect("POSTGRES_HOST must be set");
let port = std::env::var("POSTGRES_PORT").unwrap_or("5432".to_string()); let port = std::env::var("POSTGRES_PORT").unwrap_or("5432".to_string());
let name = std::env::var("POSTGRES_NAME").unwrap_or("aviation".to_string()); let name = std::env::var("POSTGRES_DB").unwrap_or("aviation_db".to_string());
let db_url = format!( let db_url = format!(
"postgres://{}:{}@{}:{}/{}", "postgres://{}:{}@{}:{}/{}",
@@ -129,10 +129,10 @@ fn redis() -> &'static RedisClient {
REDIS.get().unwrap() REDIS.get().unwrap()
} }
pub fn redis_connection() -> RedisResult<redis::Connection> { // pub fn redis_connection() -> RedisResult<redis::Connection> {
let conn = redis().get_connection()?; // let conn = redis().get_connection()?;
Ok(conn) // Ok(conn)
} // }
pub async fn redis_async_connection() -> RedisResult<RedisConnection> { pub async fn redis_async_connection() -> RedisResult<RedisConnection> {
let conn = redis().get_multiplexed_async_connection().await?; let conn = redis().get_multiplexed_async_connection().await?;
@@ -169,9 +169,3 @@ pub struct Paged<T> {
pub limit: u32, pub limit: u32,
pub total: i64, pub total: i64,
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Coordinate {
pub lon: f64,
pub lat: f64,
}

View File

@@ -204,3 +204,27 @@ impl From<sqlx::migrate::MigrateError> for Error {
Error::new(500, error.to_string()) Error::new(500, error.to_string())
} }
} }
impl From<lettre::address::AddressError> for Error {
fn from(error: lettre::address::AddressError) -> Self {
Error::new(500, error.to_string())
}
}
impl From<lettre::error::Error> for Error {
fn from(error: lettre::error::Error) -> Self {
Error::new(500, error.to_string())
}
}
impl From<lettre::transport::smtp::Error> for Error {
fn from(error: lettre::transport::smtp::Error) -> Self {
Error::new(500, error.to_string())
}
}
impl From<String> for Error {
fn from(error: String) -> Self {
Self::new(500, error)
}
}

92
api/src/http_client.rs Normal file
View 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)
}
}

View File

@@ -1,52 +1,71 @@
use std::env; use crate::account::hash;
use std::time::Duration; use crate::http_client::HttpClient;
use crate::users::{ADMIN_ROLE, User};
use actix_cors::Cors; use actix_cors::Cors;
use actix_web::{App, HttpServer, middleware::Logger, web}; use actix_web::{App, HttpServer, middleware::Logger, web};
use dotenv::from_filename; use dotenv::from_filename;
use reqwest::Certificate; use std::env;
use crate::account::hash; use std::sync::Arc;
use crate::users::{User, ADMIN_ROLE}; use utoipa::openapi::SecurityRequirement;
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
use utoipa_actix_web::{AppExt, scope};
use utoipa_swagger_ui::{Config, SwaggerUi};
mod account; mod account;
mod airports; mod airports;
mod db; mod db;
mod error; mod error;
mod http_client;
mod metars; mod metars;
mod scheduler; mod scheduler;
mod smtp;
mod system; mod system;
mod users; mod users;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct AppState { struct AppState {
client: reqwest::Client, client: Arc<HttpClient>,
} }
#[actix_web::main] #[actix_web::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
initialize_environment()?; initialize_environment()?;
db::initialize().await?; 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 // Initialize admin user
let admin_username = env::var("ADMIN_USERNAME");
let admin_email = env::var("ADMIN_EMAIL"); let admin_email = env::var("ADMIN_EMAIL");
let admin_password = env::var("ADMIN_PASSWORD"); 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(); let email = admin_email.unwrap();
if User::select(&email).await.is_none() { if User::select_by_email(&email).await.is_none() {
log::debug!("Creating default administrator"); log::debug!("Creating default administrator");
let password = admin_password.unwrap(); let password = admin_password.unwrap();
let password_hash = hash(&password)?; let password_hash = hash(&password)?;
if email == "admin@example.com" || password == "CHANGEME" { if email == "admin@example.com" || password == "changeme" {
log::warn!( 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 { let admin_user = User {
email, username,
email: Some(email),
email_verified: true,
password_hash, password_hash,
role: ADMIN_ROLE.to_string(), role: ADMIN_ROLE.to_string(),
first_name: "Admin".to_string(), first_name: "Admin".to_string(),
last_name: "".to_string(), last_name: "".to_string(),
avatar: None,
updated_at: Default::default(), updated_at: Default::default(),
created_at: Default::default(), created_at: Default::default(),
}; };
@@ -59,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 state = AppState { client };
let host = "0.0.0.0"; let host = "0.0.0.0";
let port = env::var("API_PORT").unwrap_or("5000".to_string()); let port = env::var("API_PORT").unwrap_or("5000".to_string());
@@ -87,18 +89,43 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.allow_any_header() .allow_any_header()
.supports_credentials() .supports_credentials()
.max_age(3600); .max_age(3600);
App::new() let (app, mut api) = App::new()
.wrap(cors) .wrap(cors)
.wrap(Logger::default()) .wrap(Logger::default())
.app_data(web::Data::new(state.clone())) .app_data(web::Data::new(state.clone()))
.into_utoipa_app()
.service( .service(
web::scope("api") scope::scope("/api")
.configure(airports::init_routes) .configure(airports::init_routes)
.configure(metars::init_routes) .configure(metars::init_routes)
.configure(account::init_routes) .configure(account::init_routes)
.configure(users::init_routes) .configure(users::init_routes)
.configure(system::init_routes), .configure(system::init_routes),
) )
.split_for_parts();
let version = env!("CARGO_PKG_VERSION");
api.info.title = "Aviation Data".to_string();
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.to_string();
let session_scheme = SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new("session")));
let mut components = api.components.take().unwrap_or_default();
components
.security_schemes
.insert("session_auth".to_string(), session_scheme);
api.components = Some(components);
api.security = Some(vec![SecurityRequirement::default()]);
app.service(
SwaggerUi::new("/swagger/{_:.*}")
.url("/api-docs/openapi.json", api)
.config(Config::default().use_base_layout()),
)
}) })
.bind(format!("{}:{}", host, port)) .bind(format!("{}:{}", host, port))
{ {
@@ -119,8 +146,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
fn initialize_environment() -> std::io::Result<()> { fn initialize_environment() -> std::io::Result<()> {
fn init_dir(directory: &str) -> std::io::Result<()> {
// Iterate over files in the current directory // Iterate over files in the current directory
for entry in std::fs::read_dir(".")? { for entry in std::fs::read_dir(directory)? {
let entry = entry?; let entry = entry?;
let path = entry.path(); let path = entry.path();
@@ -136,6 +164,11 @@ fn initialize_environment() -> std::io::Result<()> {
} }
} }
} }
Ok(())
}
init_dir("..")?;
init_dir(".")?;
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,api=info")); env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,api=info"));
Ok(()) Ok(())

View File

@@ -0,0 +1,66 @@
use crate::db::redis_async_connection;
use crate::error::ApiResult;
use crate::metars::Metar;
use chrono::{DateTime, Utc};
use redis::{AsyncCommands, RedisResult};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct MetarCheck {
pub icao: String,
pub status: bool,
pub updated_at: DateTime<Utc>,
pub last_metar: Option<Metar>,
}
impl MetarCheck {
pub async fn new(icao: String, status: bool) -> Self {
match Self::get(&icao).await {
Some(c) => Self {
icao,
status,
updated_at: Utc::now(),
last_metar: c.last_metar,
},
None => Self {
icao,
status,
updated_at: Utc::now(),
last_metar: None,
},
}
}
pub async fn get(icao: &str) -> Option<MetarCheck> {
let mut conn = match redis_async_connection().await {
Ok(conn) => conn,
Err(err) => {
log::error!("Unable to get connection for ICAO {}: {}", icao, err);
return None;
}
};
let result: RedisResult<Option<String>> = conn.get(icao).await;
match result {
Ok(Some(value)) => match serde_json::from_str(&value) {
Ok(result) => Some(result),
Err(err) => {
log::error!("Unable to get MetarCheck for ICAO {}: {}", icao, err);
None
}
},
Ok(None) => None,
Err(err) => {
log::error!("Error getting MetarCheck for ICAO {}: {}", icao, err);
None
}
}
}
pub async fn insert(&self) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let value = serde_json::to_string(&self)?;
conn.set::<_, _, ()>(self.icao.as_str(), value).await?;
Ok(())
}
}

View File

@@ -1,5 +1,7 @@
mod metar_check;
mod model; mod model;
mod routes; mod routes;
pub use metar_check::*;
pub use model::*; pub use model::*;
pub use routes::init_routes; pub use routes::init_routes;

View File

@@ -1,18 +1,35 @@
use crate::airports::{Airport, UpdateAirport};
use crate::error::Error; use crate::error::Error;
use crate::{error::ApiResult, db}; use crate::http_client::HttpClient;
use chrono::{DateTime, Datelike, Utc}; use crate::metars::MetarCheck;
use crate::{db, error::ApiResult};
use chrono::{DateTime, Datelike, NaiveDate, Utc};
use flate2::read::GzDecoder;
use reqwest::header::ETAG;
use serde::{Deserialize, Serialize};
use std::collections::HashSet; use std::collections::HashSet;
use std::env; use std::env;
use std::fmt::Display; use std::fmt::Display;
use std::io::{Cursor, Read};
use std::str::FromStr; use std::str::FromStr;
use redis::{AsyncCommands, RedisResult}; use std::sync::OnceLock;
use reqwest::Client; use utoipa::ToSchema;
use serde::{Deserialize, Serialize};
use crate::db::redis_async_connection; static TIME_OFFSET: OnceLock<i64> = OnceLock::new();
const TABLE_NAME: &str = "metars"; const TABLE_NAME: &str = "metars";
const DEFAULT_REFRESH_DURATION: i64 = 3000;
#[derive(Serialize, Deserialize, Debug)] 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 { pub struct Metar {
pub icao: String, pub icao: String,
pub raw_text: String, pub raw_text: String,
@@ -58,12 +75,12 @@ pub struct Metar {
pub density_altitude: Option<f64>, pub density_altitude: Option<f64>,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum ReportModifier { pub enum ReportModifier {
#[serde(rename = "AUTO")] #[serde(rename = "AUTO")]
Auto, Auto,
#[serde(rename = "COR")] #[serde(rename = "COR")]
Corrected Corrected,
} }
impl FromStr for ReportModifier { impl FromStr for ReportModifier {
@@ -72,7 +89,7 @@ impl FromStr for ReportModifier {
match s { match s {
"AUTO" => Ok(ReportModifier::Auto), "AUTO" => Ok(ReportModifier::Auto),
"COR" => Ok(ReportModifier::Corrected), "COR" => Ok(ReportModifier::Corrected),
_ => Err(Error::new(400, format!("Invalid report modifier '{}'", s))) _ => Err(Error::new(400, format!("Invalid report modifier '{}'", s))),
} }
} }
} }
@@ -86,7 +103,7 @@ impl Display for ReportModifier {
} }
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RunwayVisualRange { pub struct RunwayVisualRange {
pub runway: String, pub runway: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -108,7 +125,7 @@ impl Default for RunwayVisualRange {
} }
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum AutomatedStationType { pub enum AutomatedStationType {
#[serde(rename = "AO1")] #[serde(rename = "AO1")]
WithoutPrecipitationDiscriminator, WithoutPrecipitationDiscriminator,
@@ -122,7 +139,10 @@ impl FromStr for AutomatedStationType {
match s { match s {
"AO1" => Ok(AutomatedStationType::WithoutPrecipitationDiscriminator), "AO1" => Ok(AutomatedStationType::WithoutPrecipitationDiscriminator),
"AO2" => Ok(AutomatedStationType::WithPrecipitationDiscriminator), "AO2" => Ok(AutomatedStationType::WithPrecipitationDiscriminator),
_ => Err(Error::new(400, format!("Invalid automated station type '{}'", s))) _ => Err(Error::new(
400,
format!("Invalid automated station type '{}'", s),
)),
} }
} }
} }
@@ -136,7 +156,7 @@ impl Display for AutomatedStationType {
} }
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Remarks { pub struct Remarks {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub peak_wind: Option<PeakWind>, pub peak_wind: Option<PeakWind>,
@@ -160,7 +180,7 @@ pub struct Remarks {
pub sky_condition_at_secondary_location_not_available: Option<String>, pub sky_condition_at_secondary_location_not_available: Option<String>,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PeakWind { pub struct PeakWind {
pub degrees: i32, pub degrees: i32,
pub speed: i32, pub speed: i32,
@@ -185,7 +205,7 @@ impl Default for Remarks {
} }
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SkyCondition { pub struct SkyCondition {
pub sky_cover: String, pub sky_cover: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -204,7 +224,7 @@ impl Default for SkyCondition {
} }
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum FlightCategory { pub enum FlightCategory {
VFR, VFR,
MVFR, MVFR,
@@ -286,9 +306,9 @@ impl MetarRow {
impl Metar { impl Metar {
fn parse_multiple(metar_strings: &Vec<&str>) -> ApiResult<Vec<Self>> { 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 { for metar_string in metar_strings {
match Metar::parse(metar_string) { match Self::parse(metar_string) {
Ok(metar) => metars.push(metar), Ok(metar) => metars.push(metar),
Err(e) => { Err(e) => {
log::warn!("Failed to parse metar string: {}", e); log::warn!("Failed to parse metar string: {}", e);
@@ -309,7 +329,7 @@ impl Metar {
} }
log::trace!("Parsing METAR data: {}", metar_string); 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(); metar.raw_text = metar_string.to_owned();
let mut metar_parts: Vec<&str> = metar_string.split_whitespace().collect(); let mut metar_parts: Vec<&str> = metar_string.split_whitespace().collect();
if metar_parts.len() < 4 { if metar_parts.len() < 4 {
@@ -334,49 +354,7 @@ impl Metar {
// Date/Time // Date/Time
let observation_time = metar_parts[0]; let observation_time = metar_parts[0];
metar_parts.remove(0); metar_parts.remove(0);
if observation_time.len() != 7 { let observation_time = Self::parse_time(observation_time)?;
return Err(Error::new(
500,
format!(
"Unable to parse observation time in {}: {}",
observation_time, metar_string
),
));
}
let observation_time_day = match observation_time[0..2].parse::<u32>() {
Ok(day) => day,
Err(err) => return Err(err.into()),
};
let observation_time_hour = match observation_time[2..4].parse::<u32>() {
Ok(hour) => hour,
Err(err) => return Err(err.into()),
};
let observation_time_minute = match observation_time[4..6].parse::<u32>() {
Ok(minute) => minute,
Err(err) => return Err(err.into()),
};
let current_time = Utc::now().naive_utc();
// Check if the observation time is from the previous month
let observation_time_month = if current_time.day() > observation_time_day {
current_time.month() - 1
} else {
current_time.month()
};
// Check if the observation time is from the previous year
let observation_time_year = if current_time.month() > observation_time_month {
current_time.year() - 1
} else {
current_time.year()
};
let observation_time = format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:00Z",
observation_time_year,
observation_time_month,
observation_time_day,
observation_time_hour,
observation_time_minute
);
metar.observation_time = match chrono::DateTime::parse_from_rfc3339(&observation_time) { metar.observation_time = match chrono::DateTime::parse_from_rfc3339(&observation_time) {
Ok(datetime) => datetime.with_timezone(&Utc), Ok(datetime) => datetime.with_timezone(&Utc),
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
@@ -469,28 +447,19 @@ impl Metar {
let visibility: String = if visibility_str.contains("/") { let visibility: String = if visibility_str.contains("/") {
let visibility_parts: Vec<&str> = visibility_str.split("/").collect(); let visibility_parts: Vec<&str> = visibility_str.split("/").collect();
let visibility_left = visibility_parts[0]; let visibility_left = visibility_parts[0];
let visibility_right = visibility_parts[1].parse::<f64>().unwrap(); let visibility_right = visibility_parts[1].parse::<f64>()?;
if visibility_left.starts_with("M") { if visibility_left.starts_with("M") {
format!( format!(
"M{}", "M{}",
visibility_left[1..visibility_left.len()] visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right
.parse::<f64>()
.unwrap()
/ visibility_right
) )
} else if visibility_left.starts_with("P") { } else if visibility_left.starts_with("P") {
format!( format!(
"P{}", "P{}",
visibility_left[1..visibility_left.len()] visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right
.parse::<f64>()
.unwrap()
/ visibility_right
) )
} else { } else {
format!( format!("{}", visibility_left.parse::<f64>()? / visibility_right)
"{}",
visibility_left.parse::<f64>().unwrap() / visibility_right
)
} }
} else { } else {
visibility_str.to_string() visibility_str.to_string()
@@ -501,30 +470,39 @@ impl Metar {
&& metar_parts.len() > 1 && metar_parts.len() > 1
&& visibility_re.is_match(metar_parts[1]) && visibility_re.is_match(metar_parts[1])
{ {
let visibility_whole = metar_parts[0].parse::<f64>().unwrap(); let visibility_whole = metar_parts[0].parse::<f64>()?;
metar_parts.remove(0); metar_parts.remove(0);
let visibility_parts: Vec<&str> = metar_parts[0].split("/").collect(); let visibility_parts: Vec<&str> = metar_parts[0].split("/").collect();
metar_parts.remove(0); metar_parts.remove(0);
let visibility_left = visibility_parts[0]; let visibility_left = visibility_parts[0];
let visibility_right = visibility_parts[1][0..visibility_parts[1].len() - 2] // Parse the right-hand of visibility, with or without an SM suffix
.parse::<f64>() let visibility_right_string = match visibility_parts[1].strip_suffix("SM") {
.unwrap(); 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") { let visibility = if visibility_left.starts_with("M") {
format!( format!(
"M{}", "M{}",
visibility_whole visibility_whole
+ (visibility_left[1..visibility_left.len()] + (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
.parse::<f64>()
.unwrap()
/ visibility_right)
) )
} else if visibility_left.starts_with("P") { } else if visibility_left.starts_with("P") {
format!( format!(
"P{}", "P{}",
visibility_whole visibility_whole
+ (visibility_left[1..visibility_left.len()] + (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
.parse::<f64>()?
/ visibility_right)
) )
} else { } else {
format!( format!(
@@ -599,11 +577,16 @@ impl Metar {
metar_parts.remove(0); metar_parts.remove(0);
} }
let sky_condition_re = 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(); .unwrap();
while !metar_parts.is_empty() && sky_condition_re.is_match(metar_parts[0]) { 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); 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 sky_condition = SkyCondition::default();
let mut vv_offset = 0; let mut vv_offset = 0;
if &sky_condition_string[0..2] == "VV" { if &sky_condition_string[0..2] == "VV" {
@@ -886,56 +869,178 @@ impl Metar {
// let estimated_density = ; // let estimated_density = ;
// metar.density_altitude = Some(metar.density_altitude); // metar.density_altitude = Some(metar.density_altitude);
// Update the airport's metar observation time
let icao = metar.icao.clone();
let observation_time = metar.observation_time.clone();
tokio::spawn(async move {
match Airport::update(
&icao,
&UpdateAirport {
icao: None,
iata: None,
local: None,
name: None,
category: None,
iso_country: None,
iso_region: None,
municipality: None,
elevation_ft: None,
longitude: None,
latitude: None,
has_tower: None,
has_beacon: None,
runways: None,
communications: None,
public: None,
latest_metar_observation: Some(observation_time),
},
)
.await
{
Ok(_) => {}
Err(err) => log::error!(
"Unable to update airport {} with the latest observation time: {}",
icao,
err
),
};
});
Ok(metar) Ok(metar)
} }
async fn get_missing_metar_icaos( fn parse_time(observation_time: &str) -> ApiResult<String> {
db_metars: &Vec<Self>, if observation_time.len() != 7 {
station_icaos: &Vec<String>, return Err(Error::new(
) -> Vec<String> { 500,
let mut missing_metar_icaos: Vec<String> = vec![]; format!("Unable to parse observation time in {}", observation_time),
let current_time = chrono::Local::now().naive_local().and_utc().timestamp(); ));
let db_metars_set: HashSet<&str> = db_metars
.iter()
.map(|icao| icao.icao.as_str())
.collect();
let station_icaos_set: HashSet<&str> = station_icaos.iter().map(|s| s.as_str()).collect();
for difference in db_metars_set.symmetric_difference(&station_icaos_set) {
missing_metar_icaos.push(difference.to_string());
} }
let time_offset = env::var("API_METAR_TIME_OFFSET") let observation_day = match observation_time[0..2].parse::<u32>() {
.unwrap_or("3000".to_string()) Ok(day) => day,
.parse::<i64>() Err(err) => return Err(err.into()),
.unwrap_or(3000); };
for metar in db_metars { let observation_hour = match observation_time[2..4].parse::<u32>() {
if current_time > (metar.observation_time.timestamp() + time_offset) { Ok(hour) => hour,
log::trace!("{} METAR data is outdated", metar.icao); Err(err) => return Err(err.into()),
missing_metar_icaos.push(metar.icao.to_string()); };
let observation_minute = match observation_time[4..6].parse::<u32>() {
Ok(minute) => minute,
Err(err) => return Err(err.into()),
};
let current_time = Utc::now().naive_utc();
let current_year = current_time.year();
let current_month = current_time.month();
let candidate_date = NaiveDate::from_ymd_opt(current_year, current_month, observation_day)
.ok_or_else(|| {
Error::new(
500,
format!(
"Invalid date with day {} for current month",
observation_day
),
)
})?;
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
),
));
} }
} };
missing_metar_icaos
let obs_datetime = if candidate_date > current_time {
// Subtract one month. (Handle year rollover carefully.)
let (month, year) = if current_month == 1 {
(12, current_year - 1)
} else {
(current_month - 1, current_year)
};
let adjusted_date =
NaiveDate::from_ymd_opt(year, month, observation_day).ok_or_else(|| {
Error::new(
500,
format!(
"Invalid date with day {} for month {}",
observation_day, month
),
)
})?;
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: &[&str]) -> ApiResult<Vec<Metar>> { pub async fn get_cached_remote_metars(
let base_url = std::env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set"); 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 // Query the remote API for the missing METAR data 10 at a time
let icao_chunks = icaos let icao_chunks = icaos
.chunks(10) .chunks(10)
.map(|chunk| chunk.join(",")) .map(|chunk| chunk.join(","))
.collect::<Vec<String>>(); .collect::<Vec<String>>();
let mut metars: Vec<Metar> = vec![]; let mut metars: Vec<Self> = vec![];
for icao_chunk in icao_chunks { for icao_chunk in icao_chunks {
let url = format!("{}/metar?ids={}&order=id", base_url, icao_chunk); let url = format!(
let mut m = match client.get(url).send().await { "{}/api/data/metar?ids={}&hours=0&order=id,-obs",
Ok(r) => { base_url, icao_chunk
// Check if the status code is 200 );
if r.status() != 200 { let mut m = match client.get(&url, None).await {
return Err(Error::new( Ok(r) => match r.text().await {
500,
format!("Request returned status {}", r.status()),
));
}
match r.text().await {
Ok(r) => { Ok(r) => {
let metar_chunk = r let metar_chunk = r
.trim() .trim()
@@ -948,8 +1053,7 @@ impl Metar {
} }
} }
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()), Err(err) => return Err(err.into()),
}; };
metars.append(&mut m); metars.append(&mut m);
@@ -957,108 +1061,171 @@ impl Metar {
Ok(metars) Ok(metars)
} }
fn from_db(metar_db: MetarRow) -> ApiResult<Metar> { fn from_row(row: MetarRow) -> ApiResult<Self> {
let metar: Metar = serde_json::from_value(metar_db.data)?; let metar: Self = serde_json::from_value(row.data)?;
Ok(metar) Ok(metar)
} }
fn to_db(&self) -> ApiResult<MetarRow> { fn to_row(&self) -> ApiResult<MetarRow> {
let data = serde_json::to_value(self)?; let data = serde_json::to_value(self)?;
Ok(MetarRow { Ok(MetarRow {
icao: self.icao.clone(), icao: self.icao.to_uppercase(),
observation_time: self.observation_time, observation_time: self.observation_time,
raw_text: self.raw_text.clone(), raw_text: self.raw_text.clone(),
data, data,
}) })
} }
pub async fn find_all( pub async fn get_all_distinct(icao_list: &Vec<String>) -> ApiResult<Vec<Self>> {
client: &Client,
icao_list: &Vec<String>,
force: &bool,
) -> ApiResult<Vec<Self>> {
if icao_list.is_empty() { if icao_list.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let mut metars: Vec<Metar> = vec![];
if !*force {
let pool = db::pool(); let pool = db::pool();
let metar_rows: Vec<MetarRow> = sqlx::query_as::<_, MetarRow>(&format!( let metar_rows: Vec<MetarRow> = sqlx::query_as::<_, MetarRow>(&format!(
r#" r#"
SELECT DISTINCT ON (icao) * FROM {} WHERE icao = ANY($1) ORDER BY icao, observation_time DESC SELECT DISTINCT ON (icao) * FROM {}
WHERE icao = ANY($1)
ORDER BY icao, observation_time DESC
"#, "#,
TABLE_NAME TABLE_NAME
)) ))
.bind(icao_list) .bind(icao_list)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
metars = metar_rows let mut metars = vec![];
.into_iter() for metar_row in metar_rows {
.filter_map(|metar_db| Metar::from_db(metar_db).ok()) metars.push(Self::from_row(metar_row)?)
.collect(); }
Ok(metars)
} }
let mut conn = redis_async_connection().await?; pub async fn get_or_update_metars(
// Check for missing metars client: &HttpClient,
let missing_icao_list = Self::get_missing_metar_icaos(&metars, icao_list).await; icaos: &Vec<String>,
if !missing_icao_list.is_empty() { ) -> ApiResult<Vec<Self>> {
let mut updated_missing_icao_list: Vec<&str> = Vec::new(); let metars = Self::get_all_distinct(&icaos).await?;
for icao in &missing_icao_list { let current_time = Utc::now().timestamp();
if *force {
updated_missing_icao_list.push(icao); let mut updated_metars: Vec<Self> = vec![];
} else { let mut missing_metar_icaos: Vec<String> = vec![];
let result: RedisResult<Option<bool>> = conn.get(icao).await; let mut found_metar_icaos: HashSet<String> = HashSet::new();
match result { let mut requested_icaos: HashSet<String> = HashSet::from_iter(icaos.clone());
Ok(Some(value)) => {
if value { for metar in metars {
updated_missing_icao_list.push(icao); let icao = metar.icao.clone();
// Remove found icao from requested ICAOs
requested_icaos.remove(&icao);
// 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 => DEFAULT_REFRESH_DURATION,
};
// 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 the outdated data (to be checked on the next cycle)
else {
log::trace!(
"{} METAR data is outdated; refreshing in {} seconds",
&icao,
DEFAULT_REFRESH_DURATION - refresh_seconds
);
updated_metars.push(metar);
} }
} }
Ok(None) => { // Otherwise add the valid metar to the updated list
updated_missing_icao_list.push(icao); else {
found_metar_icaos.insert(icao.clone());
let metar_check = MetarCheck::new(icao, true).await;
metar_check.insert().await?;
updated_metars.push(metar);
} }
Err(err) => return Err(err.into()), }
// 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() + DEFAULT_REFRESH_DURATION) {
missing_metar_icaos.push(icao.to_string());
}
}
None => {
missing_metar_icaos.push(icao.to_string());
} }
} }
} }
if !updated_missing_icao_list.is_empty() {
// Retrieve missing METARs
if !missing_metar_icaos.is_empty() {
log::trace!( log::trace!(
"Retrieving missing METAR data for {:?}", "Retrieving missing METAR data for {:?}",
updated_missing_icao_list missing_metar_icaos
); );
let mut missing_icao_list = Self::get_remote_metars(client, &updated_missing_icao_list) let mut remote_metars = Self::get_remote_metars(client, &missing_metar_icaos)
.await .await
.unwrap_or_else(|err| { .unwrap_or_else(|err| {
log::warn!("Unable to get remote METAR data; {}", err); log::warn!("Unable to get remote METAR data; {}", err);
vec![] vec![]
}); });
if missing_icao_list.len() > 0 {
// Insert missing METARs // Insert missing METARs
for missing_metar in &missing_icao_list { if remote_metars.len() > 0 {
let _: RedisResult<()> = conn.set(&missing_metar.icao, true).await; for remote_metar in remote_metars.clone() {
missing_metar.insert().await?; 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().await?;
} }
metars.append(&mut missing_icao_list) updated_metars.append(&mut remote_metars);
} }
// Invalidate the still missing icaos // Update still missing METARs
let still_missing_icao_list = for difference in found_metar_icaos.symmetric_difference(&requested_icaos) {
Self::get_missing_metar_icaos(&missing_icao_list, icao_list).await; let metar_check = MetarCheck::new(difference.to_string(), false).await;
if !still_missing_icao_list.is_empty() { metar_check.insert().await?;
for icao in still_missing_icao_list { // Only add cached metar data if it's less than 4 hours old
let _: RedisResult<()> = conn.set_ex(&icao, false, 3600).await; 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 {
updated_metars.push(last_metar);
} }
} }
} }
} }
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<()> { pub async fn insert(&self) -> ApiResult<()> {
let metar: MetarRow = self.to_db()?; log::trace!(
"Inserting metar {} with observation time {}",
self.icao,
self.observation_time
);
let metar: MetarRow = self.to_row()?;
metar.insert().await?; metar.insert().await?;
Ok(()) Ok(())
} }
@@ -1067,25 +1234,66 @@ impl Metar {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::NaiveDateTime;
#[test] #[test]
fn test_metar() { fn test_parse_time() {
for day in 1..=31 {
for hour in 0..24 {
for minute in 0..60 {
// METAR form "DDHHMMZ"
let obs_time = format!("{:02}{:02}{:02}Z", day, hour, minute);
let result = Metar::parse_time(&obs_time);
match result {
Ok(datetime_str) => {
// "YYYY-MM-DDTHH:MM:00Z"
assert_eq!(
datetime_str.len(),
20,
"Unexpected length for input {} yielded {}",
obs_time,
datetime_str
);
// Remove the trailing 'Z' and parse
let trimmed = &datetime_str[..19];
NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S").unwrap_or_else(|e| {
panic!(
"Parsing '{}' from input {} failed: {}",
trimmed, obs_time, e
)
});
}
Err(_err) => {}
}
}
}
}
}
#[tokio::test]
async fn test_metar() {
let mut metar_string = "METAR KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT -RA BR BKN015 OVC025 06/04 A2990 let mut metar_string = "METAR KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT -RA BR BKN015 OVC025 06/04 A2990
RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 RWY11 RAB07 CIG 013V017 CIG 017 RWY11 PRESFR RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 RWY11 RAB07 CIG 013V017 CIG 017 RWY11 PRESFR
SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $".to_string(); SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $".to_string();
let metar = Metar::parse(&metar_string).unwrap(); let metar = Metar::parse(&metar_string).unwrap();
// dbg!(&metar); dbg!(&metar.observation_time);
metar_string = "KMIA 090053Z 33004KT 10SM FEW015 FEW024 SCT075 SCT250 25/22 A2990 RMK AO2 SLP126 T02500217 $".to_string(); metar_string = "KMIA 090053Z 33004KT 10SM FEW015 FEW024 SCT075 SCT250 25/22 A2990 RMK AO2 SLP126 T02500217 $".to_string();
let metar = Metar::parse(&metar_string).unwrap(); let metar = Metar::parse(&metar_string).unwrap();
dbg!(&metar); dbg!(&metar.observation_time);
metar_string = metar_string =
"KMRB 082253Z 30014G23KT 10SM CLR 05/M12 A3002 RMK AO2 PK WND 30028/2157 SLP168 T00501117" "KMRB 082253Z 30014G23KT 10SM CLR 05/M12 A3002 RMK AO2 PK WND 30028/2157 SLP168 T00501117"
.to_string(); .to_string();
let metar = Metar::parse(&metar_string).unwrap(); let metar = Metar::parse(&metar_string).unwrap();
// dbg!(&metar); dbg!(&metar.observation_time);
// metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 10133 20078 53002 PNO $".to_string(); metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 10133 20078 53002 PNO $".to_string();
let metar = Metar::parse(&metar_string).unwrap();
dbg!(&metar.observation_time);
metar_string = "KSLK 162351Z AUTO VRB03KT 1SM -SN BR FEW007 OVC014 00/M02 A2974 RMK AO2 SLP090 P0001 60004 T00001017 10000 21011 53026".to_string();
let metar = Metar::parse(&metar_string).unwrap();
dbg!(&metar.observation_time);
} }
} }

View File

@@ -1,28 +1,43 @@
use crate::metars::Metar;
use actix_web::{get, web, HttpResponse, HttpRequest};
use log::error;
use serde::{Deserialize, Serialize};
use crate::AppState; use crate::AppState;
use crate::account::Auth;
use crate::metars::Metar;
use actix_web::{HttpRequest, HttpResponse, get, put, web};
use log::error;
use serde::Deserialize;
use utoipa::{IntoParams, ToSchema};
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
struct FindAllParameters { #[into_params(parameter_in = Query)]
struct MetarQuery {
icaos: Option<String>, icaos: Option<String>,
force: Option<bool>,
} }
#[get("metars")] #[utoipa::path(
async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse { tag = "metar",
let parameters = web::Query::<FindAllParameters>::from_query(req.query_string()).unwrap(); params(
MetarQuery,
),
responses(
(status = 200, description = "Successful Response", body = [Metar]),
),
)]
#[get("")]
async fn find_all(req: HttpRequest) -> HttpResponse {
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
let icao_option = &parameters.icaos; let icao_option = &parameters.icaos;
if let None = icao_option {
let empty_metars: Vec<Metar> = vec![];
return HttpResponse::Ok().json(empty_metars);
}
let icao_string = match icao_option { let icao_string = match icao_option {
Some(i) => i, Some(i) => i,
None => return HttpResponse::UnprocessableEntity().body("Missing icaos parameter"), 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 force = &parameters.force.unwrap_or(false);
let client = &data.client; let metars = match Metar::get_all_distinct(&icaos).await {
let metars = match Metar::find_all(client, &icaos, force).await {
Ok(a) => a, Ok(a) => a,
Err(err) => { Err(err) => {
error!("{}", err); error!("{}", err);
@@ -32,6 +47,48 @@ async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
HttpResponse::Ok().json(metars) HttpResponse::Ok().json(metars)
} }
pub fn init_routes(config: &mut web::ServiceConfig) { #[utoipa::path(
config.service(find_all); 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 = &parameters.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);
return err.to_http_response();
}
};
HttpResponse::Ok().json(metars)
}
pub fn init_routes(config: &mut ServiceConfig) {
config.service(
scope::scope("/metars")
.service(find_all)
.service(refresh_metars),
);
} }

View File

@@ -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}; pub fn update_metars(client: Arc<HttpClient>, seconds: u64) {
// use crate::metars::Metar; tokio::spawn(async move {
// Create interval ticker
let mut interval = interval(Duration::from_secs(seconds));
let mut etag = None;
pub fn update_airports() { loop {
// tokio::spawn(async { interval.tick().await;
// let mut airports: Vec<AirportDb> = vec![];
// let limit = 100; // Record start times
// loop { let start_monotonic = Instant::now();
// log::debug!("METAR update start"); let start_utc: DateTime<Utc> = Utc::now();
// let total = match AirportDb::count(&AirportFilter::default()).await { log::debug!("METAR update started at {}", start_utc);
// Ok(t) => t,
// Err(err) => { // Run the update
// log::warn!("{}", err); match Metar::update_metars(&client, etag.clone()).await {
// break; Ok(new_etag) => etag = Some(new_etag),
// } Err(err) => log::error!("METAR update failed: {}", err),
// }; }
// if total != airports.len() as i64 {
// log::debug!("{} cached airports, expected {}", airports.len(), total); let elapsed = start_monotonic.elapsed();
// airports = vec![]; let next_utc = Utc::now() + chrono::Duration::from_std(Duration::from_secs(seconds)).unwrap();
// let pages = ((total as f32) / (if limit <= 0 { 1 } else { limit } as f32)).ceil() as i32; log::info!(
// for page in 1..(pages + 1) { "METAR update finished in {:.2?}; next run at {}",
// match AirportDb::find_all(&AirportFilter::default(), limit, page).await { elapsed,
// Ok(mut a) => airports.append(&mut a), next_utc
// 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;
// }
// });
} }

76
api/src/smtp/mod.rs Normal file
View File

@@ -0,0 +1,76 @@
use crate::error::ApiResult;
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, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use std::env;
use std::sync::OnceLock;
use std::time::Duration;
static MAILER: OnceLock<AsyncSmtpTransport<Tokio1Executor>> = OnceLock::new();
static FROM_ADDRESS: OnceLock<Mailbox> = OnceLock::new();
static REGISTRY: OnceLock<Handlebars> = OnceLock::new();
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);
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()
})
}
fn from_address() -> &'static Mailbox {
FROM_ADDRESS.get_or_init(|| {
let raw = env::var("SMTP_FROM").expect("SMTP_FROM missing");
let addr = raw.parse().expect("SMTP_FROM invalid");
Mailbox::new(Some("Aviation Data".into()), addr)
})
}
pub fn registry() -> &'static Handlebars<'static> {
REGISTRY.get_or_init(|| Handlebars::new())
}
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);
// Build the email
let email = Message::builder()
.from(from_address().clone())
.to(to_mailbox)
.subject(subject)
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(header),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html),
),
)?;
// Send the email
mailer().send(email).await?;
Ok(())
}

View File

@@ -1,29 +1,34 @@
use std::env; use actix_web::{HttpResponse, get};
use actix_web::{get, web, HttpResponse};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::env;
use utoipa::ToSchema;
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SystemInfo { pub struct SystemInfo {
version: String, version: String,
healthy: bool, healthy: bool,
} }
#[utoipa::path(
tag = "system",
responses(
(status = 200, description = "Successful system info"),
)
)]
#[get("/info")] #[get("/info")]
async fn info() -> HttpResponse { async fn info() -> HttpResponse {
let mut healthy = true; let healthy = true;
let version = match env::var("CARGO_PKG_VERSION") { let version = env!("CARGO_PKG_VERSION");
Ok(v) => v, let info = SystemInfo {
Err(_) => { version: version.to_string(),
healthy = false; healthy,
String::from("unknown")
}
}; };
let info = SystemInfo { version, healthy };
HttpResponse::Ok().json(info) HttpResponse::Ok().json(info)
} }
pub fn init_routes(config: &mut web::ServiceConfig) { pub fn init_routes(config: &mut ServiceConfig) {
config.service(web::scope("/system").service(info)); config.service(scope::scope("/system").service(info));
} }

View File

@@ -1,18 +1,35 @@
use crate::db;
use crate::{account::hash, error::ApiResult};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[allow(unused_imports)] // Import is used in schema examples
use serde_json::json;
use sqlx::{Postgres, QueryBuilder}; use sqlx::{Postgres, QueryBuilder};
use crate::{account::hash, error::ApiResult}; use utoipa::ToSchema;
use crate::db;
pub const ADMIN_ROLE: &str = "ADMIN"; pub const ADMIN_ROLE: &str = "ADMIN";
pub const USER_ROLE: &str = "USER"; pub const USER_ROLE: &str = "USER";
const TABLE_NAME: &str = "users"; const TABLE_NAME: &str = "users";
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
#[schema(
example = json!(
{
"email": "user",
"email": "user@example.com",
"password": "changeme",
"firstName": "firstname",
"lastName": "lastname"
}
)
)]
pub struct RegisterRequest { pub struct RegisterRequest {
pub email: String, pub username: String,
pub email: Option<String>,
pub password: String, pub password: String,
#[serde(rename = "firstName")]
pub first_name: String, pub first_name: String,
#[serde(rename = "lastName")]
pub last_name: String, pub last_name: String,
} }
@@ -20,53 +37,77 @@ impl RegisterRequest {
pub fn to_user(self) -> ApiResult<User> { pub fn to_user(self) -> ApiResult<User> {
let password_hash = hash(&self.password)?; let password_hash = hash(&self.password)?;
Ok(User { Ok(User {
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, password_hash,
role: USER_ROLE.to_string(), role: USER_ROLE.to_string(),
first_name: self.first_name, first_name: self.first_name,
last_name: self.last_name, last_name: self.last_name,
avatar: None,
updated_at: Utc::now(), updated_at: Utc::now(),
created_at: Utc::now(), created_at: Utc::now(),
}) })
} }
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
#[schema(
example = json!(
{
"username": "admin",
"password": "changeme"
}
)
)]
pub struct LoginRequest { pub struct LoginRequest {
pub email: String, pub username: String,
pub password: String, pub password: String,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct UserResponse { pub struct UserResponse {
pub email: String, pub username: String,
pub role: String, pub role: String,
#[serde(rename = "firstName")]
pub first_name: String, pub first_name: String,
#[serde(rename = "lastName")]
pub last_name: String, 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 { impl From<User> for UserResponse {
fn from(user: User) -> Self { fn from(user: User) -> Self {
UserResponse { UserResponse {
email: user.email, username: user.username,
email_verified: user.email_verified,
role: user.role, role: user.role,
first_name: user.first_name, first_name: user.first_name,
last_name: user.last_name, last_name: user.last_name,
avatar: user.avatar,
} }
} }
} }
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Deserialize, sqlx::FromRow, ToSchema)]
pub struct UpdateUser { pub struct UpdateUser {
pub email: Option<String>, pub email: Option<String>,
pub email_verified: Option<bool>,
pub password: Option<String>, pub password: Option<String>,
pub role: Option<String>, pub role: Option<String>,
pub first_name: Option<String>, pub first_name: Option<String>,
pub last_name: Option<String>, pub last_name: Option<String>,
pub avatar: Option<String>,
} }
impl UpdateUser { impl UpdateUser {
pub async fn update(&self, email: &str) -> ApiResult<User> { pub async fn update(&self, username: &str) -> ApiResult<User> {
let pool = db::pool(); let pool = db::pool();
let mut query_builder: QueryBuilder<Postgres> = let mut query_builder: QueryBuilder<Postgres> =
@@ -87,6 +128,11 @@ impl UpdateUser {
query_builder.push("email = "); query_builder.push("email = ");
query_builder.push_bind(email); query_builder.push_bind(email);
} }
if let Some(ref email_verified) = self.email_verified {
push_comma(&mut query_builder);
query_builder.push("email_verified = ");
query_builder.push_bind(email_verified);
}
if let Some(ref password) = self.password { if let Some(ref password) = self.password {
push_comma(&mut query_builder); push_comma(&mut query_builder);
let password_hash = hash(password)?; let password_hash = hash(password)?;
@@ -108,16 +154,19 @@ impl UpdateUser {
query_builder.push("last_name = "); query_builder.push("last_name = ");
query_builder.push_bind(last_name); query_builder.push_bind(last_name);
} }
if let Some(ref avatar) = self.avatar {
push_comma(&mut query_builder);
query_builder.push("avatar = ");
query_builder.push_bind(avatar);
}
push_comma(&mut query_builder); push_comma(&mut query_builder);
query_builder.push("updated_at = "); query_builder.push("updated_at = ");
query_builder.push_bind(Utc::now()); query_builder.push_bind(Utc::now());
query_builder.push(" WHERE email = "); query_builder.push(" WHERE username = ");
query_builder.push_bind(email.to_string()); query_builder.push_bind(username);
query_builder.push(" RETURNING *"); query_builder.push(" RETURNING *");
dbg!(&query_builder.sql());
let query = query_builder.build_query_as::<User>(); let query = query_builder.build_query_as::<User>();
let user = query.fetch_one(pool).await?; let user = query.fetch_one(pool).await?;
@@ -125,37 +174,60 @@ impl UpdateUser {
} }
} }
#[derive(Serialize, Deserialize, sqlx::FromRow, Debug)] #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct User { pub struct User {
pub email: String, pub username: String,
pub email: Option<String>,
pub email_verified: bool,
pub password_hash: String, pub password_hash: String,
pub role: String, pub role: String,
pub first_name: String, pub first_name: String,
pub last_name: String, pub last_name: String,
pub avatar: Option<String>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
impl User { impl User {
pub async fn select(email: &str) -> Option<Self> { pub async fn select(username: &str) -> Option<Self> {
let pool = db::pool(); let pool = db::pool();
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!( let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
r#" r#"
SELECT * FROM {} WHERE email = LOWER($1) SELECT * FROM {} WHERE username = $1
"#, "#,
TABLE_NAME TABLE_NAME
)) ))
.bind(email) .bind(username)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.unwrap_or_else(|err| { .unwrap_or_else(|err| {
log::error!("Unable to find user '{}': {}", email, err); log::error!("Unable to find user '{}': {}", username, err);
None None
}); });
user user
} }
pub async fn select_by_email(email: &str) -> Option<Self> {
let pool = db::pool();
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
r#"
SELECT * FROM {} WHERE email = $1
"#,
TABLE_NAME
))
.bind(email.to_lowercase())
.fetch_optional(pool)
.await
.unwrap_or_else(|err| {
log::error!("Unable to find user by email '{}': {}", email, err);
None
});
user
}
#[allow(dead_code)]
pub async fn count() -> i64 { pub async fn count() -> i64 {
let pool = db::pool(); let pool = db::pool();
@@ -175,24 +247,30 @@ impl User {
let user: User = sqlx::query_as::<_, Self>(&format!( let user: User = sqlx::query_as::<_, Self>(&format!(
r#" r#"
INSERT INTO {} ( INSERT INTO {} (
username,
email, email,
email_verified,
password_hash, password_hash,
role, role,
first_name, first_name,
last_name, last_name,
avatar,
created_at, created_at,
updated_at updated_at
) )
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING * RETURNING *
"#, "#,
TABLE_NAME, TABLE_NAME,
)) ))
.bind(&self.username)
.bind(&self.email) .bind(&self.email)
.bind(&self.email_verified)
.bind(&self.password_hash) .bind(&self.password_hash)
.bind(&self.role) .bind(&self.role)
.bind(&self.first_name) .bind(&self.first_name)
.bind(&self.last_name) .bind(&self.last_name)
.bind(&self.avatar)
.bind(self.created_at) .bind(self.created_at)
.bind(self.updated_at) .bind(self.updated_at)
.fetch_one(pool) .fetch_one(pool)

View File

@@ -152,7 +152,9 @@
// } // }
// } // }
pub fn init_routes(_config: &mut actix_web::web::ServiceConfig) { use utoipa_actix_web::service_config::ServiceConfig;
pub fn init_routes(_config: &mut ServiceConfig) {
// config.service( // config.service(
// web::scope("users") // web::scope("users")
// .service(get_favorites) // .service(get_favorites)

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Change Password name: Change Password
type: http type: http
seq: 4 seq: 6
} }
put { put {
@@ -11,7 +11,9 @@ put {
} }
body:json { body:json {
"New Password" {
"password": "New Password"
}
} }
script:post-response { script:post-response {

View 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"
}
}

View File

@@ -0,0 +1,11 @@
meta {
name: Get Profile
type: http
seq: 10
}
get {
url: {{API_URL}}/account/profile
body: none
auth: none
}

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Login name: Login
type: http type: http
seq: 2 seq: 4
} }
post { post {
@@ -12,7 +12,7 @@ post {
body:json { body:json {
{ {
"email": "admin@example.com", "username": "user",
"password": "CHANGEME" "password": "changeme"
} }
} }

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Logout name: Logout
type: http type: http
seq: 3 seq: 5
} }
post { post {
@@ -12,7 +12,7 @@ post {
body:json { body:json {
{ {
"email": "john.doe@gmail.com", "email": "user@gmail.com",
"password": "fake_password123" "password": "changeme"
} }
} }

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Validate Session name: Refresh Session
type: http type: http
seq: 5 seq: 9
} }
get { get {

View File

@@ -12,9 +12,10 @@ post {
body:json { body:json {
{ {
"email": "john.doe@gmail.com", "username": "user",
"password": "fake_password123", "email": "user@example.com",
"first_name": "John", "password": "changeme",
"last_name": "Doe" "firstName": "John",
"lastName": "Doe"
} }
} }

View 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
}

View File

@@ -0,0 +1,17 @@
meta {
name: Reset Password
type: http
seq: 7
}
post {
url: {{API_URL}}/account/password/reset
body: json
auth: none
}
body:json {
{
"email": "user@example.com"
}
}

View 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
View File

@@ -0,0 +1,3 @@
meta {
name: Account
}

View File

@@ -5,7 +5,7 @@ meta {
} }
get { get {
url: {{API_URL}}/airports?page=1&limit=1000&metars=true url: {{API_URL}}/airports?page=1&limit=1000
body: none body: none
auth: none auth: none
} }
@@ -13,7 +13,7 @@ get {
params:query { params:query {
page: 1 page: 1
limit: 1000 limit: 1000
metars: true ~metars: true
~icaos: 00AA ~icaos: 00AA
~icaos: KHEF,KJYO,KMRB,KOKV ~icaos: KHEF,KJYO,KMRB,KOKV
} }

View File

@@ -11,5 +11,5 @@ post {
} }
body:multipart-form { body:multipart-form {
: @file(/Users/bsherriff/git/private/aviation-weather/data/airports_2023-12-21.json) : @file(/Users/bsherriff/git/private/aviation/data/2025-05-13_airports.json)
} }

View File

@@ -22,7 +22,7 @@ body:json {
"latitude": 0, "latitude": 0,
"longitude": 0, "longitude": 0,
"runways": [], "runways": [],
"frequencies": [], "communications": [],
"public": true "public": true
} }
} }

View File

@@ -5,12 +5,11 @@ meta {
} }
get { get {
url: {{API_URL}}/metars?icaos=KJYO,KOKV,KMRB,KHEF,KIAD&force=true url: {{API_URL}}/metars?icaos=KIAD
body: none body: none
auth: none auth: none
} }
params:query { params:query {
icaos: KJYO,KOKV,KMRB,KHEF,KIAD icaos: KIAD
force: true
} }

File diff suppressed because it is too large Load Diff

View File

@@ -25,8 +25,7 @@ services:
volumes: volumes:
- ./ssl:/etc/nginx/ssl/ - ./ssl:/etc/nginx/ssl/
networks: networks:
- frontend - default
- backend
<<: *default_restart <<: *default_restart
postgres: postgres:
@@ -36,14 +35,14 @@ services:
environment: environment:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_NAME} POSTGRES_DB: ${POSTGRES_DB}
volumes: volumes:
- postgres:/var/lib/postgresql/data - postgres:/var/lib/postgresql/data
- postgres_logs:/var/log - postgres_logs:/var/log
ports: ports:
- "${POSTGRES_PORT:-5432}:5432" - "${POSTGRES_PORT:-5432}:5432"
networks: networks:
- backend - default
profiles: profiles:
- backend - backend
<<: *default_restart <<: *default_restart
@@ -61,7 +60,7 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
networks: networks:
- backend - default
profiles: profiles:
- backend - backend
<<: *default_restart <<: *default_restart
@@ -80,7 +79,7 @@ services:
- "${MINIO_PORT:-9000}:9000" - "${MINIO_PORT:-9000}:9000"
- "${MINIO_INTERNAL_PORT:-9001}:9001" - "${MINIO_INTERNAL_PORT:-9001}:9001"
networks: networks:
- backend - default
profiles: profiles:
- backend - backend
command: server --console-address ":9001" /data command: server --console-address ":9001" /data
@@ -102,8 +101,10 @@ services:
REDIS_PORT: 6379 REDIS_PORT: 6379
MINIO_HOST: aviation-minio MINIO_HOST: aviation-minio
MINIO_PORT: 9000 MINIO_PORT: 9000
TEMPLATE_DIR: /templates
volumes: volumes:
- ./ssl:/ssl - ./ssl:/ssl
- ./templates:/templates
ports: ports:
- "${API_PORT:-5000}:5000" - "${API_PORT:-5000}:5000"
depends_on: depends_on:
@@ -111,32 +112,50 @@ services:
- redis - redis
- minio - minio
networks: networks:
- frontend - default
- backend
profiles: profiles:
- api - api
<<: *default_restart <<: *default_restart
ui-dev: # Development Containers
image: gitea.bensherriff.com/bsherriff/aviation-ui:latest # ui-dev:
container_name: aviation-ui-dev # image: gitea.bensherriff.com/bsherriff/aviation-ui:latest
build: # container_name: aviation-ui-dev
context: . # build:
dockerfile: Dockerfile # context: .
env_file: *env # 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: 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: ports:
- "${UI_PORT:-3000}:3000" - "${MAILPIT_WEB_PORT:-8025}:8025"
- "${MAILPIT_SMTP_PORT:-1025}:1025"
volumes: volumes:
- ./ui/src:/app/src - mailpit:/data
- ./ui/public:/app/public
- ./ui/styles:/app/styles
networks: networks:
- frontend - default
profiles: profiles:
- frontend - dev
command: ["npm", "run", "dev"]
<<: *default_restart <<: *default_restart
volumes: volumes:
@@ -144,7 +163,7 @@ volumes:
postgres_logs: postgres_logs:
redis: redis:
minio: minio:
mailpit:
networks: networks:
frontend: default:
backend:

View File

@@ -12,6 +12,22 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location = /api-docs/openapi.json {
proxy_pass http://${NGINX_INTERNAL_HOST}:${API_PORT}/api-docs/openapi.json;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /swagger/ {
proxy_pass http://${NGINX_INTERNAL_HOST}:${API_PORT}/swagger/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /minio/ { location /minio/ {
proxy_pass http://${NGINX_INTERNAL_HOST}:${MINIO_INTERNAL_PORT}/; proxy_pass http://${NGINX_INTERNAL_HOST}:${MINIO_INTERNAL_PORT}/;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@@ -32,6 +32,22 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location = /api-docs/openapi.json {
proxy_pass http://${NGINX_INTERNAL_HOST}:${API_PORT}/api-docs/openapi.json;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /swagger/ {
proxy_pass http://${NGINX_INTERNAL_HOST}:${API_PORT}/swagger/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /minio/ { location /minio/ {
proxy_pass http://${NGINX_INTERNAL_HOST}:${MINIO_INTERNAL_PORT}/; proxy_pass http://${NGINX_INTERNAL_HOST}:${MINIO_INTERNAL_PORT}/;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Confirm your email</title>
<style>
body { margin:0; padding:0; background:#f2f2f2; font-family:Helvetica,Arial,sans-serif; }
.wrapper { width:100%; table-layout:fixed; background:#f2f2f2; padding:40px 0; }
.main { background:#ffffff; width:600px; margin:0 auto; border-radius:6px; overflow:hidden; }
.header { background:#fff; text-align:center; padding:30px; }
.header img { width:60px; height:auto; display:block; margin:0 auto 10px; }
.header h1 { margin:0; font-size:24px; color:#333333; }
.header p { margin:5px 0 0; font-size:14px; color:#777777; }
.content { padding:30px; color:#333333; font-size:16px; line-height:1.5; }
.content h2 { margin-top:0; font-size:20px; }
.btn-wrap { text-align:center; margin:30px 0; }
.btn { background:#007bff; color:#ffffff !important; text-decoration:none; padding:12px 24px; border-radius:4px; display:inline-block; font-size:16px; }
.footer { text-align:center; padding:20px; font-size:12px; color:#999999; }
.footer a { color:#999999; text-decoration:none; }
</style>
</head>
<body>
<div class="wrapper">
<div class="main">
<!-- header -->
<div class="header">
<img src="{{logo_url}}" alt="Aviation Data Logo" />
<h1>Aviation Data</h1>
<p>Your source for aviation data</p>
</div>
<!-- body -->
<div class="content">
<h2>Confirm Your Email</h2>
<p>Thanks for signing up! Please confirm your email address by clicking the button below:</p>
<div class="btn-wrap">
<a href="{{{link}}}" class="btn">Confirm my email</a>
</div>
<p>If you didnt create an account with us, you can safely ignore this email.</p>
<p>Cheers,<br/>The Aviation Data Team</p>
</div>
</div>
<!-- footer -->
<div class="footer">
Serving the Aviation Community<br/>
<a href="{{domain}}">{{domain}}</a> &nbsp;|&nbsp; © {{year}} Aviation Data
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Reset your password</title>
<style>
body { margin:0; padding:0; background:#f2f2f2; font-family:Helvetica,Arial,sans-serif; }
.wrapper { width:100%; table-layout:fixed; background:#f2f2f2; padding:40px 0; }
.main { background:#ffffff; width:600px; margin:0 auto; border-radius:6px; overflow:hidden; }
.header { background:#fff; text-align:center; padding:30px; }
.header img { width:60px; height:auto; display:block; margin:0 auto 10px; }
.header h1 { margin:0; font-size:24px; color:#333333; }
.header p { margin:5px 0 0; font-size:14px; color:#777777; }
.content { padding:30px; color:#333333; font-size:16px; line-height:1.5; }
.content h2 { margin-top:0; font-size:20px; }
.btn-wrap { text-align:center; margin:30px 0; }
.btn { background:#28a745; color:#ffffff !important; text-decoration:none; padding:12px 24px; border-radius:4px; display:inline-block; font-size:16px; }
.footer { text-align:center; padding:20px; font-size:12px; color:#999999; }
.footer a { color:#999999; text-decoration:none; }
</style>
</head>
<body>
<div class="wrapper">
<div class="main">
<!-- header -->
<div class="header">
<img src="{{logo_url}}" alt="Aviation Data Logo" />
<h1>Aviation Data</h1>
<p>Your source for aviation data</p>
</div>
<!-- body -->
<div class="content">
<h2>Reset Your Password</h2>
<p>We received a request to reset your password. Click the button below to choose a new one:</p>
<div class="btn-wrap">
<a href="{{{link}}}" class="btn">Reset my password</a>
</div>
<p>If you didnt request this reset, you can safely ignore this email.</p>
<p>Cheers,<br/>The Aviation Data Team</p>
</div>
</div>
<!-- footer -->
<div class="footer">
Serving the Aviation Community<br/>
<a href="{{domain}}">{{domain}}</a> &nbsp;|&nbsp; © {{year}} Aviation Data
</div>
</div>
</body>
</html>

View File

@@ -10,6 +10,7 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<!-- The config file only exists in production environments -->
<script src="./config.js"></script> <script src="./config.js"></script>
<script type="module" src="./src/main.tsx"></script> <script type="module" src="./src/main.tsx"></script>
</body> </body>

309
ui/package-lock.json generated
View File

@@ -1,22 +1,24 @@
{ {
"name": "aviation-ui", "name": "aviation-ui",
"version": "0.1.2", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aviation-ui", "name": "aviation-ui",
"version": "0.1.2", "version": "0.1.0",
"dependencies": { "dependencies": {
"@mantine/core": "^7.17.2", "@mantine/core": "^8.0.0",
"@mantine/form": "^7.17.2", "@mantine/dropzone": "^8.0.0",
"@mantine/hooks": "^7.17.2", "@mantine/form": "^8.0.0",
"@mantine/modals": "^7.17.2", "@mantine/hooks": "^8.0.0",
"@mantine/notifications": "^7.17.2", "@mantine/modals": "^8.0.0",
"@mantine/notifications": "^8.0.0",
"@tabler/icons-react": "^3.31.0", "@tabler/icons-react": "^3.31.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lodash.debounce": "^4.0.8",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
@@ -27,6 +29,7 @@
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/leaflet": "^1.9.16", "@types/leaflet": "^1.9.16",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -62,15 +65,15 @@
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.26.2", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-validator-identifier": "^7.25.9", "@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0", "js-tokens": "^4.0.0",
"picocolors": "^1.0.0" "picocolors": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -194,9 +197,9 @@
} }
}, },
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.25.9", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -204,9 +207,9 @@
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.25.9", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -224,27 +227,27 @@
} }
}, },
"node_modules/@babel/helpers": { "node_modules/@babel/helpers": {
"version": "7.26.9", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz",
"integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.26.9", "@babel/template": "^7.27.1",
"@babel/types": "^7.26.9" "@babel/types": "^7.27.1"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.26.9", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz",
"integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.26.9" "@babel/types": "^7.27.1"
}, },
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
@@ -286,27 +289,24 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.26.9", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
"integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
"license": "MIT", "license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.26.9", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.26.2", "@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.26.9", "@babel/parser": "^7.27.2",
"@babel/types": "^7.26.9" "@babel/types": "^7.27.1"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -342,14 +342,14 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.26.9", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
"integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.25.9", "@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.25.9" "@babel/helper-validator-identifier": "^7.27.1"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -1104,28 +1104,43 @@
} }
}, },
"node_modules/@mantine/core": { "node_modules/@mantine/core": {
"version": "7.17.2", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.17.2.tgz", "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.0.0.tgz",
"integrity": "sha512-R6MYhitJ0JEgrhadd31Nw9FhRaQwDHjXUs5YIlitKH/fTOz9gKSxKjzmNng3bEBQCcbEDOkZj3FRcBgTUh/F0Q==", "integrity": "sha512-TskeJS2/+DbmUe85fXDoUAyErkSvR4YlbUl8MLqhjFBJUqwc72ZrLynmN13wuKtlVPakDYYjq4/IEDMReh3CYA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/react": "^0.26.28", "@floating-ui/react": "^0.26.28",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"react-number-format": "^5.4.3", "react-number-format": "^5.4.3",
"react-remove-scroll": "^2.6.2", "react-remove-scroll": "^2.6.2",
"react-textarea-autosize": "8.5.6", "react-textarea-autosize": "8.5.9",
"type-fest": "^4.27.0" "type-fest": "^4.27.0"
}, },
"peerDependencies": { "peerDependencies": {
"@mantine/hooks": "7.17.2", "@mantine/hooks": "8.0.0",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/dropzone": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.0.0.tgz",
"integrity": "sha512-eSQbYg0M6MuvPvCJuiM3HKJufcNRqjjwaa157tXRGV7iUPfzXxdF1EMP1osljXRjMEGH/A+CiDN3eCsNTzt53A==",
"license": "MIT",
"dependencies": {
"react-dropzone-esm": "15.2.0"
},
"peerDependencies": {
"@mantine/core": "8.0.0",
"@mantine/hooks": "8.0.0",
"react": "^18.x || ^19.x", "react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x" "react-dom": "^18.x || ^19.x"
} }
}, },
"node_modules/@mantine/form": { "node_modules/@mantine/form": {
"version": "7.17.2", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@mantine/form/-/form-7.17.2.tgz", "resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.0.0.tgz",
"integrity": "sha512-MxZPKXXhaZ7M1ZJOpS2wifhh186DMvNjcXa2bP04Tp9TdvTlbLAJZxKjZkQnGGgt8Atsf6/3gdeJMfG704Km6g==", "integrity": "sha512-ErbbEFMEiRsK2Rn0jmFE5ohNJXHSMSbuJsL2vDUVsbIaXo6svw6ockw1WWGdiU8oEGqxM6Pd618yI9cJWNHF3g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
@@ -1136,46 +1151,46 @@
} }
}, },
"node_modules/@mantine/hooks": { "node_modules/@mantine/hooks": {
"version": "7.17.2", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.17.2.tgz", "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.0.0.tgz",
"integrity": "sha512-tbErVcGZu0E4dSmE6N0k6Tv1y9R3SQmmQgwqorcc+guEgKMdamc36lucZGlJnSGUmGj+WLUgELkEQ0asdfYBDA==", "integrity": "sha512-hrcgZMHUPsgu+VBfUVcJOqMG7Qi+AshYjFyc/qo0Cz8TEhqWmD0I1yJW+qj4sDTTDWRQC6kvI5c1h+87/9MvoA==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"react": "^18.x || ^19.x" "react": "^18.x || ^19.x"
} }
}, },
"node_modules/@mantine/modals": { "node_modules/@mantine/modals": {
"version": "7.17.2", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-7.17.2.tgz", "resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-8.0.0.tgz",
"integrity": "sha512-Ms8MYLJCZcxRnGfIQr4riGK2g5mpklxiEAU84vbptoAlQ2d5Iqu+CQ0XpDfamCQl/ltmPmYJYkrq52zhQWIS3w==", "integrity": "sha512-yki3KzW9Pykf6hVSezWjeHC0FCiYD3mK2r2Sn6qE0ag+EeXZs1cbrqpjZHYov2rh6j0xzW2jnaoVbKEqYw1vUQ==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@mantine/core": "7.17.2", "@mantine/core": "8.0.0",
"@mantine/hooks": "7.17.2", "@mantine/hooks": "8.0.0",
"react": "^18.x || ^19.x", "react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x" "react-dom": "^18.x || ^19.x"
} }
}, },
"node_modules/@mantine/notifications": { "node_modules/@mantine/notifications": {
"version": "7.17.2", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.17.2.tgz", "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.0.0.tgz",
"integrity": "sha512-vg0L8cmihz0ODg4WJ9MAyK06WPt/6g67ksIUFxd4F8RfdJbIMLTsNG9yWoSfuhtXenUg717KaA917IWLjDSaqw==", "integrity": "sha512-sWldvQmq4YJsknHURBNKkc3CAU0qDb0LuQGKIZGxqFlwEiXNIAI8mtfr7stgzVx+mteVW1g+HBb7FaZp07jRxQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@mantine/store": "7.17.2", "@mantine/store": "8.0.0",
"react-transition-group": "4.4.5" "react-transition-group": "4.4.5"
}, },
"peerDependencies": { "peerDependencies": {
"@mantine/core": "7.17.2", "@mantine/core": "8.0.0",
"@mantine/hooks": "7.17.2", "@mantine/hooks": "8.0.0",
"react": "^18.x || ^19.x", "react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x" "react-dom": "^18.x || ^19.x"
} }
}, },
"node_modules/@mantine/store": { "node_modules/@mantine/store": {
"version": "7.17.2", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.17.2.tgz", "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.0.0.tgz",
"integrity": "sha512-UoMUYQK/z58hMueCkpDIXc49gPgrVO/zcpb0k+B7MFU51EIUiFzHLxLFBmWrgCAM6rzJORqN8JjyCd/PB9j4aw==", "integrity": "sha512-42RWCsXMNuhpX+d/hwr5aHj+HWyi5ltbc0R0xdiUnAmqSB7CHbWxDDLh4+DbmqPrN9pTeYvpPGp3v/CG2vuGBg==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"react": "^18.x || ^19.x" "react": "^18.x || ^19.x"
@@ -1553,12 +1568,6 @@
"@babel/types": "^7.20.7" "@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": { "node_modules/@types/d3": {
"version": "7.4.3", "version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -1881,6 +1890,23 @@
"@types/geojson": "*" "@types/geojson": "*"
} }
}, },
"node_modules/@types/lodash": {
"version": "4.17.16",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash.debounce": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz",
"integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.10", "version": "22.13.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
@@ -3741,6 +3767,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4261,6 +4293,21 @@
"react": "^19.0.0" "react": "^19.0.0"
} }
}, },
"node_modules/react-dropzone-esm": {
"version": "15.2.0",
"resolved": "https://registry.npmjs.org/react-dropzone-esm/-/react-dropzone-esm-15.2.0.tgz",
"integrity": "sha512-pPwR8xWVL+tFLnbAb8KVH5f6Vtl397tck8dINkZ1cPMxHWH+l9dFmIgRWgbh7V7jbjIcuKXCsVrXbhQz68+dVA==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -4349,15 +4396,13 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.5.0", "version": "7.6.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz",
"integrity": "sha512-estOHrRlDMKdlQa6Mj32gIks4J+AxNsYoE0DbTTxiMy2mPzZuWSDU+N85/r1IlNR7kGfznF3VCUlvc5IUO+B9g==", "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^1.0.1", "cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0", "set-cookie-parser": "^2.6.0"
"turbo-stream": "2.4.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -4395,9 +4440,9 @@
} }
}, },
"node_modules/react-textarea-autosize": { "node_modules/react-textarea-autosize": {
"version": "8.5.6", "version": "8.5.9",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.6.tgz", "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
"integrity": "sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw==", "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.20.13", "@babel/runtime": "^7.20.13",
@@ -4437,12 +4482,6 @@
"pify": "^2.3.0" "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": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -4789,6 +4828,51 @@
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -4821,12 +4905,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -5033,15 +5111,18 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.2.1", "version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==", "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"rollup": "^4.30.1" "rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
@@ -5104,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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -11,15 +11,17 @@
"format": "prettier --write src" "format": "prettier --write src"
}, },
"dependencies": { "dependencies": {
"@mantine/core": "^7.17.2", "@mantine/core": "^8.0.0",
"@mantine/form": "^7.17.2", "@mantine/dropzone": "^8.0.0",
"@mantine/hooks": "^7.17.2", "@mantine/form": "^8.0.0",
"@mantine/modals": "^7.17.2", "@mantine/hooks": "^8.0.0",
"@mantine/notifications": "^7.17.2", "@mantine/modals": "^8.0.0",
"@mantine/notifications": "^8.0.0",
"@tabler/icons-react": "^3.31.0", "@tabler/icons-react": "^3.31.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lodash.debounce": "^4.0.8",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
@@ -30,6 +32,7 @@
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/leaflet": "^1.9.16", "@types/leaflet": "^1.9.16",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",

View File

@@ -14,40 +14,15 @@
width: 100%; width: 100%;
} }
.map-button { .custom-control a.active {
position: absolute;
right: 12px;
z-index: 1000;
color: #000;
background: #fff;
border-radius: 3px;
border: 1px solid #ccc;
border-bottom-width: 2px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
height: 30px;
width: 30px;
text-align: center;
line-height: 30px; /* Vertically center text */
font-weight: bold;
cursor: pointer;
user-select: none;
transition:
background-color 0.2s,
color 0.2s;
}
.map-button.active {
background-color: #228be6; background-color: #228be6;
color: #fff; color: white;
} }
.map-button.active:hover { .custom-control a {
background-color: #187ed7; padding: 0;
} line-height: 1;
display: flex;
.map-button:hover { align-items: center;
background: #e6e6e6; justify-content: center;
} }

View File

@@ -1,4 +1,4 @@
import { LayersControl, MapContainer, TileLayer, useMapEvents, ZoomControl } from 'react-leaflet'; import { LayersControl, MapContainer, ScaleControl, TileLayer, useMapEvents, ZoomControl } from 'react-leaflet';
import '@mantine/core/styles.css'; import '@mantine/core/styles.css';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import './App.css'; import './App.css';
@@ -10,10 +10,13 @@ import { Header } from '@components/Header';
import AirportLayer from '@components/AirportLayer.tsx'; import AirportLayer from '@components/AirportLayer.tsx';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Airport } from '@lib/airport.types.ts'; import { Airport } from '@lib/airport.types.ts';
import AirportDrawer from '@components/AirportDrawer.tsx';
import { getWeatherMapUrl } from '@lib/rainViewer.ts'; import { getWeatherMapUrl } from '@lib/rainViewer.ts';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { UnstyledButton } from '@mantine/core'; 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 // Fix Leaflet's default icon path issues with Webpack
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error // @ts-expect-error
@@ -25,9 +28,17 @@ L.Icon.Default.mergeOptions({
shadowUrl: markerShadow shadowUrl: markerShadow
}); });
const openStreetMapUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; export interface LayerInfo {
const lightLayerUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'; url: string;
const darkLayerUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'; name: string;
markerOutline: string;
}
const layerMap: LayerInfo[] = [
{ url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', name: 'Open Street Map', markerOutline: 'black' },
{ url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', name: 'Carto Light', markerOutline: 'black' },
{ url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', name: 'Carto Dark', markerOutline: 'white' }
];
// const dark1Url = 'https://maps.rainviewer.com/data/v3/5/10/11.pbf'; // const dark1Url = 'https://maps.rainviewer.com/data/v3/5/10/11.pbf';
// const dark2Url = 'https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer/tile/2/0/3.pbf'; // const dark2Url = 'https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer/tile/2/0/3.pbf';
const defaultZoom = 6; const defaultZoom = 6;
@@ -38,7 +49,10 @@ function App() {
const [rainViewerUrl, setRainViewerUrl] = useState<string | null>(null); const [rainViewerUrl, setRainViewerUrl] = useState<string | null>(null);
const initialRadarValue = Cookies.get('showRadar') === 'true'; const initialRadarValue = Cookies.get('showRadar') === 'true';
const [showRadar, setShowRadar] = useState<boolean>(initialRadarValue); const [showRadar, setShowRadar] = useState<boolean>(initialRadarValue);
const [baseLayer, setBaseLayer] = useState<string>(Cookies.get('selectedBaseLayer') || 'Open Street Map'); const initialShowNoMetarValue = Cookies.get('showNoMetar') === 'true';
const [showNoMetar, setShowNoMetar] = useState<boolean>(initialShowNoMetarValue);
const [selectedLayerIndex, setSelectedLayerIndex] = useState<string>(Cookies.get('selectedLayer') || '0');
const [selectedLayer, setSelectedLayer] = useState<LayerInfo>(layerMap[Number(selectedLayerIndex)]);
useEffect(() => { useEffect(() => {
if (showRadar) { if (showRadar) {
@@ -56,11 +70,21 @@ function App() {
}); });
} }
function toggleShowNoMetar() {
setShowNoMetar((prev) => {
const newValue = !prev;
Cookies.set('showNoMetar', newValue.toString(), { expires: 7 });
return newValue;
});
}
function BaseLayerChangeHandler() { function BaseLayerChangeHandler() {
useMapEvents({ useMapEvents({
baselayerchange: (e) => { baselayerchange: (e) => {
setBaseLayer(e.name); const index = layerMap.findIndex((layer) => layer.name === e.name);
Cookies.set('selectedBaseLayer', e.name, { expires: 7 }); setSelectedLayerIndex(`${index}`);
Cookies.set('selectedLayer', `${index}`, { expires: 7 });
setSelectedLayer(layerMap[index]);
} }
}); });
return null; return null;
@@ -70,7 +94,6 @@ function App() {
<div className='App'> <div className='App'>
<Header /> <Header />
<div className='map-wrapper'> <div className='map-wrapper'>
<AirportDrawer airport={airport} setAirport={setAirport} />
<MapContainer <MapContainer
className='leaflet-container' className='leaflet-container'
attributionControl={false} attributionControl={false}
@@ -84,31 +107,41 @@ function App() {
]} ]}
scrollWheelZoom={true} scrollWheelZoom={true}
zoomControl={false} zoomControl={false}
markerZoomAnimation={false}
> >
<AirportDrawer airport={airport} setAirport={setAirport} />
<LayersControl> <LayersControl>
<LayersControl.BaseLayer checked={baseLayer === 'Open Street Map'} name={'Open Street Map'}> {layerMap.map((layer, index) => (
<TileLayer url={openStreetMapUrl} /> <LayersControl.BaseLayer key={index} checked={selectedLayerIndex === `${index}`} name={layer.name}>
</LayersControl.BaseLayer> <TileLayer url={layer.url} />
<LayersControl.BaseLayer checked={baseLayer === 'Carto Light'} name={'Carto Light'}>
<TileLayer url={lightLayerUrl} />
</LayersControl.BaseLayer>
<LayersControl.BaseLayer checked={baseLayer === 'Carto Dark'} name={'Carto Dark'}>
<TileLayer url={darkLayerUrl} />
</LayersControl.BaseLayer> </LayersControl.BaseLayer>
))}
</LayersControl> </LayersControl>
<ScaleControl />
{rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />} {rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />}
<ZoomControl position={'bottomright'} /> <ZoomControl position={'bottomright'} />
<AirportLayer setAirport={setAirport} /> <AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} />
<BaseLayerChangeHandler /> <BaseLayerChangeHandler />
<LocateControl />
<GroupControl
buttons={[
{
title: 'Toggle radar',
active: showRadar,
onClick: toggleRadar,
icon: <IconRadar />
},
{
title: 'Toggle nonMETAR airports',
active: showNoMetar,
onClick: toggleShowNoMetar,
icon: <IconBuildingAirport />
}
]}
/>
</MapContainer> </MapContainer>
<UnstyledButton
onClick={toggleRadar}
style={{ bottom: '80px' }}
className={`map-button ${showRadar ? 'active' : ''}`}
>
R
</UnstyledButton>
</div> </div>
<Footer />
</div> </div>
); );
} }

View File

@@ -1,18 +1,21 @@
import { Header } from '@components/Header'; import { Header } from '@components/Header';
import { Navigate } from 'react-router';
import { useUserContext } from '@components/context/UserContext.tsx'; import { useUserContext } from '@components/context/UserContext.tsx';
import { AirportTable } from '@components/AirportTable';
import { AirportDrop } from '@components/AirportDrop';
import { NotFound } from '@components/NotFound';
export function Administration() { export function Administration() {
const { user } = useUserContext(); const { user } = useUserContext();
if (user == undefined) { if (user == undefined || user.role != 'ADMIN') {
return <Navigate to={'/'} />; return <NotFound />;
} }
return ( return (
<> <>
<Header /> <Header />
Todo: administration {user?.email} <AirportTable />
<AirportDrop />
</> </>
); );
} }

View File

@@ -1,72 +0,0 @@
import { Divider, Drawer, Group } from '@mantine/core';
import { Airport, AirportCategory } from '@lib/airport.types.ts';
export default function AirportDrawer({
airport,
setAirport
}: {
airport: Airport | null;
setAirport: (airport: Airport | null) => void;
}) {
if (!airport) {
return null;
}
return (
<Drawer
opened={true}
onClose={() => setAirport(null)}
title={airport.name}
withinPortal
zIndex={10000}
styles={{ root: { width: 0, height: 0 } }}
padding='md'
size='md'
position='left'
withOverlay={false}
closeOnClickOutside={false}
>
<Group>
<div>ICAO: {airport.icao}</div>
<div>Category: {airportCategoryToText(airport.category)}</div>
<div>
Country / Region: {airport.iso_country}, {airport.iso_region}
</div>
<div>Municipality: {airport.municipality || 'N/A'}</div>
<div>Local Code: {airport.local || 'N/A'}</div>
<div>Elevation: {airport.elevation_ft}</div>
<div>
Coordinates: {airport.latitude.toFixed(4)}, {airport.longitude.toFixed(4)}
</div>
<div>Control Tower: {airport.has_tower ? 'Yes' : 'No'}</div>
<div>Beacon: {airport.has_beacon ? 'Yes' : 'No'}</div>
{airport.latest_metar && airport.latest_metar.flight_category && (
<>
<Divider my='sm' />
<div>Flight Category: {airport.latest_metar.flight_category}</div>
</>
)}
</Group>
</Drawer>
);
}
function airportCategoryToText(category: AirportCategory): string {
switch (category) {
case AirportCategory.SMALL:
return 'Small';
case AirportCategory.MEDIUM:
return 'Medium';
case AirportCategory.LARGE:
return 'Large';
case AirportCategory.HELIPORT:
return 'Helipad';
case AirportCategory.CLOSED:
return 'Closed';
case AirportCategory.SEAPLANE:
return 'Seaplane Base';
case AirportCategory.BALLOONPORT:
return 'Balloon Port';
default:
return 'Unknown';
}
}

View File

@@ -0,0 +1,3 @@
.drawer {
background: #32495f;
}

View File

@@ -0,0 +1,27 @@
import { Table } from '@mantine/core';
import { Communication } from '@lib/airport.types.ts';
export function CommunicationTable({ communications }: { communications: Communication[] }) {
const rows = communications.map((communication) => (
<Table.Tr key={communication.id}>
<Table.Td>{communication.id}</Table.Td>
<Table.Td>{communication.name}</Table.Td>
<Table.Td>{communication.frequencies_mhz}</Table.Td>
<Table.Td>{communication.phone}</Table.Td>
</Table.Tr>
));
return (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>ID</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>MHz</Table.Th>
<Table.Th>Phone</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
);
}

View File

@@ -0,0 +1,27 @@
import { Table } from '@mantine/core';
import { Runway } from '@lib/airport.types.ts';
export function RunwayTable({ runways }: { runways: Runway[] }) {
const rows = runways.map((runway) => (
<Table.Tr key={runway.id}>
<Table.Td>{runway.id}</Table.Td>
<Table.Td>{runway.surface}</Table.Td>
<Table.Td>{runway.length_ft}</Table.Td>
<Table.Td>{runway.width_ft}</Table.Td>
</Table.Tr>
));
return (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>ID</Table.Th>
<Table.Th>Surface</Table.Th>
<Table.Th>Length (ft)</Table.Th>
<Table.Th>Width (ft)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
);
}

View File

@@ -0,0 +1,335 @@
import {
Accordion,
Badge,
Box,
Divider,
Drawer,
Group,
Tabs,
TabsList,
Text,
Tooltip,
UnstyledButton
} from '@mantine/core';
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 { 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,
setAirport
}: {
airport: Airport | null;
setAirport: (airport: Airport | null) => void;
}) {
const [metar, setMetar] = useState<Metar | undefined>(undefined);
const isMobile = useMediaQuery('(max-width: 768px)');
const map = useMap();
useEffect(() => {
if (!airport) return;
function updateMetar() {
if (!airport) return;
getMetars({ icaos: [airport.icao] }).then((m) => {
if (m.length > 0) {
setMetar(m[0]);
} else {
setMetar(undefined);
}
});
}
updateMetar();
const interval = setInterval(updateMetar, 60000);
return () => clearInterval(interval);
}, [airport]);
if (!airport) {
return null;
}
const metarColor = getMarkerColor(metar?.flight_category || 'UNKN');
return (
<Drawer.Root
opened={true}
onClose={() => setAirport(null)}
withinPortal
zIndex={1000}
styles={{ root: { padding: 0, margin: 0, width: 0, height: 0, backgroundColor: 'red' } }}
padding='md'
size={isMobile ? '100%' : 'md'}
position='left'
closeOnClickOutside={false}
>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>
<Text size={'xl'}>{airport.name}</Text>
</Drawer.Title>
<Drawer.CloseButton />
</Drawer.Header>
<Drawer.Body>
<Box mb='lg'>
{metar && metar.flight_category && (
<Group
justify='space-between'
mb='md'
style={{
backgroundColor: '#32495f',
borderTop: '1px solid #1a242f',
borderBottom: '1px solid #1a242f',
padding: '10px'
}}
>
<Badge size='lg' color={metarColor}>
{metar.flight_category}
</Badge>
{/*<Text style={{ color: metarColor }}>{metar.flight_category}</Text>*/}
<Tooltip zIndex={1001} label={new Date(metar.observation_time).toLocaleString()}>
<TimeSince date={metar.observation_time} />
</Tooltip>
</Group>
)}
<Tabs variant={'outline'} defaultValue={'info'}>
<TabsList grow>
<Tabs.Tab value={'info'}>Info</Tabs.Tab>
<Tabs.Tab value={'weather'}>Weather</Tabs.Tab>
</TabsList>
<Tabs.Panel value={'info'}>
<AirportInfo map={map} airport={airport} />
</Tabs.Panel>
<Tabs.Panel value={'weather'}>
<WeatherInfo metar={airport.latest_metar} />
</Tabs.Panel>
</Tabs>
</Box>
</Drawer.Body>
</Drawer.Content>
</Drawer.Root>
);
}
function AirportInfoSlot({ title, style, children }: { title?: string; style?: CSSProperties; children?: ReactNode }) {
return (
<div style={{ ...style }}>
{title && (
<Text size='xs' color='dimmed'>
{title}
</Text>
)}
<Box fw={500} size='sm'>
{children}
</Box>
</div>
);
}
function AirportInfoRow({ style, children }: { style?: CSSProperties; children: ReactNode }) {
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignContent: 'center',
padding: 'var(--mantine-spacing-sm) var(--mantine-spacing-lg)',
borderTop: '1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))',
...style
}}
>
{children}
</div>
);
}
function AirportInfo({ map, airport }: { map: LeafletMap; airport: Airport }) {
function goToLocation(map: LeafletMap, latitude: number, longitude: number) {
if (!map) return;
map.setView([latitude, longitude], map.getZoom());
}
return (
<div>
<AirportInfoRow>
<AirportInfoSlot title={'ICAO'} children={airport.icao} />
<AirportInfoSlot title={'IATA'} children={airport.iata} />
<AirportInfoSlot title={'LOCAL'} children={airport.local} />
<AirportInfoSlot title={'Category'} children={airportCategoryToText(airport.category)} />
</AirportInfoRow>
<AirportInfoRow style={{ justifyContent: 'flex-start' }}>
<AirportInfoSlot title={'Location'}>
{airport.latitude}°, {airport.longitude}°
</AirportInfoSlot>
<AirportInfoSlot title={'Elevation'} style={{ paddingLeft: '1rem' }} children={`${airport.elevation_ft} ft`} />
<AirportInfoSlot style={{ marginLeft: 'auto', paddingLeft: '1rem', paddingTop: '0.5rem' }}>
<UnstyledButton
onClick={() => {
goToLocation(map, airport.latitude, airport.longitude);
}}
>
<IconViewfinder />
</UnstyledButton>
</AirportInfoSlot>
</AirportInfoRow>
<Accordion chevronPosition={'right'} variant={'contained'}>
{airport.runways != null && airport.runways.length > 0 && (
<Accordion.Item value={'runways'}>
<Accordion.Control>Runways</Accordion.Control>
<Accordion.Panel>
<RunwayTable runways={airport.runways} />
</Accordion.Panel>
</Accordion.Item>
)}
{airport.communications != null && airport.communications.length > 0 && (
<Accordion.Item value={'communication'}>
<Accordion.Control>Communication</Accordion.Control>
<Accordion.Panel>
<CommunicationTable communications={airport.communications} />
</Accordion.Panel>
</Accordion.Item>
)}
</Accordion>
<Divider />
</div>
);
}
function WeatherInfo({ metar }: { metar?: Metar }) {
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 {
switch (category) {
case AirportCategory.SMALL:
return 'Small';
case AirportCategory.MEDIUM:
return 'Medium';
case AirportCategory.LARGE:
return 'Large';
case AirportCategory.HELIPORT:
return 'Helipad';
case AirportCategory.CLOSED:
return 'Closed';
case AirportCategory.SEAPLANE:
return 'Seaplane Base';
case AirportCategory.BALLOONPORT:
return 'Balloon Port';
default:
return 'Unknown';
}
}
const TimeSince = forwardRef<HTMLParagraphElement, { date: string }>(({ date }, ref) => {
const inputDate = new Date(date);
// @ts-expect-error doing arithmetic with dates
const seconds = Math.floor((new Date() - inputDate) / 1000);
if (seconds < 60) {
const content = seconds + (seconds === 1 ? ' second ago' : ' seconds ago');
return (
<Text ref={ref} style={{ userSelect: 'none' }}>
{content}
</Text>
);
} else {
const minutes = Math.floor(seconds / 60);
const content = minutes + (minutes === 1 ? ' minute ago' : ' minutes ago');
// If more than 60 minutes have passed, set the text color to yellow
return (
<Text ref={ref} style={{ color: minutes >= 60 ? '#fca903' : undefined, userSelect: 'none' }}>
{content}
</Text>
);
}
});

View File

@@ -0,0 +1,28 @@
.wrapper {
position: relative;
margin-bottom: 30px;
}
.dropzone {
border-width: 1px;
padding-bottom: 50px;
color: var(--mantine-color-bright);
}
.icon {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-white));
}
.control {
position: absolute;
width: 250px;
left: calc(50% - 125px);
bottom: -20px;
}
.description {
text-align: center;
font-size: var(--mantine-font-size-sm);
color: var(--mantine-color-dimmed);
margin-top: var(--mantine-spacing-xs);
}

View File

@@ -0,0 +1,69 @@
import { useRef, useState } from 'react';
import { IconCloudUpload, IconDownload, IconX } from '@tabler/icons-react';
import { Button, Group, Text, useMantineTheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import classes from './AirportDrop.module.css';
import { importAirports } from '@lib/airport.ts';
export function AirportDrop() {
const theme = useMantineTheme();
const openRef = useRef<() => void>(null);
const [loading, setLoading] = useState(false);
return (
<div className={classes.wrapper}>
<Dropzone
loading={loading}
openRef={openRef}
onDrop={async (files) => {
if (files.length === 0) return;
setLoading(true);
try {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file, file.name);
});
await importAirports(formData);
} catch (error) {
console.error('Upload error:', error);
} finally {
setLoading(false);
}
}}
className={classes.dropzone}
radius='md'
accept={['application/JSON']}
maxSize={30 * 1024 ** 2}
>
<div style={{ pointerEvents: 'none' }}>
<Group justify='center'>
<Dropzone.Accept>
<IconDownload size={50} color={theme.colors.blue[6]} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={50} color={theme.colors.red[6]} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconCloudUpload size={50} stroke={1.5} className={classes.icon} />
</Dropzone.Idle>
</Group>
<Text ta='center' fw={700} fz='lg' mt='xl'>
<Dropzone.Accept>Drop files here</Dropzone.Accept>
<Dropzone.Reject>Json file less than 30mb</Dropzone.Reject>
<Dropzone.Idle>Upload JSON</Dropzone.Idle>
</Text>
<Text className={classes.description}>
Drag&apos;n&apos;drop files here to upload. We can accept only <i>.json</i> files that are less than 30mb in
size.
</Text>
</div>
</Dropzone>
<Button className={classes.control} size='md' radius='xl' onClick={() => openRef.current?.()}>
Select files
</Button>
</div>
);
}

View File

@@ -1,91 +1,64 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Airport, AirportCategory } from '@lib/airport.types.ts'; import { Airport, AirportCategory } from '@lib/airport.types.ts';
import { useMapEvents } from 'react-leaflet'; import { useMapEvents } from 'react-leaflet';
import debounce from 'lodash.debounce';
import { getAirports } from '@lib/airport.ts'; import { getAirports } from '@lib/airport.ts';
import AirportMarker from '@components/AirportMarker.tsx'; import AirportMarker from '@components/AirportMarker.tsx';
import { LeafletEvent } from 'leaflet'; import { LayerInfo } from '@/App.tsx';
import { LatLng } from 'leaflet';
interface Bounds { export default function AirportLayer({
northEast: { lat: number; lon: number }; setAirport,
southWest: { lat: number; lon: number }; showNoMetar,
} selectedLayer
}: {
export default function AirportLayer({ setAirport }: { setAirport: (airport: Airport) => void }) { setAirport: (airport: Airport) => void;
showNoMetar: boolean;
selectedLayer: LayerInfo;
}) {
const [airports, setAirports] = useState<Airport[]>([]); const [airports, setAirports] = useState<Airport[]>([]);
const lastBoundsRef = useRef<{ ne: LatLng; sw: LatLng } | null>(null);
function loadAirports(event: LeafletEvent) { const debouncedLoad = useRef(
const map = event.target; debounce(async (map: any) => {
const bounds = map.getBounds(); const bounds = map.getBounds();
const ne = bounds.getNorthEast();
const sw = bounds.getSouthWest();
lastBoundsRef.current = { ne, sw };
const boundsParam: Bounds = { try {
northEast: { const resp = await getAirports({
lat: bounds.getNorth(), bounds: { northEast: ne, southWest: sw },
lon: bounds.getEast()
},
southWest: {
lat: bounds.getSouth(),
lon: bounds.getWest()
}
};
getAirports({
bounds: boundsParam,
metars: true, metars: true,
categories: [AirportCategory.HELIPORT, AirportCategory.SMALL, AirportCategory.MEDIUM, AirportCategory.LARGE] categories: [AirportCategory.HELIPORT, AirportCategory.SMALL, AirportCategory.MEDIUM, AirportCategory.LARGE]
})
.then((response) => {
setAirports(response.data);
})
.catch((error) => {
console.error('Error fetching airports:', error);
setAirports([]);
}); });
setAirports(resp.data);
} catch (err) {
console.error('fetch error', err);
setAirports([]);
} }
}, 300)
).current;
const map = useMapEvents({ const map = useMapEvents({
moveend: loadAirports move: () => debouncedLoad(map)
}); });
useEffect(() => { useEffect(() => {
if (map) { if (map) debouncedLoad(map);
loadAirports({ target: map } as LeafletEvent); return () => {
} debouncedLoad.cancel();
}, [map]);
const categoryOrder: { [key in AirportCategory]?: number } = {
[AirportCategory.LARGE]: 3,
[AirportCategory.MEDIUM]: 2,
[AirportCategory.SMALL]: 1,
[AirportCategory.HELIPORT]: 0
}; };
}, [map, debouncedLoad]);
const sortedAirports = airports.slice().sort((a, b) => {
// Compare by airport category first.
const categoryA = categoryOrder[a.category] ?? 4;
const categoryB = categoryOrder[b.category] ?? 4;
if (categoryA !== categoryB) {
return categoryA - categoryB;
}
// Then compare by flight category if available.
// Assuming that latest_metar.flight_category is a string and "UNKN" needs to come last.
const fcA = a.latest_metar?.flight_category ?? 'UNKN';
const fcB = b.latest_metar?.flight_category ?? 'UNKN';
if (fcA === 'UNKN' && fcB !== 'UNKN') return 1;
if (fcB === 'UNKN' && fcA !== 'UNKN') return -1;
// If both flight categories are not "UNKN", do a simple alphabetical comparison.
// (You may wish to customize this logic based on the actual flight category values.)
if (fcA < fcB) return -1;
if (fcA > fcB) return 1;
return 0;
});
return ( return (
<> <>
{sortedAirports.map((airport, index) => ( {airports.map((airport, index) => (
<AirportMarker key={index} airport={airport} index={index} setAirport={setAirport} /> <div key={index}>
{(showNoMetar || airport.latest_metar != undefined) && (
<AirportMarker airport={airport} index={index} setAirport={setAirport} selectedLayer={selectedLayer} />
)}
</div>
))} ))}
</> </>
); );

View File

@@ -2,17 +2,21 @@ import { Airport, AirportCategory } from '@lib/airport.types.ts';
import { Marker, Popup } from 'react-leaflet'; import { Marker, Popup } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
import { useRef } from 'react'; import { useRef } from 'react';
import { getMarkerColor } from '@lib/metar.types.ts';
import { LayerInfo } from '@/App.tsx';
export default function AirportMarker({ export default function AirportMarker({
index, index,
airport, airport,
setAirport setAirport,
selectedLayer
}: { }: {
index: number; index: number;
airport: Airport; airport: Airport;
setAirport: (airport: Airport) => void; setAirport: (airport: Airport) => void;
selectedLayer: LayerInfo;
}) { }) {
const icon = createCustomIcon(airport); const icon = createCustomIcon(airport, selectedLayer);
const markerRef = useRef<L.Marker>(null); const markerRef = useRef<L.Marker>(null);
return ( return (
@@ -27,29 +31,14 @@ export default function AirportMarker({
mouseout: () => markerRef.current?.closePopup() mouseout: () => markerRef.current?.closePopup()
}} }}
> >
<Popup closeButton={false} autoPan={false}> <Popup closeButton={false} autoPan={false} interactive={false}>
{airport.icao} - {airport.name} {airport.icao} - {airport.name}
</Popup> </Popup>
</Marker> </Marker>
); );
} }
function getMarkerInfo(flightCategory: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'): [string, number] { function createCustomIcon(airport: Airport, selectedLayer: LayerInfo): L.DivIcon {
switch (flightCategory) {
case 'IFR':
return ['#ff0100', 5];
case 'LIFR':
return ['#7f007f', 6];
case 'MVFR':
return ['#00f', 7];
case 'VFR':
return ['#018000', 8];
case 'UNKN':
return ['#696969', 4];
}
}
function createCustomIcon(airport: Airport): L.DivIcon {
if (airport.category === AirportCategory.HELIPORT) { if (airport.category === AirportCategory.HELIPORT) {
return L.divIcon({ return L.divIcon({
html: ` html: `
@@ -73,12 +62,12 @@ function createCustomIcon(airport: Airport): L.DivIcon {
} else { } else {
// Default to a filled circle. // Default to a filled circle.
const flightCategory = airport.latest_metar?.flight_category || 'UNKN'; const flightCategory = airport.latest_metar?.flight_category || 'UNKN';
const info = getMarkerInfo(flightCategory); const color = getMarkerColor(flightCategory);
if (flightCategory == 'UNKN') { if (flightCategory == 'UNKN') {
return L.divIcon({ return L.divIcon({
html: ` html: `
<div style=" <div style="
background-color: ${info[0]}; background-color: ${color};
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
@@ -93,11 +82,11 @@ function createCustomIcon(airport: Airport): L.DivIcon {
return L.divIcon({ return L.divIcon({
html: ` html: `
<div style=" <div style="
background-color: ${info[0]}; background-color: ${color};
width: 18px; width: 18px;
height: 18px; height: 18px;
border-radius: 50%; border-radius: 50%;
border: 2px solid #fff; border: 2px solid ${selectedLayer.markerOutline};
z-index: {info[1]}"> z-index: {info[1]}">
</div> </div>
`, `,

View File

@@ -0,0 +1,19 @@
.header {
position: sticky;
top: 0;
background-color: var(--mantine-color-body);
transition: box-shadow 150ms ease;
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-3));
}
}
.scrolled {
box-shadow: var(--mantine-shadow-sm);
}

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import cx from 'clsx';
import { Center, Pagination, ScrollArea, Table } from '@mantine/core';
import classes from './AirportTable.module.css';
import { getAirports } from '@lib/airport.ts';
import { Airport } from '@lib/airport.types.ts';
export function AirportTable() {
const [data, setData] = useState<Airport[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const limit = 1000;
getAirports({ page, limit }).then((r) => {
setData(r.data);
setTotalPages(r.total / r.data.length);
});
}, [page]);
const rows = data.map((row, idx) => (
<Table.Tr key={idx}>
<Table.Td>{row.name}</Table.Td>
<Table.Td>{row.icao}</Table.Td>
<Table.Td>{row.latitude}</Table.Td>
<Table.Td>{row.longitude}</Table.Td>
</Table.Tr>
));
return (
<>
<ScrollArea h={300} onScrollPositionChange={({ y }) => setScrolled(y !== 0)}>
<Table miw={700}>
<Table.Thead className={cx(classes.header, { [classes.scrolled]: scrolled })}>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>ICAO</Table.Th>
<Table.Th>Latitude</Table.Th>
<Table.Th>Longitude</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ScrollArea>
<Center mt='sm'>
<Pagination value={page} onChange={setPage} total={totalPages} siblings={1} boundaries={1} />
</Center>
</>
);
}

View File

@@ -0,0 +1,68 @@
import { ReactNode, useEffect, useRef } from 'react';
import * as L from 'leaflet';
import { useMap } from 'react-leaflet';
import { createRoot, Root } from 'react-dom/client';
interface Props {
position?: L.ControlPosition;
onClick: () => void;
active?: boolean;
title?: string;
children?: ReactNode;
}
export function CustomControl({ position = 'bottomright', onClick, active = false, title = '', children }: Props) {
const map = useMap();
const controlRef = useRef<L.Control>(null);
const rootRef = useRef<Root>(null);
useEffect(() => {
const ctrl = new L.Control({ position });
ctrl.onAdd = () => {
return L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
};
ctrl.addTo(map);
controlRef.current = ctrl;
// @ts-expect-error ctrl is a L.Control
const container = (ctrl as unknown)._container as HTMLElement;
rootRef.current = createRoot(container);
L.DomEvent.disableClickPropagation(container);
return () => {
if (rootRef.current) {
rootRef.current!.unmount();
rootRef.current = null;
}
ctrl.remove();
};
}, [map, position]);
useEffect(() => {
if (rootRef.current) {
rootRef.current.render(
<a
href={'#'}
title={title}
className={active ? 'active' : ''}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick();
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '4px'
}}
>
{children}
</a>
);
}
}, [onClick, active, title, children]);
return null;
}

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

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

View File

@@ -0,0 +1,76 @@
import { ReactNode, useEffect, useRef } from 'react';
import * as L from 'leaflet';
import { useMap } from 'react-leaflet';
import { createRoot, Root } from 'react-dom/client';
export interface ButtonDef {
title: string;
active: boolean;
onClick: () => void;
icon: ReactNode;
}
interface GroupControlProps {
position?: L.ControlPosition;
buttons: ButtonDef[];
}
export function GroupControl({ position = 'bottomright', buttons }: GroupControlProps) {
const map = useMap();
// References
const controlRef = useRef<L.Control>(null);
const rootRef = useRef<Root>(null);
useEffect(() => {
const ctrl = new L.Control({ position });
controlRef.current = ctrl;
ctrl.onAdd = () => {
return L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
};
ctrl.addTo(map);
// @ts-expect-error ctrl is a L.Control
const container = (ctrl as unknown)._container as HTMLElement;
rootRef.current = createRoot(container);
L.DomEvent.disableClickPropagation(container);
return () => {
ctrl.remove();
rootRef.current!.unmount();
};
}, [map, position]);
useEffect(() => {
if (rootRef.current) {
rootRef.current.render(
<div style={{ display: 'flex', flexDirection: 'column' }}>
{buttons.map((b, i) => (
<a
key={i}
href='#'
title={b.title}
className={b.active ? 'active' : ''}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
b.onClick();
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '4px'
}}
>
{b.icon}
</a>
))}
</div>
);
}
}, [buttons]);
return null;
}

View File

@@ -1,10 +1,15 @@
.header { .header {
height: 56px; height: 56px;
padding: 0 16px 0 16px; padding: 0 16px 0 16px;
background-color: var(--mantine-color-body); /*background-color: var(--mantine-color-body);*/
background: #32495f;
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
} }
.user {
user-select: none;
}
.inner { .inner {
height: 56px; height: 56px;
display: flex; display: flex;

View File

@@ -17,46 +17,46 @@ import Cookies from 'js-cookie';
interface HeaderModalProps { interface HeaderModalProps {
type?: string; type?: string;
toggle: (input: string | undefined) => void; toggle: (input: string | undefined) => void;
login: ({ email, password }: { email: string; password: string }) => Promise<boolean>; login: ({ username, password }: { username: string; password: string }) => Promise<boolean>;
register: ({ register: ({
firstName, firstName,
lastName, lastName,
email, username,
password password
}: { }: {
firstName: string; firstName: string;
lastName: string; lastName: string;
email: string; username: string;
password: string; password: string;
}) => Promise<boolean>; }) => Promise<boolean>;
} }
export function HeaderModal({ type, toggle, login, register }: HeaderModalProps) { export function HeaderModal({ type, toggle, login, register }: HeaderModalProps) {
function passwordValidator(value: string) { function passwordValidator(value: string) {
if (value.trim().length < 8) { if (value.trim().length < 6) {
return 'Password must be at least 8 characters'; return 'Password must be at least 6 characters';
} }
if (value.trim().length >= 128) { if (value.trim().length >= 128) {
return 'Password must be at most 128 characters'; return 'Password must be at most 128 characters';
} }
if (!/(\d)/.test(value)) { // if (!/(\d)/.test(value)) {
return 'Password must contain at least one number'; // return 'Password must contain at least one number';
} // }
if (!/[a-z]/.test(value)) { // if (!/[a-z]/.test(value)) {
return 'Password must contain at least one lowercase letter'; // return 'Password must contain at least one lowercase letter';
} // }
if (!/[A-Z]/.test(value)) { // if (!/[A-Z]/.test(value)) {
return 'Password must contain at least one uppercase letter'; // return 'Password must contain at least one uppercase letter';
} // }
if (!/[!@#$%^&*]/.test(value)) { // if (!/[!@#$%^&*]/.test(value)) {
return 'Password must contain at least one special character'; // return 'Password must contain at least one special character';
} // }
return null; return null;
} }
function emailValidator(value: string) { function emailValidator(value: string) {
if (value.trim().length == 0) { if (value.trim().length == 0) {
return 'Email is required'; return null;
} }
if (!/^\S+@\S+$/.test(value)) { if (!/^\S+@\S+$/.test(value)) {
return 'Invalid email'; return 'Invalid email';
@@ -68,12 +68,14 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
initialValues: { initialValues: {
firstName: '', firstName: '',
lastName: '', lastName: '',
username: '',
email: '', email: '',
password: '' password: ''
}, },
validate: { validate: {
firstName: (value) => (value.trim().length > 0 ? null : 'First name is required'), firstName: (value) => (value.trim().length > 0 ? null : 'First name is required'),
lastName: (value) => (value.trim().length > 0 ? null : 'Last 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, email: emailValidator,
password: passwordValidator password: passwordValidator
} }
@@ -81,7 +83,7 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
const loginForm = useForm({ const loginForm = useForm({
initialValues: { initialValues: {
email: Cookies.get('email') || '', username: Cookies.get('username') || '',
password: '', password: '',
remember: Cookies.get('remember') === 'true' remember: Cookies.get('remember') === 'true'
} }
@@ -150,14 +152,20 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
{...registerForm.getInputProps('lastName')} {...registerForm.getInputProps('lastName')}
/> />
<TextInput <TextInput
label='Email' label='Username'
placeholder='you@example.com' placeholder='Your username'
required required
{...registerForm.getInputProps('username')}
/>
<TextInput
label='Email'
description={'Optional for email verification and updates'}
placeholder='you@example.com'
{...registerForm.getInputProps('email')} {...registerForm.getInputProps('email')}
/> />
<PasswordInput <PasswordInput
label='Password' 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' placeholder='Your password'
required required
mt='md' mt='md'
@@ -184,9 +192,9 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
onSubmit={loginForm.onSubmit(async (values) => { onSubmit={loginForm.onSubmit(async (values) => {
Cookies.set('remember', 'true', { expires: 365 }); Cookies.set('remember', 'true', { expires: 365 });
if (values.remember) { if (values.remember) {
Cookies.set('email', values.email, { expires: 365 }); Cookies.set('username', values.username, { expires: 365 });
} else { } else {
Cookies.remove('email'); Cookies.remove('username');
} }
const success = await login(values); const success = await login(values);
if (success) { 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 <PasswordInput
label='Password' label='Password'
placeholder='Your password' placeholder='Your password'

View File

@@ -19,9 +19,9 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP
<UnstyledButton> <UnstyledButton>
<Group> <Group>
<Avatar src={profilePicture ? URL.createObjectURL(profilePicture) : undefined} /> <Avatar src={profilePicture ? URL.createObjectURL(profilePicture) : undefined} />
<div style={{ flex: 1 }}> <div tabIndex={-1} style={{ flex: 1, userSelect: 'none' }}>
<Text size='sm' fw={500}> <Text size='sm' fw={500}>
{user.first_name} {user.last_name} {user.firstName} {user.lastName}
</Text> </Text>
<Text c='dimmed' size='xs' style={{ textTransform: 'uppercase' }}> <Text c='dimmed' size='xs' style={{ textTransform: 'uppercase' }}>
{user.role} {user.role}
@@ -62,7 +62,7 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP
)} )}
</FileButton> </FileButton>
<Text ta='center' fz='lg' fw={500} mt='sm'> <Text ta='center' fz='lg' fw={500} mt='sm'>
{user.first_name} {user.last_name} {user.firstName} {user.lastName}
</Text> </Text>
<Text ta='center' fz='sm' c='dimmed' style={{ textTransform: 'uppercase' }}> <Text ta='center' fz='sm' c='dimmed' style={{ textTransform: 'uppercase' }}>
{user.role} {user.role}

View File

@@ -1,5 +1,5 @@
import { Avatar, Box, Burger, Button, Group, Text } from '@mantine/core'; import { Autocomplete, Avatar, Box, Burger, Button, Group, Text } from '@mantine/core';
import { useDisclosure, useToggle } from '@mantine/hooks'; import { useDisclosure, useMediaQuery, useToggle } from '@mantine/hooks';
import classes from './Header.module.css'; import classes from './Header.module.css';
import { HeaderModal } from '@components/Header/HeaderModal.tsx'; import { HeaderModal } from '@components/Header/HeaderModal.tsx';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
@@ -18,6 +18,7 @@ export function Header() {
const { user, setUser } = useUserContext(); const { user, setUser } = useUserContext();
const [opened, { toggle }] = useDisclosure(false); const [opened, { toggle }] = useDisclosure(false);
const [modalType, modalToggle] = useToggle([undefined, 'login', 'register', 'reset']); const [modalType, modalToggle] = useToggle([undefined, 'login', 'register', 'reset']);
const isMobile = useMediaQuery('(max-width: 768px)');
// const [active, setActive] = useState(links[0].link); // const [active, setActive] = useState(links[0].link);
// const navItems = links.map((link) => ( // const navItems = links.map((link) => (
@@ -35,12 +36,12 @@ export function Header() {
// </a> // </a>
// )); // ));
async function loginUser({ email, password }: { email: string; password: string }): Promise<boolean> { async function loginUser({ username, password }: { username: string; password: string }): Promise<boolean> {
const loginResponse = await login(email, password); const loginResponse = await login(username, password);
if (loginResponse) { if (loginResponse) {
setUser(loginResponse); setUser(loginResponse);
notifications.show({ notifications.show({
title: `Welcome back ${loginResponse.first_name}!`, title: `Welcome back ${loginResponse.firstName}!`,
message: `You have been logged in.`, message: `You have been logged in.`,
color: 'green', color: 'green',
autoClose: 2000, autoClose: 2000,
@@ -68,12 +69,14 @@ export function Header() {
async function registerUser({ async function registerUser({
firstName, firstName,
lastName, lastName,
username,
email, email,
password password
}: { }: {
firstName: string; firstName: string;
lastName: string; lastName: string;
email: string; username: string;
email?: string;
password: string; password: string;
}): Promise<boolean> { }): Promise<boolean> {
const id = notifications.show({ const id = notifications.show({
@@ -84,19 +87,20 @@ export function Header() {
withCloseButton: false withCloseButton: false
}); });
const registerResponse = await register({ const registerResponse = await register({
first_name: firstName, firstName: firstName,
last_name: lastName, lastName: lastName,
username: username,
email: email, email: email,
password: password password: password
}); });
if (registerResponse) { if (registerResponse) {
const loginResponse = await login(email, password); const loginResponse = await login(username, password);
if (loginResponse) { if (loginResponse) {
setUser(loginResponse); setUser(loginResponse);
notifications.update({ notifications.update({
id, id,
title: `Account created`, title: `Account created`,
message: `Welcome ${loginResponse.first_name}!`, message: `Welcome ${loginResponse.firstName}!`,
color: 'green', color: 'green',
autoClose: 2000, autoClose: 2000,
loading: false loading: false
@@ -130,17 +134,18 @@ export function Header() {
<Box> <Box>
<header className={classes.header}> <header className={classes.header}>
<Group justify='space-between' h='100%'> <Group justify='space-between' h='100%'>
<Burger opened={opened} onClick={toggle} hiddenFrom='sm' size='sm' />
<Group align='center' gap='xs'> <Group align='center' gap='xs'>
<Link to='/'> <Link to='/'>
<Avatar src='/logo.svg' alt='logo' onClick={toggle} /> <Avatar src='/logo.svg' alt='logo' onClick={toggle} />
</Link> </Link>
<Text>Aviation Data</Text> <Text size={'xl'}>Aviation Data</Text>
</Group> </Group>
{/*<Group gap={5} visibleFrom='sm' className={classes.navGroup}>*/} {/*<Group gap={5} visibleFrom='sm' className={classes.navGroup}>*/}
{/* {navItems}*/} {/* {navItems}*/}
{/*</Group>*/} {/*</Group>*/}
{!isMobile && (
<Group align='center' gap='xs'> <Group align='center' gap='xs'>
<Autocomplete placeholder={'Enter airport name or ICAO'} limit={5} />
{user ? ( {user ? (
<HeaderUser user={user} profilePicture={undefined} logout={logoutUser} /> <HeaderUser user={user} profilePicture={undefined} logout={logoutUser} />
) : ( ) : (
@@ -152,6 +157,8 @@ export function Header() {
</Group> </Group>
)} )}
</Group> </Group>
)}
{isMobile && <Burger opened={opened} onClick={toggle} size='sm' />}
</Group> </Group>
</header> </header>
</Box> </Box>

View File

@@ -0,0 +1,30 @@
import { useMap } from 'react-leaflet';
import { CustomControl } from '@components/CustomControl.tsx';
import { IconCurrentLocation } from '@tabler/icons-react';
export function LocateControl() {
const map = useMap();
function handleClick() {
if (!navigator.geolocation) {
console.warn('Geolocation is not supported by your browser');
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude } = pos.coords;
// you can use setView or flyTo
map.setView([latitude, longitude], map.getZoom());
},
(err) => {
console.warn('Unable to retrieve your location', err);
}
);
}
return (
<CustomControl onClick={handleClick} title='Go to my location'>
<IconCurrentLocation />
</CustomControl>
);
}

View File

@@ -0,0 +1,12 @@
import { ComponentPropsWithoutRef } from 'react';
export function Illustration(props: ComponentPropsWithoutRef<'svg'>) {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 362 145' {...props}>
<path
fill='currentColor'
d='M62.6 142c-2.133 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2L58.2 4c.8-1.333 2.067-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .667.533 1 1.267 1 2.2v21.2c0 .933-.333 1.733-1 2.4-.667.533-1.467.8-2.4.8H93v20.8c0 2.133-1.067 3.2-3.2 3.2H62.6zM33 90.4h26.4V51.2L33 90.4zM181.67 144.6c-7.333 0-14.333-1.333-21-4-6.666-2.667-12.866-6.733-18.6-12.2-5.733-5.467-10.266-13-13.6-22.6-3.333-9.6-5-20.667-5-33.2 0-12.533 1.667-23.6 5-33.2 3.334-9.6 7.867-17.133 13.6-22.6 5.734-5.467 11.934-9.533 18.6-12.2 6.667-2.8 13.667-4.2 21-4.2 7.467 0 14.534 1.4 21.2 4.2 6.667 2.667 12.8 6.733 18.4 12.2 5.734 5.467 10.267 13 13.6 22.6 3.334 9.6 5 20.667 5 33.2 0 12.533-1.666 23.6-5 33.2-3.333 9.6-7.866 17.133-13.6 22.6-5.6 5.467-11.733 9.533-18.4 12.2-6.666 2.667-13.733 4-21.2 4zm0-31c9.067 0 15.6-3.733 19.6-11.2 4.134-7.6 6.2-17.533 6.2-29.8s-2.066-22.2-6.2-29.8c-4.133-7.6-10.666-11.4-19.6-11.4-8.933 0-15.466 3.8-19.6 11.4-4 7.6-6 17.533-6 29.8s2 22.2 6 29.8c4.134 7.467 10.667 11.2 19.6 11.2zM316.116 142c-2.134 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2l56.6-84.6c.8-1.333 2.066-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .666.533 1 1.267 1 2.2v21.2c0 .933-.334 1.733-1 2.4-.667.533-1.467.8-2.4.8h-11.2v20.8c0 2.133-1.067 3.2-3.2 3.2h-27.2zm-29.6-51.6h26.4V51.2l-26.4 39.2z'
/>
</svg>
);
}

View File

@@ -0,0 +1,43 @@
.root {
padding-top: 80px;
padding-bottom: 80px;
}
.inner {
position: relative;
}
.image {
position: absolute;
inset: 0;
opacity: 0.75;
color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.content {
padding-top: 220px;
position: relative;
z-index: 1;
@media (max-width: $mantine-breakpoint-sm) {
padding-top: 120px;
}
}
.title {
font-family: Outfit, var(--mantine-font-family);
text-align: center;
font-weight: 500;
font-size: 38px;
@media (max-width: $mantine-breakpoint-sm) {
font-size: 32px;
}
}
.description {
max-width: 540px;
margin: auto;
margin-top: var(--mantine-spacing-xl);
margin-bottom: calc(var(--mantine-spacing-xl) * 1.5);
}

View File

@@ -0,0 +1,27 @@
import { Button, Container, Group, Text, Title } from '@mantine/core';
import { Illustration } from './Illustration';
import classes from './NotFound.module.css';
import { useNavigate } from 'react-router';
export function NotFound() {
const navigate = useNavigate();
return (
<Container className={classes.root}>
<div className={classes.inner}>
<Illustration className={classes.image} />
<div className={classes.content}>
<Title className={classes.title}>Nothing to see here</Title>
<Text c='dimmed' size='lg' ta='center' className={classes.description}>
Page you are trying to open does not exist. You may have mistyped the address, or the page has been moved to
another URL. If you think this is an error contact support.
</Text>
<Group justify='center'>
<Button size='md' onClick={() => navigate('/')}>
Take me back to home page
</Button>
</Group>
</div>
</div>
</Container>
);
}

View File

@@ -1,18 +1,18 @@
import { Header } from '@components/Header'; import { Header } from '@components/Header';
import { useUserContext } from '@components/context/UserContext.tsx'; import { useUserContext } from '@components/context/UserContext.tsx';
import { Navigate } from 'react-router'; import { NotFound } from '@components/NotFound';
export function Profile() { export function Profile() {
const { user } = useUserContext(); const { user } = useUserContext();
if (user == undefined) { if (user == undefined) {
return <Navigate to={'/'} />; return <NotFound />;
} }
return ( return (
<> <>
<Header /> <Header />
Todo: profile {user?.email} Todo: profile {user?.firstName}
</> </>
); );
} }

View File

@@ -1,15 +1,24 @@
import { ReactNode, useEffect, useState } from 'react'; import { ReactNode, useEffect, useState } from 'react';
import { UserContext } from './UserContext.tsx'; import { UserContext } from './UserContext.tsx';
import { refresh } from '@lib/account.ts'; import { profile } from '@lib/account.ts';
import { User } from '@lib/account.types.ts'; import { User } from '@lib/account.types.ts';
import { Center, Loader } from '@mantine/core'; import { Center, Loader } from '@mantine/core';
import Cookies from 'js-cookie';
const sessionExpirationName = 'session_expiration';
export function UserProvider({ children }: { children: ReactNode }) { export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | undefined>(undefined); const [user, setUser] = useState<User | undefined>(undefined);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
refresh().then((refreshUser) => { const sessionExpiration = Cookies.get(sessionExpirationName);
if (sessionExpiration != undefined) {
const date = new Date(parseInt(sessionExpiration) * 1000);
const now = new Date();
if (date > now) {
profile().then((refreshUser) => {
if (refreshUser) { if (refreshUser) {
setUser(refreshUser); setUser(refreshUser);
} else { } else {
@@ -17,6 +26,13 @@ export function UserProvider({ children }: { children: ReactNode }) {
} }
setLoading(false); setLoading(false);
}); });
} else {
Cookies.remove(sessionExpirationName);
setLoading(false);
}
} else {
setLoading(false);
}
}, []); }, []);
return ( return (

View File

@@ -1,8 +1,8 @@
import { getRequest, postRequest } from '.'; import { getRequest, postRequest } from '.';
import { RegisterUser, User } from './account.types'; import { RegisterUser, User } from './account.types';
export async function login(email: string, password: string): Promise<User | undefined> { export async function login(username: string, password: string): Promise<User | undefined> {
const response = await postRequest('account/login', { email, password }); const response = await postRequest('account/login', { username, password });
if (response?.status === 200) { if (response?.status === 200) {
return response.json(); return response.json();
} else { } else {
@@ -23,8 +23,8 @@ export async function logout() {
return await postRequest('account/logout', {}); return await postRequest('account/logout', {});
} }
export async function refresh(): Promise<User | undefined> { export async function profile(): Promise<User | undefined> {
const response = await getRequest('account/session'); const response = await getRequest('account/profile');
if (response?.status === 200) { if (response?.status === 200) {
return response.json(); return response.json();
} else { } else {

View File

@@ -1,14 +1,16 @@
export interface RegisterUser { export interface RegisterUser {
email: string; username: string;
email?: string;
password: string; password: string;
first_name: string; firstName: string;
last_name: string; lastName: string;
} }
export interface User { export interface User {
email: string; username: string;
emailVerified: boolean;
role: string; role: string;
first_name: string; firstName: string;
last_name: string; lastName: string;
profile_picture?: string; profilePicture?: string;
} }

Some files were not shown because too many files have changed in this diff Show More