Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28dc464ec5 | |||
| 6ad2afe6dd | |||
| ed98140d22 | |||
| 2ecb82ae63 | |||
| 3674623691 | |||
| e46e8ab9b4 | |||
| 1e3c75624a | |||
| 019fb77373 | |||
| abfa6b534c | |||
| a273d4134b | |||
| dc1a6de6c6 | |||
| 1d4b8338cc | |||
| 973054517c | |||
| aa38d3c29c | |||
| ebc1f30f24 | |||
| 3b5514e825 | |||
| 1ce5e61ae3 | |||
| 916abdf8ac | |||
| fdb53f0b7f | |||
| 95e4b8abf3 | |||
| 06f9a96498 | |||
| 19ed8ef2ca | |||
| d714287fd9 | |||
| 4a200b3f94 | |||
| 20d5bf26de | |||
| 3aa8954626 | |||
| 81335f1b48 | |||
| 385c04ff98 | |||
| f5446ac0eb |
41
.env
41
.env
@@ -2,16 +2,16 @@ RUST_LOG=warn,api=info
|
||||
|
||||
NGINX_HOST=localhost
|
||||
NGINX_SSL_ENABLED=false
|
||||
NGINX_PROTOCOL=http
|
||||
NGINX_HTTP_PORT=8080
|
||||
NGINX_HTTPS_PORT=8443
|
||||
# Set to 'localhost' or 'host.docker.internal' or '172.17.0.1'
|
||||
NGINX_INTERNAL_HOST=host.docker.internal
|
||||
EXTERNAL_URL=http://localhost:8080
|
||||
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_USER=aviation
|
||||
POSTGRES_PASSWORD=CHANGEME
|
||||
POSTGRES_NAME=aviation
|
||||
POSTGRES_PASSWORD=changeme
|
||||
POSTGRES_DB=aviation_db
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
REDIS_HOST=localhost
|
||||
@@ -19,28 +19,47 @@ REDIS_PORT=6379
|
||||
|
||||
MINIO_HOST=localhost
|
||||
MINIO_ROOT_USER=aviation
|
||||
MINIO_ROOT_PASSWORD=CHANGEME
|
||||
MINIO_ROOT_PASSWORD=changeme
|
||||
MINIO_BUCKET=aviation
|
||||
MINIO_PROTOCOL=http
|
||||
MINIO_PORT=9000
|
||||
MINIO_INTERNAL_PORT=9001
|
||||
MINIO_BROWSER_REDIRECT_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTP_PORT}/minio/
|
||||
MINIO_BROWSER_REDIRECT_URL=${EXTERNAL_URL}/minio/
|
||||
|
||||
UI_PORT=3000
|
||||
API_PORT=5000
|
||||
API_METAR_TIME_OFFSET=3000
|
||||
API_METAR_TIME_OFFSET=1800
|
||||
|
||||
SSL_CA_NAME=ca
|
||||
SSL_CA_PATH=../ssl/${SSL_CA_NAME}.pem
|
||||
SSL_CERT_PATH=../ssl/localhost.crt
|
||||
SSL_CERT_KEY_PATH=../ssl/localhost.key
|
||||
|
||||
VITE_API_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTP_PORT}/api
|
||||
SMTP_USERNAME=smtp-user
|
||||
SMTP_PASSWORD=smtp-password
|
||||
SMTP_FROM=noreply@example.com
|
||||
SMTP_SERVER=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_ADDITIONAL_SERVER_ALLOWED_HOSTS:${NGINX_HOST}
|
||||
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=${NGINX_HOST}
|
||||
|
||||
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
1
.gitignore
vendored
@@ -8,7 +8,6 @@
|
||||
node_modules
|
||||
target/
|
||||
dist/
|
||||
Cargo.lock
|
||||
ssl/
|
||||
|
||||
.DS_Store
|
||||
|
||||
39
Makefile
39
Makefile
@@ -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}'
|
||||
@echo
|
||||
|
||||
format: format-api format-ui ## Format code
|
||||
format: format-api format-ui format-adsb ## Format code
|
||||
|
||||
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 #
|
||||
@@ -24,12 +24,22 @@ psql: ## Connect to the PSQL DB
|
||||
format-api: ## Format code
|
||||
@cd api && cargo fmt
|
||||
|
||||
build-api: ## Build the project
|
||||
build-api: ## Build the API project
|
||||
@cd api && cargo build
|
||||
|
||||
run-api: ## Run the API project
|
||||
@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 #
|
||||
#################
|
||||
@@ -66,24 +76,24 @@ down-backend: backend-down
|
||||
run: ## Run the api
|
||||
@cd api && cargo run
|
||||
|
||||
frontend-up: ## Start Docker containers
|
||||
@docker compose --profile frontend up -d
|
||||
dev-up: ## Start Docker containers
|
||||
@docker compose --profile dev up -d
|
||||
|
||||
up-frontend: frontend-up
|
||||
up-dev: dev-up
|
||||
|
||||
frontend-down: ## Stop Docker containers
|
||||
@docker compose --profile frontend down
|
||||
dev-down: ## Stop Docker containers
|
||||
@docker compose --profile dev down
|
||||
|
||||
down-frontend: frontend-down
|
||||
down-dev: dev-down
|
||||
|
||||
docker-prune: ## Prune the docker system
|
||||
@docker system prune -a
|
||||
|
||||
docker-clean: ## Stop the docker containers and remove volumes
|
||||
@docker compose --profile frontend --profile api --profile backend down -v
|
||||
@docker compose --profile dev --profile api --profile backend down -v
|
||||
|
||||
docker-down: ## Stop the docker container
|
||||
@docker compose --profile frontend --profile api --profile backend down
|
||||
@docker compose --profile dev --profile api --profile backend down
|
||||
|
||||
docker-up: ## Start the docker container
|
||||
@docker compose --profile backend --profile api up -d
|
||||
@@ -112,6 +122,10 @@ push: registry=$(if $(r),$(r),gitea.bensherriff.com/bsherriff)
|
||||
push: platform=$(if $(p),$(p),linux/amd64,linux/arm64)
|
||||
push: image=${registry}/aviation-${folder}:${version}
|
||||
push: ## Build and push a specific docker image (`make push f=httpd`)
|
||||
docker buildx create \
|
||||
--use \
|
||||
--name aviation-builder \
|
||||
--platform ${platform} || true; \
|
||||
docker buildx build \
|
||||
-f ${folder}/Dockerfile \
|
||||
--platform ${platform} \
|
||||
@@ -122,6 +136,9 @@ push: ## Build and push a specific docker image (`make push f=httpd`)
|
||||
--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: ## Generate a cert for the given domain
|
||||
./scripts/generate_cert.sh ${domain}
|
||||
|
||||
28
README.md
28
README.md
@@ -3,12 +3,17 @@
|
||||
<h1 align="center">Aviation Data</h1>
|
||||
</div>
|
||||
|
||||
[Swagger Docs](https://aviation.bensherriff.com/swagger/#/)
|
||||
|
||||
## Makefile
|
||||
* `make` or `make help` to list all commands
|
||||
* `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
|
||||
to the application
|
||||
|
||||
**WARNING**: Running `make docker-clean` or `make docker-refresh` will wipe the database, redis, and minio data
|
||||
|
||||
## Setup
|
||||
|
||||
1. Override any environment variables in `.env.local`
|
||||
@@ -20,7 +25,16 @@ to the application
|
||||
* Running just `make cert` will generate `localhost` certificates
|
||||
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
|
||||
Start with `make docker-up`
|
||||
|
||||
The most likely to change environment variables are the following:
|
||||
* `UI_PORT`
|
||||
* `API_PORT`
|
||||
@@ -28,6 +42,8 @@ The most likely to change environment variables are the following:
|
||||
* `POSTGRES_PASSWORD` - Please change in production environments
|
||||
* `MINIO_HOST` - Match to the `NGINX_HOST` value (see below)
|
||||
* `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_INTERNAL_HOST` - Typically `host.docker.internal` or `172.17.0.1`
|
||||
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_PASSWORD` - Please change in production environments
|
||||
* `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`.
|
||||
For example: `aviation.bensherriff.com`
|
||||
|
||||
@@ -69,5 +85,15 @@ The following resources were used to help decode METARS.
|
||||
### 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
|
||||
- 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
475
adsb/Cargo.lock
generated
Normal file
@@ -0,0 +1,475 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adsb"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
"env_logger",
|
||||
"log",
|
||||
"rusb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362"
|
||||
dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "697b5419f348fd5ae2478e8018cb016c00a5881c7f46c717de98ffd135a5651c"
|
||||
dependencies = [
|
||||
"nix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a064218214dc6a10fbae5ec5fa888d80c45d611aba169222fc272072bf7aef6"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "199b7932d97e325aff3a7030e141eafe7f2c6268e1d1b24859b753a627f45254"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.172"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
|
||||
|
||||
[[package]]
|
||||
name = "libusb1-sys"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "rusb"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libusb1-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
11
adsb/Cargo.toml
Normal file
11
adsb/Cargo.toml
Normal 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
10
adsb/README.md
Normal 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
3
adsb/rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy"]
|
||||
3
adsb/rustfmt.toml
Normal file
3
adsb/rustfmt.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
indent_style = "Block"
|
||||
reorder_imports = false
|
||||
tab_spaces = 2
|
||||
62
adsb/src/constants.rs
Normal file
62
adsb/src/constants.rs
Normal 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
550
adsb/src/device.rs
Normal 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
44
adsb/src/error.rs
Normal 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
473
adsb/src/frame.rs
Normal 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
44
adsb/src/hex.rs
Normal 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
98
adsb/src/main.rs
Normal 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
1406
api/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,40 @@
|
||||
[package]
|
||||
name = "api"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
authors = ["Ben Sherriff <ben@bensherriff.com>"]
|
||||
repository = "https://gitea.bensherriff.com/bsherriff/aviation"
|
||||
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
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4.10.2"
|
||||
actix-cors = "0.7.1"
|
||||
actix-web-httpauth = "0.8.2"
|
||||
actix-multipart = "0.7.2"
|
||||
chrono = { version = "0.4.40", features = ["serde"] }
|
||||
chrono = { version = "0.4.41", features = ["serde"] }
|
||||
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"
|
||||
reqwest = "0.12.15"
|
||||
serde = {version = "1.0.219", features = ["derive"]}
|
||||
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"] }
|
||||
log = "0.4.27"
|
||||
argon2 = "0.5.3"
|
||||
redis = { version = "0.29.5", features = ["tokio-comp", "connection-manager", "r2d2", "json"] }
|
||||
redis = { version = "0.31.0", features = ["tokio-comp", "connection-manager", "r2d2", "json"] }
|
||||
regex = "1.11.1"
|
||||
futures-util = "0.3.31"
|
||||
rust-s3 = "0.35.1"
|
||||
rand = "0.9.0"
|
||||
rand = "0.9.1"
|
||||
rand_chacha = "0.9.0"
|
||||
geo-types = "0.7.15"
|
||||
byteorder = "1.5.0"
|
||||
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"
|
||||
|
||||
@@ -12,9 +12,11 @@ CREATE TABLE IF NOT EXISTS airports (
|
||||
elevation_ft REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
latitude REAL NOT NULL,
|
||||
geometry GEOMETRY(POINT, 4326) NOT NULL,
|
||||
has_tower 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);
|
||||
@@ -24,11 +26,12 @@ CREATE INDEX ON airports (category);
|
||||
CREATE INDEX ON airports (iso_country);
|
||||
CREATE INDEX ON airports (iso_region);
|
||||
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 (
|
||||
id UUID PRIMARY KEY NOT NULL,
|
||||
icao TEXT NOT NULL,
|
||||
icao TEXT NOT NULL REFERENCES airports(icao) ON DELETE CASCADE,
|
||||
runway_id TEXT NOT NULL,
|
||||
length_ft REAL NOT NULL,
|
||||
width_ft REAL NOT NULL,
|
||||
@@ -36,17 +39,20 @@ CREATE TABLE IF NOT EXISTS runways (
|
||||
);
|
||||
|
||||
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,
|
||||
icao TEXT NOT NULL,
|
||||
icao TEXT NOT NULL REFERENCES airports(icao) ON DELETE CASCADE,
|
||||
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 frequencies (frequency_mhz);
|
||||
CREATE INDEX ON communications (icao);
|
||||
CREATE INDEX ON communications (frequency_id);
|
||||
CREATE INDEX ON communications (name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metars (
|
||||
icao TEXT NOT NULL,
|
||||
@@ -59,11 +65,14 @@ CREATE TABLE IF NOT EXISTS metars (
|
||||
CREATE INDEX ON metars (observation_time DESC);
|
||||
|
||||
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,
|
||||
role TEXT NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
avatar TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
);
|
||||
@@ -1,3 +1,3 @@
|
||||
indent_style = "Block"
|
||||
reorder_imports = false
|
||||
reorder_imports = true
|
||||
tab_spaces = 2
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload, http};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use super::{SESSION_COOKIE_NAME, Session};
|
||||
use crate::{error::Error, users::User};
|
||||
use super::{Session, SESSION_COOKIE_NAME};
|
||||
use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Auth {
|
||||
@@ -34,13 +34,13 @@ impl FromRequest for Auth {
|
||||
return Err(Error::new(401, "API Key does not exist".to_string()).into());
|
||||
}
|
||||
};
|
||||
match User::select(&api_key.email).await {
|
||||
match User::select(&api_key.username).await {
|
||||
Some(user) => Ok(Auth {
|
||||
session_id: None,
|
||||
api_key: Some(key_id),
|
||||
user,
|
||||
}),
|
||||
None => Err(Error::new(404, format!("User {} not found", api_key.email)).into()),
|
||||
None => Err(Error::new(404, format!("User {} not found", api_key.username)).into()),
|
||||
}
|
||||
};
|
||||
return Box::pin(fut);
|
||||
@@ -79,13 +79,13 @@ impl FromRequest for Auth {
|
||||
// Verify the session
|
||||
let fut = async move {
|
||||
match Session::verify(&session_id, &ip_address).await {
|
||||
Ok(session) => match User::select(&session.email).await {
|
||||
Ok(session) => match User::select(&session.username).await {
|
||||
Some(user) => Ok(Auth {
|
||||
session_id: Some(session_id),
|
||||
api_key: None,
|
||||
user,
|
||||
}),
|
||||
None => Err(Error::new(404, format!("User {} not found", session.email)).into()),
|
||||
None => Err(Error::new(404, format!("User {} not found", session.username)).into()),
|
||||
},
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
|
||||
161
api/src/account/email_token.rs
Normal file
161
api/src/account/email_token.rs
Normal 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 didn’t 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(())
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, SaltString},
|
||||
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||
password_hash::{SaltString, rand_core::OsRng},
|
||||
};
|
||||
use rand::distr::Alphanumeric;
|
||||
use rand::prelude::*;
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
|
||||
mod auth;
|
||||
mod email_token;
|
||||
mod model;
|
||||
mod routes;
|
||||
mod session;
|
||||
|
||||
pub use auth::*;
|
||||
pub use session::*;
|
||||
pub use routes::init_routes;
|
||||
pub use session::*;
|
||||
|
||||
use crate::error::{Error, ApiResult};
|
||||
use crate::error::{ApiResult, Error};
|
||||
|
||||
pub fn csprng(take: usize) -> String {
|
||||
// Generate a CSPRNG 128-bit (16 byte) ID using alphanumeric characters (a-z, A-Z, 0-9)
|
||||
|
||||
24
api/src/account/model.rs
Normal file
24
api/src/account/model.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PasswordRequirements {
|
||||
pub max_length: Option<usize>,
|
||||
pub min_length: Option<usize>,
|
||||
pub lowercase_count: Option<usize>,
|
||||
pub uppercase_count: Option<usize>,
|
||||
pub numeric_count: Option<usize>,
|
||||
pub special_count: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for PasswordRequirements {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_length: Some(128),
|
||||
min_length: Some(6),
|
||||
lowercase_count: None,
|
||||
uppercase_count: None,
|
||||
numeric_count: None,
|
||||
special_count: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,32 @@
|
||||
use actix_web::{post, web, HttpResponse, ResponseError, HttpRequest, put, get};
|
||||
use crate::{
|
||||
account::{verify_hash, Session, SESSION_COOKIE_NAME},
|
||||
account::{SESSION_COOKIE_NAME, Session, verify_hash},
|
||||
error::Error,
|
||||
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;
|
||||
|
||||
#[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")]
|
||||
async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse {
|
||||
let register_user = user.into_inner();
|
||||
let username = register_user.username.clone();
|
||||
let email = register_user.email.clone();
|
||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||
let insert_user: User = match register_user.to_user() {
|
||||
@@ -22,75 +38,221 @@ async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpRes
|
||||
Ok(user) => {
|
||||
let user_response: UserResponse = user.into();
|
||||
log::info!(
|
||||
"Successful user registration [Email: {}] [IP Address: {}]",
|
||||
email,
|
||||
"Successful user registration [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
|
||||
// Send confirmation email
|
||||
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)
|
||||
}
|
||||
Err(err) => {
|
||||
// Obfuscate the service error message to prevent leaking database details
|
||||
if err.status == 409 {
|
||||
log::warn!(
|
||||
"Duplicate user registration attempt [Email: {}] [IP Address: {}]",
|
||||
email,
|
||||
"Duplicate user registration attempt [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
HttpResponse::Conflict().finish()
|
||||
} else {
|
||||
log::error!("attemptFailed to register user [Email: {}]: {}", email, err);
|
||||
log::error!("Failed to register user [User: {}]: {}", username, err);
|
||||
ResponseError::error_response(&err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/login")]
|
||||
async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
|
||||
let email = &request.email;
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct ConfirmEmail {
|
||||
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 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,
|
||||
None => return HttpResponse::Unauthorized().finish(),
|
||||
};
|
||||
|
||||
if verify_hash(&request.password, &query_user.password_hash) {
|
||||
// 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_exp_cookie = session.expiration_cookie();
|
||||
// Save the session to the database
|
||||
if let Err(err) = session.store().await {
|
||||
log::error!(
|
||||
"Login attempt failure [Email: {}] [IP Address: {}]: {}",
|
||||
email,
|
||||
"Login attempt failure [User: {}] [IP Address: {}]: {}",
|
||||
username,
|
||||
ip_address,
|
||||
err
|
||||
);
|
||||
return ResponseError::error_response(&Error::new(500, err.to_string()));
|
||||
}
|
||||
log::info!(
|
||||
"Successful login attempt [Email: {}] [IP Address: {}]",
|
||||
email,
|
||||
"Successful login attempt [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
let user_response: UserResponse = query_user.into();
|
||||
HttpResponse::Ok()
|
||||
.cookie(session_cookie)
|
||||
.cookie(session_exp_cookie)
|
||||
.json(user_response)
|
||||
} else {
|
||||
log::error!(
|
||||
"Invalid login attempt [Email: {}] [IP Address: {}]",
|
||||
email,
|
||||
"Invalid login attempt [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
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")]
|
||||
async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
|
||||
let email = auth.user.email;
|
||||
let username = auth.user.username;
|
||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||
// Delete the session from the store
|
||||
match req.cookie(SESSION_COOKIE_NAME) {
|
||||
@@ -98,8 +260,8 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
|
||||
let session_id = cookie.value().to_string();
|
||||
if let Err(err) = Session::delete(&session_id).await {
|
||||
log::error!(
|
||||
"Logout attempt failure [Email: {}] [IP Address: {}]: {}",
|
||||
email,
|
||||
"Logout attempt failure [User: {}] [IP Address: {}]: {}",
|
||||
username,
|
||||
ip_address,
|
||||
err
|
||||
);
|
||||
@@ -108,8 +270,8 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
|
||||
}
|
||||
None => {
|
||||
log::error!(
|
||||
"Invalid logout attempt [Email: {}] [IP Address: {}]",
|
||||
email,
|
||||
"Invalid logout attempt [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
return ResponseError::error_response(&Error::new(400, "Invalid session".to_string()));
|
||||
@@ -117,15 +279,92 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Successful logout attempt [Email: {}] [IP Address: {}]",
|
||||
email,
|
||||
"Successful logout attempt [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
HttpResponse::Ok().cookie(Session::empty_cookie()).finish()
|
||||
HttpResponse::Ok()
|
||||
.cookie(Session::empty_cookie())
|
||||
.cookie(Session::empty_expiration_cookie())
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[get("/session")]
|
||||
async fn validate_session(req: HttpRequest) -> HttpResponse {
|
||||
#[utoipa::path(
|
||||
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();
|
||||
// Verify a session cookie exists
|
||||
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 = match Session::replace(&session_id, &ip_address).await {
|
||||
Ok(session) => session,
|
||||
Err(err) => {
|
||||
Err(_) => {
|
||||
log::error!(
|
||||
"Invalid session validate attempt [Session: {}] [IP Address: {}]",
|
||||
session_id,
|
||||
@@ -142,93 +381,182 @@ async fn validate_session(req: HttpRequest) -> HttpResponse {
|
||||
);
|
||||
return HttpResponse::Unauthorized()
|
||||
.cookie(Session::empty_cookie())
|
||||
.cookie(Session::empty_expiration_cookie())
|
||||
.finish();
|
||||
}
|
||||
};
|
||||
let email = &session.email;
|
||||
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 username = &session.username;
|
||||
let session_cookie = session.cookie();
|
||||
let session_exp_cookie = session.expiration_cookie();
|
||||
|
||||
log::info!(
|
||||
"Successful session validate attempt [Email: {}] [IP Address: {}]",
|
||||
email,
|
||||
"Successful session validate attempt [User: {}] [IP Address: {}]",
|
||||
username,
|
||||
ip_address
|
||||
);
|
||||
HttpResponse::Ok()
|
||||
.cookie(session_cookie)
|
||||
.json(user_response)
|
||||
.cookie(session_exp_cookie)
|
||||
.finish()
|
||||
}
|
||||
None => HttpResponse::Unauthorized()
|
||||
.cookie(Session::empty_cookie())
|
||||
.cookie(Session::empty_expiration_cookie())
|
||||
.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")]
|
||||
async fn change_password(
|
||||
password: web::Json<String>,
|
||||
request: web::Json<ChangePassword>,
|
||||
req: HttpRequest,
|
||||
auth: Auth,
|
||||
) -> HttpResponse {
|
||||
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();
|
||||
};
|
||||
|
||||
let update_user = UpdateUser {
|
||||
email: None,
|
||||
password: Some(password.into_inner()),
|
||||
email_verified: None,
|
||||
password: Some(request.password.clone()),
|
||||
role: None,
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
avatar: None,
|
||||
};
|
||||
|
||||
match update_user.update(&email).await {
|
||||
match update_user.update(&username).await {
|
||||
Ok(user) => {
|
||||
let response: UserResponse = user.into();
|
||||
log::info!(
|
||||
"Successful password change attempt [Email: {}] [IP Address: {}]",
|
||||
&email,
|
||||
"Successful password change attempt [User: {}] [IP Address: {}]",
|
||||
&username,
|
||||
ip_address
|
||||
);
|
||||
HttpResponse::Ok().json(response)
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Invalid password change attempt [Email: {}] [IP Address: {}]: {}",
|
||||
&email,
|
||||
"Invalid password change attempt [User: {}] [IP Address: {}]: {}",
|
||||
&username,
|
||||
ip_address,
|
||||
err
|
||||
);
|
||||
ResponseError::error_response(&Error::new(500, err.to_string()))
|
||||
ResponseError::error_response(&err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/password-reset")]
|
||||
async fn password_reset(req: HttpRequest, _auth: Auth) -> HttpResponse {
|
||||
let _ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct PasswordReset {
|
||||
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()
|
||||
}
|
||||
|
||||
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(
|
||||
web::scope("account")
|
||||
scope::scope("/account")
|
||||
.service(register)
|
||||
.service(confirm_email_registration)
|
||||
.service(resend_email_verification)
|
||||
.service(login)
|
||||
.service(logout)
|
||||
.service(get_profile)
|
||||
.service(session_refresh)
|
||||
.service(change_password)
|
||||
.service(validate_session),
|
||||
.service(reset_password)
|
||||
.service(confirm_password_reset),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,37 @@
|
||||
use actix_web::cookie::{time::Duration, Cookie};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use redis::{AsyncCommands, RedisResult};
|
||||
use tokio::task;
|
||||
use super::{csprng, hash, verify_hash};
|
||||
use crate::{
|
||||
db::redis_async_connection,
|
||||
error::{Error, ApiResult},
|
||||
error::{ApiResult, Error},
|
||||
};
|
||||
use super::{csprng, hash, verify_hash};
|
||||
use actix_web::cookie::{Cookie, time::Duration};
|
||||
use chrono::{DateTime, Utc};
|
||||
use redis::{AsyncCommands, RedisResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::task;
|
||||
|
||||
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
|
||||
pub const SESSION_COOKIE_NAME: &str = "session";
|
||||
pub const SESSION_EXPIRATION_COOKIE_NAME: &str = "session_expiration";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub session_id: String,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub ip_address: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn default(email: &str, ip_address: &str) -> Self {
|
||||
Self::new(64, email, ip_address, Some(DEFAULT_SESSION_TTL))
|
||||
pub fn default(username: &str, ip_address: &str) -> Self {
|
||||
Self::new(64, username, ip_address, Some(DEFAULT_SESSION_TTL))
|
||||
}
|
||||
|
||||
pub fn new(take: usize, 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();
|
||||
Self {
|
||||
session_id: csprng(take),
|
||||
email: email.to_string(),
|
||||
username: username.to_string(),
|
||||
ip_address: hash(&ip_address).unwrap(),
|
||||
expires_at: match 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?;
|
||||
Ok(session)
|
||||
}
|
||||
@@ -118,8 +119,8 @@ impl Session {
|
||||
if let Ok(environment) = std::env::var("ENVIRONMENT") {
|
||||
if environment == "development" || environment == "dev" {
|
||||
log::trace!(
|
||||
"Development cookie [Email: {}]: {}",
|
||||
self.email,
|
||||
"Session cookie [User: {}]: {}",
|
||||
self.username,
|
||||
self.session_id
|
||||
);
|
||||
cookie.set_secure(false);
|
||||
@@ -130,6 +131,33 @@ impl Session {
|
||||
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> {
|
||||
let mut cookie = Cookie::build(SESSION_COOKIE_NAME, "")
|
||||
.path("/")
|
||||
@@ -147,4 +175,21 @@ impl Session {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mod model;
|
||||
mod routes;
|
||||
pub mod model;
|
||||
pub mod routes;
|
||||
|
||||
pub use model::*;
|
||||
pub use routes::init_routes;
|
||||
|
||||
@@ -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::{
|
||||
AirportCategory, Frequency, FrequencyRow, Runway, RunwayRow, UpdateFrequency, UpdateRunway,
|
||||
AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication,
|
||||
UpdateRunway,
|
||||
};
|
||||
use crate::db;
|
||||
use crate::error::{ApiResult, Error};
|
||||
use crate::metars::Metar;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures_util::try_join;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Postgres, QueryBuilder};
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
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 icao: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -33,13 +38,14 @@ pub struct Airport {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub has_beacon: Option<bool>,
|
||||
pub runways: Vec<Runway>,
|
||||
pub frequencies: Vec<Frequency>,
|
||||
pub communications: Vec<Communication>,
|
||||
pub public: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_metar: Option<Metar>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct AirportQuery {
|
||||
pub page: 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 north_east_lat: f32,
|
||||
pub north_east_lon: f32,
|
||||
@@ -121,9 +175,10 @@ struct AirportRow {
|
||||
pub has_tower: Option<bool>,
|
||||
pub has_beacon: Option<bool>,
|
||||
pub public: bool,
|
||||
pub metar_observation_time: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateAirport {
|
||||
pub icao: Option<String>,
|
||||
pub iata: Option<String>,
|
||||
@@ -139,8 +194,9 @@ pub struct UpdateAirport {
|
||||
pub has_tower: Option<bool>,
|
||||
pub has_beacon: Option<bool>,
|
||||
pub runways: Option<Vec<UpdateRunway>>,
|
||||
pub frequencies: Option<Vec<UpdateFrequency>>,
|
||||
pub communications: Option<Vec<UpdateCommunication>>,
|
||||
pub public: Option<bool>,
|
||||
pub latest_metar_observation: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Into<AirportRow> for Airport {
|
||||
@@ -160,6 +216,10 @@ impl Into<AirportRow> for Airport {
|
||||
has_tower: self.has_tower,
|
||||
has_beacon: self.has_beacon,
|
||||
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_beacon: airport.has_beacon,
|
||||
runways: vec![],
|
||||
frequencies: vec![],
|
||||
communications: vec![],
|
||||
public: airport.public,
|
||||
latest_metar: None,
|
||||
}
|
||||
@@ -195,19 +255,22 @@ impl From<AirportRow> for Airport {
|
||||
}
|
||||
|
||||
impl Airport {
|
||||
pub async fn select(client: &Client, icao: &str, metar: bool) -> Option<Self> {
|
||||
pub async fn select(icao: &str, metar: bool) -> Option<Self> {
|
||||
let pool = db::pool();
|
||||
|
||||
let airport_fut = async {
|
||||
sqlx::query_as(&format!("SELECT * FROM {} WHERE icao = $1", TABLE_NAME))
|
||||
.bind(icao)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT {} FROM {} WHERE icao = $1",
|
||||
DEFAULT_COLUMNS, TABLE_NAME
|
||||
))
|
||||
.bind(icao)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
};
|
||||
|
||||
let metar_fut = async {
|
||||
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)),
|
||||
Err(err) => {
|
||||
log::error!("{}", err);
|
||||
@@ -220,10 +283,10 @@ impl Airport {
|
||||
};
|
||||
|
||||
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) =
|
||||
tokio::join!(airport_fut, runways_fut, frequencies_fut, metar_fut);
|
||||
let (airport_result, runways_result, communications_result, metar_result) =
|
||||
tokio::join!(airport_fut, runways_fut, communications_fut, metar_fut);
|
||||
|
||||
let airport_row: Option<AirportRow> = match airport_result {
|
||||
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,
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Error retrieving frequencies for airport '{}': {}",
|
||||
"Error retrieving communications for airport '{}': {}",
|
||||
icao,
|
||||
err
|
||||
);
|
||||
@@ -264,17 +327,17 @@ impl Airport {
|
||||
airport_row.map(|row| {
|
||||
let mut airport: Airport = row.into();
|
||||
airport.runways = runways;
|
||||
airport.frequencies = frequencies;
|
||||
airport.communications = communications;
|
||||
airport.latest_metar = metar;
|
||||
airport
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn select_all(client: &Client, query: &AirportQuery) -> ApiResult<Vec<Self>> {
|
||||
pub async fn select_all(query: &AirportQuery) -> ApiResult<Vec<Self>> {
|
||||
let pool = db::pool();
|
||||
|
||||
let mut builder = QueryBuilder::<Postgres>::new("SELECT * FROM ");
|
||||
builder.push(TABLE_NAME);
|
||||
let mut builder =
|
||||
QueryBuilder::<Postgres>::new(format!("SELECT {} FROM {}", DEFAULT_COLUMNS, TABLE_NAME));
|
||||
|
||||
let mut has_where = false;
|
||||
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_bounds(&mut builder, &mut has_where, &query.bounds)?;
|
||||
|
||||
// Order by AircraftCategory
|
||||
builder.push(" ORDER BY CASE category ");
|
||||
builder.push(" ORDER BY (metar_observation_time IS NULL) ASC, ");
|
||||
builder.push(" CASE category ");
|
||||
builder.push(" WHEN 'large_airport' THEN 1 ");
|
||||
builder.push(" WHEN 'medium_airport' THEN 2 ");
|
||||
builder.push(" WHEN 'small_airport' THEN 3 ");
|
||||
@@ -333,12 +396,12 @@ impl Airport {
|
||||
}
|
||||
|
||||
// Bulk update airport sub-fields
|
||||
let icaos: Vec<String> = airports.iter().map(|a| a.icao.clone()).collect();
|
||||
let icaos: Vec<String> = airports.iter().map(|a| a.icao.to_uppercase()).collect();
|
||||
|
||||
let runway_future = Runway::select_all_map(icaos.clone());
|
||||
let frequency_future = Frequency::select_all_map(icaos.clone());
|
||||
let runway_future = Runway::select_all_map(&icaos);
|
||||
let frequency_future = Communication::select_all_map(&icaos);
|
||||
let metar_future = if query.metars.unwrap_or(false) {
|
||||
Some(Metar::find_all(client, &icaos, &false))
|
||||
Some(Metar::get_all_distinct(&icaos))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -366,7 +429,7 @@ impl Airport {
|
||||
|
||||
for airport in airports.iter_mut() {
|
||||
airport.runways = runway_map.get(&airport.icao).cloned().unwrap_or_default();
|
||||
airport.frequencies = frequency_map
|
||||
airport.communications = frequency_map
|
||||
.get(&airport.icao)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
@@ -421,29 +484,31 @@ impl Airport {
|
||||
let pool = db::pool();
|
||||
|
||||
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 {
|
||||
all_runway_rows.push(Runway::into(runway, &self.icao));
|
||||
}
|
||||
for frequency in &self.frequencies {
|
||||
all_frequency_rows.push(Frequency::into(frequency, &self.icao));
|
||||
for frequency in &self.communications {
|
||||
all_frequency_rows.push(Communication::into(frequency, &self.icao));
|
||||
}
|
||||
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!(
|
||||
r#"
|
||||
INSERT INTO {} (
|
||||
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 (
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$8, $9, $10, $11, $12, $13, $14
|
||||
$1, $2, $3, $4, $5, $6, $7, $8,
|
||||
$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.iata)
|
||||
@@ -469,27 +534,25 @@ impl Airport {
|
||||
let pool = db::pool();
|
||||
let chunk_size = 1000;
|
||||
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
|
||||
.into_iter()
|
||||
.map(|airport| {
|
||||
for runway in &airport.runways {
|
||||
all_runway_rows.push(Runway::into(runway, &airport.icao));
|
||||
}
|
||||
for frequency in &airport.frequencies {
|
||||
all_frequency_rows.push(Frequency::into(frequency, &airport.icao));
|
||||
for frequency in &airport.communications {
|
||||
all_frequency_rows.push(Communication::into(frequency, &airport.icao));
|
||||
}
|
||||
airport.into()
|
||||
})
|
||||
.collect();
|
||||
Runway::insert_all(&all_runway_rows).await?;
|
||||
Frequency::insert_all(&all_frequency_rows).await?;
|
||||
|
||||
for chunk in airport_rows.chunks(chunk_size) {
|
||||
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(
|
||||
"INSERT INTO airports (icao, iata, local, name, category, \
|
||||
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| {
|
||||
b.push_bind(&row.icao)
|
||||
@@ -503,6 +566,11 @@ impl Airport {
|
||||
.push_bind(row.elevation_ft)
|
||||
.push_bind(row.longitude)
|
||||
.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_beacon)
|
||||
.push_bind(row.public);
|
||||
@@ -512,11 +580,27 @@ impl Airport {
|
||||
query.execute(pool).await?;
|
||||
}
|
||||
|
||||
Runway::insert_all(&all_runway_rows).await?;
|
||||
Communication::insert_all(&all_frequency_rows).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 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(())
|
||||
}
|
||||
|
||||
@@ -557,7 +641,7 @@ impl Airport {
|
||||
column: &str,
|
||||
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.
|
||||
let values: Vec<&str> = value_str
|
||||
.split(',')
|
||||
@@ -586,7 +670,7 @@ impl Airport {
|
||||
field: &'a Option<String>,
|
||||
) {
|
||||
// Query column like
|
||||
if let Some(ref value) = field {
|
||||
if let Some(value) = field {
|
||||
if !*has_where {
|
||||
builder.push(" WHERE ");
|
||||
*has_where = true;
|
||||
@@ -607,7 +691,7 @@ impl Airport {
|
||||
field: &'a Option<String>,
|
||||
) -> ApiResult<()> {
|
||||
// Query bounds
|
||||
if let Some(ref bounds_string) = field {
|
||||
if let Some(bounds_string) = field {
|
||||
if !*has_where {
|
||||
builder.push(" WHERE ");
|
||||
*has_where = true;
|
||||
@@ -617,15 +701,15 @@ impl Airport {
|
||||
let bounds = Bounds::parse(bounds_string)?;
|
||||
builder
|
||||
.push("(")
|
||||
.push("latitude BETWEEN ")
|
||||
.push_bind(bounds.south_west_lat)
|
||||
.push(" AND ")
|
||||
.push_bind(bounds.north_east_lat)
|
||||
.push(" AND ")
|
||||
.push("longitude BETWEEN ")
|
||||
.push("geometry && ST_MakeEnvelope(")
|
||||
.push_bind(bounds.south_west_lon)
|
||||
.push(" AND ")
|
||||
.push(", ")
|
||||
.push_bind(bounds.south_west_lat)
|
||||
.push(", ")
|
||||
.push_bind(bounds.north_east_lon)
|
||||
.push(", ")
|
||||
.push_bind(bounds.north_east_lat)
|
||||
.push(", 4326)")
|
||||
.push(")");
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Display;
|
||||
use std::str::FromStr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub enum AirportCategory {
|
||||
#[serde(rename = "small_airport")]
|
||||
Small,
|
||||
|
||||
131
api/src/airports/model/communication.rs
Normal file
131
api/src/airports/model/communication.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
mod airport;
|
||||
mod airport_category;
|
||||
mod frequency;
|
||||
mod communication;
|
||||
mod runway;
|
||||
|
||||
pub use airport::*;
|
||||
pub use airport_category::*;
|
||||
pub use frequency::*;
|
||||
pub use communication::*;
|
||||
pub use runway::*;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Postgres, QueryBuilder};
|
||||
use uuid::Uuid;
|
||||
use crate::db;
|
||||
use crate::error::ApiResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Postgres, QueryBuilder};
|
||||
use std::collections::HashMap;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
const TABLE_NAME: &str = "runways";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Runway {
|
||||
#[serde(rename = "id")]
|
||||
pub runway_id: String,
|
||||
@@ -26,7 +27,7 @@ pub struct RunwayRow {
|
||||
pub surface: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateRunway {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icao: Option<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 runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!(
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
use futures_util::stream::StreamExt as _;
|
||||
|
||||
use crate::{
|
||||
airports::Airport,
|
||||
db::Paged,
|
||||
account::{Auth, verify_role},
|
||||
AppState,
|
||||
};
|
||||
use actix_multipart::Multipart;
|
||||
use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError};
|
||||
use crate::airports::{AirportQuery, UpdateAirport};
|
||||
use crate::users::ADMIN_ROLE;
|
||||
use crate::{
|
||||
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")]
|
||||
async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
|
||||
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()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "airport",
|
||||
params(
|
||||
AirportQuery
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "", body = [Airport]),
|
||||
),
|
||||
)]
|
||||
#[get("")]
|
||||
async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
|
||||
async fn get_airports(req: HttpRequest) -> HttpResponse {
|
||||
let mut query = match web::Query::<AirportQuery>::from_query(req.query_string()) {
|
||||
Ok(q) => q.into_inner(),
|
||||
Err(err) => {
|
||||
@@ -72,8 +103,7 @@ async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpRespon
|
||||
query.limit = Some(limit);
|
||||
query.page = Some(page);
|
||||
|
||||
let client = &data.client;
|
||||
match Airport::select_all(client, &query).await {
|
||||
match Airport::select_all(&query).await {
|
||||
Ok(airports) => HttpResponse::Ok().json(Paged {
|
||||
data: airports,
|
||||
page,
|
||||
@@ -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}")]
|
||||
async fn get_airport(
|
||||
data: web::Data<AppState>,
|
||||
icao: web::Path<String>,
|
||||
req: HttpRequest,
|
||||
) -> HttpResponse {
|
||||
async fn get_airport(icao: web::Path<String>, req: HttpRequest) -> HttpResponse {
|
||||
let metar = match web::Query::<AirportQuery>::from_query(req.query_string()) {
|
||||
Ok(q) => q.metars.unwrap_or_else(|| false),
|
||||
Err(err) => {
|
||||
@@ -101,13 +134,23 @@ async fn get_airport(
|
||||
}
|
||||
};
|
||||
|
||||
let client = &data.client;
|
||||
match Airport::select(client, &icao.into_inner(), metar).await {
|
||||
match Airport::select(&icao.into_inner(), metar).await {
|
||||
Some(airport) => HttpResponse::Ok().json(airport),
|
||||
None => HttpResponse::NotFound().finish(),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "airport",
|
||||
responses(
|
||||
(status = 200, description = "", body = Airport),
|
||||
(status = 401, description = ""),
|
||||
(status = 409, description = ""),
|
||||
),
|
||||
security(
|
||||
("session_auth" = [])
|
||||
)
|
||||
)]
|
||||
#[post("")]
|
||||
async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
|
||||
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}")]
|
||||
async fn update_airport(
|
||||
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("")]
|
||||
async fn delete_airports(auth: Auth) -> HttpResponse {
|
||||
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}")]
|
||||
async fn delete_airport(icao: web::Path<String>, auth: Auth) -> HttpResponse {
|
||||
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(
|
||||
web::scope("airports")
|
||||
scope::scope("/airports")
|
||||
.service(import_airports)
|
||||
.service(get_airports)
|
||||
.service(get_airport)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::error::ApiResult;
|
||||
use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection, RedisResult};
|
||||
use s3::{Bucket, Region, creds::Credentials, BucketConfiguration, request::ResponseData};
|
||||
use redis::{Client as RedisClient, RedisResult, aio::MultiplexedConnection as RedisConnection};
|
||||
use s3::{Bucket, BucketConfiguration, Region, creds::Credentials, request::ResponseData};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
static POOL: OnceLock<Pool<Postgres>> = 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 host: String = std::env::var("POSTGRES_HOST").expect("POSTGRES_HOST must be set");
|
||||
let port = std::env::var("POSTGRES_PORT").unwrap_or("5432".to_string());
|
||||
let name = std::env::var("POSTGRES_NAME").unwrap_or("aviation".to_string());
|
||||
let name = std::env::var("POSTGRES_DB").unwrap_or("aviation_db".to_string());
|
||||
|
||||
let db_url = format!(
|
||||
"postgres://{}:{}@{}:{}/{}",
|
||||
@@ -129,10 +129,10 @@ fn redis() -> &'static RedisClient {
|
||||
REDIS.get().unwrap()
|
||||
}
|
||||
|
||||
pub fn redis_connection() -> RedisResult<redis::Connection> {
|
||||
let conn = redis().get_connection()?;
|
||||
Ok(conn)
|
||||
}
|
||||
// pub fn redis_connection() -> RedisResult<redis::Connection> {
|
||||
// let conn = redis().get_connection()?;
|
||||
// Ok(conn)
|
||||
// }
|
||||
|
||||
pub async fn redis_async_connection() -> RedisResult<RedisConnection> {
|
||||
let conn = redis().get_multiplexed_async_connection().await?;
|
||||
@@ -169,9 +169,3 @@ pub struct Paged<T> {
|
||||
pub limit: u32,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct Coordinate {
|
||||
pub lon: f64,
|
||||
pub lat: f64,
|
||||
}
|
||||
|
||||
@@ -204,3 +204,27 @@ impl From<sqlx::migrate::MigrateError> for Error {
|
||||
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
92
api/src/http_client.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use crate::error::{ApiResult, Error};
|
||||
use governor::clock::DefaultClock;
|
||||
use governor::state::{InMemoryState, NotKeyed};
|
||||
use governor::{Quota, RateLimiter};
|
||||
use reqwest::header::{IF_NONE_MATCH, RETRY_AFTER};
|
||||
use reqwest::{Certificate, Client, Response, StatusCode};
|
||||
use std::env;
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpClient {
|
||||
client: Client,
|
||||
limiter: Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>,
|
||||
pub default_retry_after: u64,
|
||||
}
|
||||
|
||||
impl HttpClient {
|
||||
pub fn new(default_retry_after: u64) -> ApiResult<Self> {
|
||||
let mut client_builder = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.tls_built_in_root_certs(true);
|
||||
|
||||
if let Ok(val) = env::var("NGINX_SSL_ENABLED") {
|
||||
if val == "true" {
|
||||
let certificate_path = env::var("SSL_CA_PATH")?;
|
||||
let certificate_data = std::fs::read(certificate_path)?;
|
||||
let certificate = Certificate::from_pem(&certificate_data)?;
|
||||
client_builder = client_builder.add_root_certificate(certificate);
|
||||
}
|
||||
}
|
||||
|
||||
let client = client_builder.build()?;
|
||||
|
||||
let quota = Quota::per_second(NonZeroU32::new(15).unwrap());
|
||||
let limiter = RateLimiter::direct(quota);
|
||||
let limiter = Arc::new(limiter);
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
limiter,
|
||||
default_retry_after,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default() -> ApiResult<Self> {
|
||||
Self::new(60)
|
||||
}
|
||||
|
||||
pub async fn get(&self, url: &str, etag: Option<String>) -> ApiResult<Response> {
|
||||
self.limiter.until_ready().await;
|
||||
|
||||
let mut request = self.client.get(url);
|
||||
if let Some(ref etag) = etag {
|
||||
request = request.header(IF_NONE_MATCH, etag);
|
||||
}
|
||||
|
||||
let mut response = request.send().await?;
|
||||
|
||||
// Handle too many requests
|
||||
if response.status() == StatusCode::TOO_MANY_REQUESTS {
|
||||
let retry_after = response
|
||||
.headers()
|
||||
.get(RETRY_AFTER)
|
||||
.and_then(|hdr| hdr.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(self.default_retry_after);
|
||||
|
||||
log::warn!(
|
||||
"Received 429 Too Many Requests, retrying after {}s",
|
||||
retry_after
|
||||
);
|
||||
sleep(Duration::from_secs(retry_after)).await;
|
||||
|
||||
// Retry once more
|
||||
response = self.client.get(url).send().await?;
|
||||
} else if response.status() == StatusCode::NOT_MODIFIED {
|
||||
log::warn!("Received 304 Not modified")
|
||||
}
|
||||
|
||||
if response.status() != 200 {
|
||||
return Err(Error::new(
|
||||
response.status().as_u16(),
|
||||
format!("Request returned status {}", response.status()),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
119
api/src/main.rs
119
api/src/main.rs
@@ -1,52 +1,71 @@
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
use crate::account::hash;
|
||||
use crate::http_client::HttpClient;
|
||||
use crate::users::{ADMIN_ROLE, User};
|
||||
use actix_cors::Cors;
|
||||
use actix_web::{App, HttpServer, middleware::Logger, web};
|
||||
use dotenv::from_filename;
|
||||
use reqwest::Certificate;
|
||||
use crate::account::hash;
|
||||
use crate::users::{User, ADMIN_ROLE};
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
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 airports;
|
||||
mod db;
|
||||
mod error;
|
||||
mod http_client;
|
||||
mod metars;
|
||||
mod scheduler;
|
||||
mod smtp;
|
||||
mod system;
|
||||
mod users;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AppState {
|
||||
client: reqwest::Client,
|
||||
client: Arc<HttpClient>,
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
initialize_environment()?;
|
||||
db::initialize().await?;
|
||||
// scheduler::update_airports();
|
||||
|
||||
let client = Arc::new(HttpClient::default()?);
|
||||
|
||||
let scheduler_client = client.clone();
|
||||
let interval = env::var("METAR_INTERVAL")
|
||||
.unwrap_or("300".to_string())
|
||||
.parse::<u64>()
|
||||
.unwrap_or(300);
|
||||
scheduler::update_metars(scheduler_client, interval);
|
||||
|
||||
// Initialize admin user
|
||||
let admin_username = env::var("ADMIN_USERNAME");
|
||||
let admin_email = env::var("ADMIN_EMAIL");
|
||||
let admin_password = env::var("ADMIN_PASSWORD");
|
||||
if admin_email.is_ok() && admin_password.is_ok() {
|
||||
if admin_username.is_ok() && admin_email.is_ok() && admin_password.is_ok() {
|
||||
let username = admin_username.unwrap();
|
||||
let email = admin_email.unwrap();
|
||||
if User::select(&email).await.is_none() {
|
||||
if User::select_by_email(&email).await.is_none() {
|
||||
log::debug!("Creating default administrator");
|
||||
let password = admin_password.unwrap();
|
||||
let password_hash = hash(&password)?;
|
||||
if email == "admin@example.com" || password == "CHANGEME" {
|
||||
if email == "admin@example.com" || password == "changeme" {
|
||||
log::warn!(
|
||||
"Default admin credentials are in use, update the ADMIN_EMAIL and ADMIN_PASSWORD."
|
||||
"Default admin credentials are in use, update the ADMIN_USERNAME, ADMIN_EMAIL, and ADMIN_PASSWORD."
|
||||
);
|
||||
}
|
||||
let admin_user = User {
|
||||
email,
|
||||
username,
|
||||
email: Some(email),
|
||||
email_verified: true,
|
||||
password_hash,
|
||||
role: ADMIN_ROLE.to_string(),
|
||||
first_name: "Admin".to_string(),
|
||||
last_name: "".to_string(),
|
||||
avatar: None,
|
||||
updated_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 host = "0.0.0.0";
|
||||
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()
|
||||
.supports_credentials()
|
||||
.max_age(3600);
|
||||
App::new()
|
||||
let (app, mut api) = App::new()
|
||||
.wrap(cors)
|
||||
.wrap(Logger::default())
|
||||
.app_data(web::Data::new(state.clone()))
|
||||
.into_utoipa_app()
|
||||
.service(
|
||||
web::scope("api")
|
||||
scope::scope("/api")
|
||||
.configure(airports::init_routes)
|
||||
.configure(metars::init_routes)
|
||||
.configure(account::init_routes)
|
||||
.configure(users::init_routes)
|
||||
.configure(system::init_routes),
|
||||
)
|
||||
.split_for_parts();
|
||||
|
||||
let 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))
|
||||
{
|
||||
@@ -119,24 +146,30 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
fn initialize_environment() -> std::io::Result<()> {
|
||||
// Iterate over files in the current directory
|
||||
for entry in std::fs::read_dir(".")? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
fn init_dir(directory: &str) -> std::io::Result<()> {
|
||||
// Iterate over files in the current directory
|
||||
for entry in std::fs::read_dir(directory)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
// Check if the file name starts with ".env" and is a file
|
||||
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if file_name.starts_with(".env") && path.is_file() {
|
||||
// Try to load the file
|
||||
if let Err(err) = from_filename(&file_name) {
|
||||
eprintln!("Failed to load {}: {}", file_name, err);
|
||||
} else {
|
||||
println!("Loaded: {}", file_name);
|
||||
// Check if the file name starts with ".env" and is a file
|
||||
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if file_name.starts_with(".env") && path.is_file() {
|
||||
// Try to load the file
|
||||
if let Err(err) = from_filename(&file_name) {
|
||||
eprintln!("Failed to load {}: {}", file_name, err);
|
||||
} else {
|
||||
println!("Loaded: {}", file_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
init_dir("..")?;
|
||||
init_dir(".")?;
|
||||
|
||||
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,api=info"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
66
api/src/metars/metar_check.rs
Normal file
66
api/src/metars/metar_check.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
mod metar_check;
|
||||
mod model;
|
||||
mod routes;
|
||||
|
||||
pub use metar_check::*;
|
||||
pub use model::*;
|
||||
pub use routes::init_routes;
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
use crate::airports::{Airport, UpdateAirport};
|
||||
use crate::error::Error;
|
||||
use crate::{error::ApiResult, db};
|
||||
use chrono::{DateTime, Datelike, Utc};
|
||||
use crate::http_client::HttpClient;
|
||||
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::env;
|
||||
use std::fmt::Display;
|
||||
use std::io::{Cursor, Read};
|
||||
use std::str::FromStr;
|
||||
use redis::{AsyncCommands, RedisResult};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::db::redis_async_connection;
|
||||
use std::sync::OnceLock;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
static TIME_OFFSET: OnceLock<i64> = OnceLock::new();
|
||||
|
||||
const TABLE_NAME: &str = "metars";
|
||||
const DEFAULT_REFRESH_DURATION: i64 = 3000;
|
||||
|
||||
#[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 icao: String,
|
||||
pub raw_text: String,
|
||||
@@ -58,12 +75,12 @@ pub struct Metar {
|
||||
pub density_altitude: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub enum ReportModifier {
|
||||
#[serde(rename = "AUTO")]
|
||||
Auto,
|
||||
#[serde(rename = "COR")]
|
||||
Corrected
|
||||
Corrected,
|
||||
}
|
||||
|
||||
impl FromStr for ReportModifier {
|
||||
@@ -72,7 +89,7 @@ impl FromStr for ReportModifier {
|
||||
match s {
|
||||
"AUTO" => Ok(ReportModifier::Auto),
|
||||
"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 runway: String,
|
||||
#[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 {
|
||||
#[serde(rename = "AO1")]
|
||||
WithoutPrecipitationDiscriminator,
|
||||
@@ -122,7 +139,10 @@ impl FromStr for AutomatedStationType {
|
||||
match s {
|
||||
"AO1" => Ok(AutomatedStationType::WithoutPrecipitationDiscriminator),
|
||||
"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 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub peak_wind: Option<PeakWind>,
|
||||
@@ -160,7 +180,7 @@ pub struct Remarks {
|
||||
pub sky_condition_at_secondary_location_not_available: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PeakWind {
|
||||
pub degrees: 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 sky_cover: String,
|
||||
#[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 {
|
||||
VFR,
|
||||
MVFR,
|
||||
@@ -286,9 +306,9 @@ impl MetarRow {
|
||||
|
||||
impl Metar {
|
||||
fn parse_multiple(metar_strings: &Vec<&str>) -> ApiResult<Vec<Self>> {
|
||||
let mut metars: Vec<Metar> = vec![];
|
||||
let mut metars: Vec<Self> = vec![];
|
||||
for metar_string in metar_strings {
|
||||
match Metar::parse(metar_string) {
|
||||
match Self::parse(metar_string) {
|
||||
Ok(metar) => metars.push(metar),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse metar string: {}", e);
|
||||
@@ -309,7 +329,7 @@ impl Metar {
|
||||
}
|
||||
|
||||
log::trace!("Parsing METAR data: {}", metar_string);
|
||||
let mut metar: Metar = Metar::default();
|
||||
let mut metar: Self = Self::default();
|
||||
metar.raw_text = metar_string.to_owned();
|
||||
let mut metar_parts: Vec<&str> = metar_string.split_whitespace().collect();
|
||||
if metar_parts.len() < 4 {
|
||||
@@ -334,49 +354,7 @@ impl Metar {
|
||||
// Date/Time
|
||||
let observation_time = metar_parts[0];
|
||||
metar_parts.remove(0);
|
||||
if observation_time.len() != 7 {
|
||||
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
|
||||
);
|
||||
let observation_time = Self::parse_time(observation_time)?;
|
||||
metar.observation_time = match chrono::DateTime::parse_from_rfc3339(&observation_time) {
|
||||
Ok(datetime) => datetime.with_timezone(&Utc),
|
||||
Err(err) => return Err(err.into()),
|
||||
@@ -469,28 +447,19 @@ impl Metar {
|
||||
let visibility: String = if visibility_str.contains("/") {
|
||||
let visibility_parts: Vec<&str> = visibility_str.split("/").collect();
|
||||
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") {
|
||||
format!(
|
||||
"M{}",
|
||||
visibility_left[1..visibility_left.len()]
|
||||
.parse::<f64>()
|
||||
.unwrap()
|
||||
/ visibility_right
|
||||
visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right
|
||||
)
|
||||
} else if visibility_left.starts_with("P") {
|
||||
format!(
|
||||
"P{}",
|
||||
visibility_left[1..visibility_left.len()]
|
||||
.parse::<f64>()
|
||||
.unwrap()
|
||||
/ visibility_right
|
||||
visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}",
|
||||
visibility_left.parse::<f64>().unwrap() / visibility_right
|
||||
)
|
||||
format!("{}", visibility_left.parse::<f64>()? / visibility_right)
|
||||
}
|
||||
} else {
|
||||
visibility_str.to_string()
|
||||
@@ -501,30 +470,39 @@ impl Metar {
|
||||
&& metar_parts.len() > 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);
|
||||
let visibility_parts: Vec<&str> = metar_parts[0].split("/").collect();
|
||||
metar_parts.remove(0);
|
||||
let visibility_left = visibility_parts[0];
|
||||
let visibility_right = visibility_parts[1][0..visibility_parts[1].len() - 2]
|
||||
.parse::<f64>()
|
||||
.unwrap();
|
||||
// Parse the right-hand of visibility, with or without an SM suffix
|
||||
let visibility_right_string = match visibility_parts[1].strip_suffix("SM") {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
if visibility_parts[1].chars().all(|c| c.is_numeric() || c == '.') {
|
||||
visibility_parts[1]
|
||||
} else {
|
||||
log::warn!(
|
||||
"Skipping invalid visibility field '{}' ({})",
|
||||
metar_parts[0],
|
||||
metar_string
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let visibility_right = visibility_right_string.parse::<f64>()?;
|
||||
let visibility = if visibility_left.starts_with("M") {
|
||||
format!(
|
||||
"M{}",
|
||||
visibility_whole
|
||||
+ (visibility_left[1..visibility_left.len()]
|
||||
.parse::<f64>()
|
||||
.unwrap()
|
||||
/ visibility_right)
|
||||
+ (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
|
||||
)
|
||||
} else if visibility_left.starts_with("P") {
|
||||
format!(
|
||||
"P{}",
|
||||
visibility_whole
|
||||
+ (visibility_left[1..visibility_left.len()]
|
||||
.parse::<f64>()?
|
||||
/ visibility_right)
|
||||
+ (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
@@ -599,11 +577,16 @@ impl Metar {
|
||||
metar_parts.remove(0);
|
||||
}
|
||||
let sky_condition_re =
|
||||
regex::Regex::new(r"^(?:CLR|SKC|NSC|NCD|(?:FEW|SCT|BKN|OVC|VV)([0-9/]{3})?(?:CB|TCU)?)$")
|
||||
regex::Regex::new(r"^(?:CLR|SKC|NSC|NCD|(?:FEW|SCT|BKN|OVC|VV)([0-9/]{3})?(?:CB|TCU)?)(?:///)?$")
|
||||
.unwrap();
|
||||
while !metar_parts.is_empty() && sky_condition_re.is_match(metar_parts[0]) {
|
||||
let sky_condition_string = metar_parts[0];
|
||||
let mut sky_condition_string = metar_parts[0];
|
||||
metar_parts.remove(0);
|
||||
|
||||
if sky_condition_string.ends_with("///") {
|
||||
sky_condition_string = &sky_condition_string[..sky_condition_string.len() - 3];
|
||||
}
|
||||
|
||||
let mut sky_condition = SkyCondition::default();
|
||||
let mut vv_offset = 0;
|
||||
if &sky_condition_string[0..2] == "VV" {
|
||||
@@ -886,70 +869,191 @@ impl Metar {
|
||||
// let estimated_density = ;
|
||||
// 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)
|
||||
}
|
||||
|
||||
async fn get_missing_metar_icaos(
|
||||
db_metars: &Vec<Self>,
|
||||
station_icaos: &Vec<String>,
|
||||
) -> Vec<String> {
|
||||
let mut missing_metar_icaos: Vec<String> = vec![];
|
||||
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());
|
||||
fn parse_time(observation_time: &str) -> ApiResult<String> {
|
||||
if observation_time.len() != 7 {
|
||||
return Err(Error::new(
|
||||
500,
|
||||
format!("Unable to parse observation time in {}", observation_time),
|
||||
));
|
||||
}
|
||||
let time_offset = env::var("API_METAR_TIME_OFFSET")
|
||||
.unwrap_or("3000".to_string())
|
||||
.parse::<i64>()
|
||||
.unwrap_or(3000);
|
||||
for metar in db_metars {
|
||||
if current_time > (metar.observation_time.timestamp() + time_offset) {
|
||||
log::trace!("{} METAR data is outdated", metar.icao);
|
||||
missing_metar_icaos.push(metar.icao.to_string());
|
||||
let observation_day = match observation_time[0..2].parse::<u32>() {
|
||||
Ok(day) => day,
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
let observation_hour = match observation_time[2..4].parse::<u32>() {
|
||||
Ok(hour) => hour,
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
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>> {
|
||||
let base_url = std::env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set");
|
||||
pub async fn get_cached_remote_metars(
|
||||
client: &HttpClient,
|
||||
etag: Option<String>,
|
||||
) -> ApiResult<(Vec<Self>, String)> {
|
||||
let base_url = env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set");
|
||||
let url = format!("{}/data/cache/metars.cache.csv.gz", base_url);
|
||||
|
||||
match client.get(&url, etag.clone()).await {
|
||||
Ok(r) => {
|
||||
let new_etag = r
|
||||
.headers()
|
||||
.get(ETAG)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let bytes = r.bytes().await?;
|
||||
let mut gz = GzDecoder::new(Cursor::new(bytes));
|
||||
let mut text = String::new();
|
||||
gz.read_to_string(&mut text)?;
|
||||
|
||||
let mut output: Vec<Metar> = Vec::new();
|
||||
|
||||
for line in text.lines() {
|
||||
// Split off first column
|
||||
let raw_text = line.splitn(2, ',').next().unwrap();
|
||||
match Metar::parse(raw_text) {
|
||||
Ok(m) => output.push(m),
|
||||
Err(err) => {
|
||||
log::warn!("{}", err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
match new_etag {
|
||||
Some(etag) => Ok((output, etag)),
|
||||
None => match etag {
|
||||
Some(etag) => Ok((output, etag)),
|
||||
None => Ok((output, String::new())),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_remote_metars(client: &HttpClient, icaos: &Vec<String>) -> ApiResult<Vec<Self>> {
|
||||
let base_url = env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set");
|
||||
// Query the remote API for the missing METAR data 10 at a time
|
||||
let icao_chunks = icaos
|
||||
.chunks(10)
|
||||
.map(|chunk| chunk.join(","))
|
||||
.collect::<Vec<String>>();
|
||||
let mut metars: Vec<Metar> = vec![];
|
||||
let mut metars: Vec<Self> = vec![];
|
||||
for icao_chunk in icao_chunks {
|
||||
let url = format!("{}/metar?ids={}&order=id", base_url, icao_chunk);
|
||||
let mut m = match client.get(url).send().await {
|
||||
Ok(r) => {
|
||||
// Check if the status code is 200
|
||||
if r.status() != 200 {
|
||||
return Err(Error::new(
|
||||
500,
|
||||
format!("Request returned status {}", r.status()),
|
||||
));
|
||||
}
|
||||
match r.text().await {
|
||||
Ok(r) => {
|
||||
let metar_chunk = r
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(|m| !m.trim().is_empty())
|
||||
.collect();
|
||||
match Self::parse_multiple(&metar_chunk) {
|
||||
Ok(m) => m,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
let url = format!(
|
||||
"{}/api/data/metar?ids={}&hours=0&order=id,-obs",
|
||||
base_url, icao_chunk
|
||||
);
|
||||
let mut m = match client.get(&url, None).await {
|
||||
Ok(r) => match r.text().await {
|
||||
Ok(r) => {
|
||||
let metar_chunk = r
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(|m| !m.trim().is_empty())
|
||||
.collect();
|
||||
match Self::parse_multiple(&metar_chunk) {
|
||||
Ok(m) => m,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
Err(err) => return Err(Error::new(500, format!("METAR parse failed: {}", err))),
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(Error::new(500, format!("METAR parse failed: {}", err))),
|
||||
},
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
metars.append(&mut m);
|
||||
@@ -957,108 +1061,171 @@ impl Metar {
|
||||
Ok(metars)
|
||||
}
|
||||
|
||||
fn from_db(metar_db: MetarRow) -> ApiResult<Metar> {
|
||||
let metar: Metar = serde_json::from_value(metar_db.data)?;
|
||||
fn from_row(row: MetarRow) -> ApiResult<Self> {
|
||||
let metar: Self = serde_json::from_value(row.data)?;
|
||||
Ok(metar)
|
||||
}
|
||||
|
||||
fn to_db(&self) -> ApiResult<MetarRow> {
|
||||
fn to_row(&self) -> ApiResult<MetarRow> {
|
||||
let data = serde_json::to_value(self)?;
|
||||
Ok(MetarRow {
|
||||
icao: self.icao.clone(),
|
||||
icao: self.icao.to_uppercase(),
|
||||
observation_time: self.observation_time,
|
||||
raw_text: self.raw_text.clone(),
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn find_all(
|
||||
client: &Client,
|
||||
icao_list: &Vec<String>,
|
||||
force: &bool,
|
||||
) -> ApiResult<Vec<Self>> {
|
||||
pub async fn get_all_distinct(icao_list: &Vec<String>) -> ApiResult<Vec<Self>> {
|
||||
if icao_list.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut metars: Vec<Metar> = vec![];
|
||||
if !*force {
|
||||
let pool = db::pool();
|
||||
let metar_rows: Vec<MetarRow> = sqlx::query_as::<_, MetarRow>(&format!(
|
||||
r#"
|
||||
SELECT DISTINCT ON (icao) * FROM {} WHERE icao = ANY($1) ORDER BY icao, observation_time DESC
|
||||
let pool = db::pool();
|
||||
let metar_rows: Vec<MetarRow> = sqlx::query_as::<_, MetarRow>(&format!(
|
||||
r#"
|
||||
SELECT DISTINCT ON (icao) * FROM {}
|
||||
WHERE icao = ANY($1)
|
||||
ORDER BY icao, observation_time DESC
|
||||
"#,
|
||||
TABLE_NAME
|
||||
))
|
||||
.bind(icao_list)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
metars = metar_rows
|
||||
.into_iter()
|
||||
.filter_map(|metar_db| Metar::from_db(metar_db).ok())
|
||||
.collect();
|
||||
TABLE_NAME
|
||||
))
|
||||
.bind(icao_list)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let mut metars = vec![];
|
||||
for metar_row in metar_rows {
|
||||
metars.push(Self::from_row(metar_row)?)
|
||||
}
|
||||
|
||||
let mut conn = redis_async_connection().await?;
|
||||
// Check for missing metars
|
||||
let missing_icao_list = Self::get_missing_metar_icaos(&metars, icao_list).await;
|
||||
if !missing_icao_list.is_empty() {
|
||||
let mut updated_missing_icao_list: Vec<&str> = Vec::new();
|
||||
for icao in &missing_icao_list {
|
||||
if *force {
|
||||
updated_missing_icao_list.push(icao);
|
||||
} else {
|
||||
let result: RedisResult<Option<bool>> = conn.get(icao).await;
|
||||
match result {
|
||||
Ok(Some(value)) => {
|
||||
if value {
|
||||
updated_missing_icao_list.push(icao);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
updated_missing_icao_list.push(icao);
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
if !updated_missing_icao_list.is_empty() {
|
||||
log::trace!(
|
||||
"Retrieving missing METAR data for {:?}",
|
||||
updated_missing_icao_list
|
||||
);
|
||||
let mut missing_icao_list = Self::get_remote_metars(client, &updated_missing_icao_list)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
log::warn!("Unable to get remote METAR data; {}", err);
|
||||
vec![]
|
||||
});
|
||||
|
||||
if missing_icao_list.len() > 0 {
|
||||
// Insert missing METARs
|
||||
for missing_metar in &missing_icao_list {
|
||||
let _: RedisResult<()> = conn.set(&missing_metar.icao, true).await;
|
||||
missing_metar.insert().await?;
|
||||
}
|
||||
metars.append(&mut missing_icao_list)
|
||||
}
|
||||
|
||||
// Invalidate the still missing icaos
|
||||
let still_missing_icao_list =
|
||||
Self::get_missing_metar_icaos(&missing_icao_list, icao_list).await;
|
||||
if !still_missing_icao_list.is_empty() {
|
||||
for icao in still_missing_icao_list {
|
||||
let _: RedisResult<()> = conn.set_ex(&icao, false, 3600).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(metars)
|
||||
}
|
||||
|
||||
pub async fn get_or_update_metars(
|
||||
client: &HttpClient,
|
||||
icaos: &Vec<String>,
|
||||
) -> ApiResult<Vec<Self>> {
|
||||
let metars = Self::get_all_distinct(&icaos).await?;
|
||||
let current_time = Utc::now().timestamp();
|
||||
|
||||
let mut updated_metars: Vec<Self> = vec![];
|
||||
let mut missing_metar_icaos: Vec<String> = vec![];
|
||||
let mut found_metar_icaos: HashSet<String> = HashSet::new();
|
||||
let mut requested_icaos: HashSet<String> = HashSet::from_iter(icaos.clone());
|
||||
|
||||
for metar in metars {
|
||||
let icao = metar.icao.clone();
|
||||
// Remove found icao from requested ICAOs
|
||||
requested_icaos.remove(&icao);
|
||||
|
||||
// Handle outdated METARs
|
||||
if current_time > (metar.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);
|
||||
}
|
||||
}
|
||||
// Otherwise add the valid metar to the updated list
|
||||
else {
|
||||
found_metar_icaos.insert(icao.clone());
|
||||
let metar_check = MetarCheck::new(icao, true).await;
|
||||
metar_check.insert().await?;
|
||||
updated_metars.push(metar);
|
||||
}
|
||||
}
|
||||
|
||||
// Add all METARs that were not in the returned database METARs
|
||||
for icao in &requested_icaos {
|
||||
match MetarCheck::get(icao).await {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve missing METARs
|
||||
if !missing_metar_icaos.is_empty() {
|
||||
log::trace!(
|
||||
"Retrieving missing METAR data for {:?}",
|
||||
missing_metar_icaos
|
||||
);
|
||||
let mut remote_metars = Self::get_remote_metars(client, &missing_metar_icaos)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
log::warn!("Unable to get remote METAR data; {}", err);
|
||||
vec![]
|
||||
});
|
||||
|
||||
// Insert missing METARs
|
||||
if remote_metars.len() > 0 {
|
||||
for remote_metar in remote_metars.clone() {
|
||||
remote_metar.insert().await?;
|
||||
found_metar_icaos.insert(remote_metar.icao.to_string());
|
||||
let mut metar_check = MetarCheck::new(remote_metar.icao.clone(), true).await;
|
||||
metar_check.last_metar = Some(remote_metar);
|
||||
metar_check.insert().await?;
|
||||
}
|
||||
updated_metars.append(&mut remote_metars);
|
||||
}
|
||||
|
||||
// Update still missing METARs
|
||||
for difference in found_metar_icaos.symmetric_difference(&requested_icaos) {
|
||||
let metar_check = MetarCheck::new(difference.to_string(), false).await;
|
||||
metar_check.insert().await?;
|
||||
// Only add cached metar data if it's less than 4 hours old
|
||||
if let Some(last_metar) = metar_check.last_metar {
|
||||
let four_hours_ago = Utc::now() - chrono::Duration::hours(4);
|
||||
if last_metar.observation_time < four_hours_ago {
|
||||
updated_metars.push(last_metar);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updated_metars)
|
||||
}
|
||||
|
||||
pub async fn update_metars(client: &HttpClient, etag: Option<String>) -> ApiResult<String> {
|
||||
let (remote_metars, etag) = Self::get_cached_remote_metars(client, etag)
|
||||
.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<()> {
|
||||
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?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1067,25 +1234,66 @@ impl Metar {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
#[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
|
||||
RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 RWY11 RAB07 CIG 013V017 CIG 017 RWY11 PRESFR
|
||||
SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $".to_string();
|
||||
let metar = Metar::parse(&metar_string).unwrap();
|
||||
// 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();
|
||||
let metar = Metar::parse(&metar_string).unwrap();
|
||||
dbg!(&metar);
|
||||
dbg!(&metar.observation_time);
|
||||
|
||||
metar_string =
|
||||
"KMRB 082253Z 30014G23KT 10SM CLR 05/M12 A3002 RMK AO2 PK WND 30028/2157 SLP168 T00501117"
|
||||
.to_string();
|
||||
let metar = Metar::parse(&metar_string).unwrap();
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::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)]
|
||||
struct FindAllParameters {
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
struct MetarQuery {
|
||||
icaos: Option<String>,
|
||||
force: Option<bool>,
|
||||
}
|
||||
|
||||
#[get("metars")]
|
||||
async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
|
||||
let parameters = web::Query::<FindAllParameters>::from_query(req.query_string()).unwrap();
|
||||
#[utoipa::path(
|
||||
tag = "metar",
|
||||
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 = ¶meters.icaos;
|
||||
if let None = icao_option {
|
||||
let empty_metars: Vec<Metar> = vec![];
|
||||
return HttpResponse::Ok().json(empty_metars);
|
||||
}
|
||||
let icao_string = match icao_option {
|
||||
Some(i) => i,
|
||||
None => return HttpResponse::UnprocessableEntity().body("Missing icaos parameter"),
|
||||
};
|
||||
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_string()).collect();
|
||||
let force = ¶meters.force.unwrap_or(false);
|
||||
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect();
|
||||
|
||||
let client = &data.client;
|
||||
let metars = match Metar::find_all(client, &icaos, force).await {
|
||||
let metars = match Metar::get_all_distinct(&icaos).await {
|
||||
Ok(a) => a,
|
||||
Err(err) => {
|
||||
error!("{}", err);
|
||||
@@ -32,6 +47,48 @@ async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
|
||||
HttpResponse::Ok().json(metars)
|
||||
}
|
||||
|
||||
pub fn init_routes(config: &mut web::ServiceConfig) {
|
||||
config.service(find_all);
|
||||
#[utoipa::path(
|
||||
tag = "metar",
|
||||
params(
|
||||
MetarQuery,
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Successful Response", body = [Metar]),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(
|
||||
("session_auth" = [])
|
||||
)
|
||||
)]
|
||||
#[put("")]
|
||||
async fn refresh_metars(data: web::Data<AppState>, req: HttpRequest, _auth: Auth) -> HttpResponse {
|
||||
let client = data.client.clone();
|
||||
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
|
||||
let icao_option = ¶meters.icaos;
|
||||
if let None = icao_option {
|
||||
let empty_metars: Vec<Metar> = vec![];
|
||||
return HttpResponse::Ok().json(empty_metars);
|
||||
}
|
||||
let icao_string = match icao_option {
|
||||
Some(i) => i,
|
||||
None => return HttpResponse::UnprocessableEntity().body("Missing icaos parameter"),
|
||||
};
|
||||
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect();
|
||||
|
||||
let metars = match Metar::get_or_update_metars(&client, &icaos).await {
|
||||
Ok(a) => a,
|
||||
Err(err) => {
|
||||
error!("{}", err);
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,37 @@
|
||||
// use tokio::time::{sleep, Duration};
|
||||
use crate::http_client::HttpClient;
|
||||
use crate::metars::Metar;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time::interval;
|
||||
|
||||
// use crate::airports::{AirportDb, AirportFilter};
|
||||
// use crate::metars::Metar;
|
||||
pub fn update_metars(client: Arc<HttpClient>, seconds: u64) {
|
||||
tokio::spawn(async move {
|
||||
// Create interval ticker
|
||||
let mut interval = interval(Duration::from_secs(seconds));
|
||||
let mut etag = None;
|
||||
|
||||
pub fn update_airports() {
|
||||
// tokio::spawn(async {
|
||||
// let mut airports: Vec<AirportDb> = vec![];
|
||||
// let limit = 100;
|
||||
// loop {
|
||||
// log::debug!("METAR update start");
|
||||
// let total = match AirportDb::count(&AirportFilter::default()).await {
|
||||
// Ok(t) => t,
|
||||
// Err(err) => {
|
||||
// log::warn!("{}", err);
|
||||
// break;
|
||||
// }
|
||||
// };
|
||||
// if total != airports.len() as i64 {
|
||||
// log::debug!("{} cached airports, expected {}", airports.len(), total);
|
||||
// airports = vec![];
|
||||
// let pages = ((total as f32) / (if limit <= 0 { 1 } else { limit } as f32)).ceil() as i32;
|
||||
// for page in 1..(pages + 1) {
|
||||
// match AirportDb::find_all(&AirportFilter::default(), limit, page).await {
|
||||
// Ok(mut a) => airports.append(&mut a),
|
||||
// Err(err) => {
|
||||
// log::warn!("{}", err);
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// log::debug!("Updating {} airport METARS", airports.len());
|
||||
//
|
||||
// let airport_icaos: Vec<String> = airports.iter().map(|a| a.icao.to_string()).collect();
|
||||
// let mut peekable = airport_icaos.into_iter().peekable();
|
||||
// let mut observation_time = chrono::Utc::now().timestamp();
|
||||
//
|
||||
// if peekable.peek().is_none() {
|
||||
// log::debug!("No airports to update, sleeping for 1 hour");
|
||||
// sleep(Duration::from_secs(3600)).await;
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// while peekable.peek().is_some() {
|
||||
// let chunk: Vec<String> = peekable.by_ref().take(limit as usize).collect();
|
||||
// let icao_string = chunk.join(",");
|
||||
// log::warn!("Updating METARS for: {}", &icao_string); // TODO: back to trace after
|
||||
// match Metar::find_all(&[&icao_string]).await {
|
||||
// Ok(metars) => {
|
||||
// // Find the oldest observation time
|
||||
// for metar in metars {
|
||||
// if metar.observation_time.timestamp() < observation_time {
|
||||
// observation_time = metar.observation_time.timestamp();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// Err(err) => {
|
||||
// log::warn!("{}", err);
|
||||
// }
|
||||
// }
|
||||
// // Sleep for 100ms between chunks to avoid rate limiting
|
||||
// sleep(Duration::from_millis(100)).await;
|
||||
// }
|
||||
// log::debug!("METAR update complete");
|
||||
// // Sleep until the earliest observation time is 1 hour old
|
||||
// // Bounded by 1 and 3600 seconds
|
||||
// let now = chrono::Utc::now().timestamp();
|
||||
// let sleep_time = std::cmp::min(std::cmp::max(1, now - (observation_time + 3600)), 3600);
|
||||
// log::debug!("Next update in {} seconds", sleep_time);
|
||||
// sleep(Duration::from_secs(sleep_time as u64)).await;
|
||||
// }
|
||||
// });
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// Record start times
|
||||
let start_monotonic = Instant::now();
|
||||
let start_utc: DateTime<Utc> = Utc::now();
|
||||
log::debug!("METAR update started at {}", start_utc);
|
||||
|
||||
// Run the update
|
||||
match Metar::update_metars(&client, etag.clone()).await {
|
||||
Ok(new_etag) => etag = Some(new_etag),
|
||||
Err(err) => log::error!("METAR update failed: {}", err),
|
||||
}
|
||||
|
||||
let elapsed = start_monotonic.elapsed();
|
||||
let next_utc = Utc::now() + chrono::Duration::from_std(Duration::from_secs(seconds)).unwrap();
|
||||
log::info!(
|
||||
"METAR update finished in {:.2?}; next run at {}",
|
||||
elapsed,
|
||||
next_utc
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
76
api/src/smtp/mod.rs
Normal file
76
api/src/smtp/mod.rs
Normal 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(())
|
||||
}
|
||||
@@ -1,29 +1,34 @@
|
||||
use std::env;
|
||||
use actix_web::{get, web, HttpResponse};
|
||||
use actix_web::{HttpResponse, get};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use utoipa::ToSchema;
|
||||
use utoipa_actix_web::scope;
|
||||
use utoipa_actix_web::service_config::ServiceConfig;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SystemInfo {
|
||||
version: String,
|
||||
healthy: bool,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "system",
|
||||
responses(
|
||||
(status = 200, description = "Successful system info"),
|
||||
)
|
||||
)]
|
||||
#[get("/info")]
|
||||
async fn info() -> HttpResponse {
|
||||
let mut healthy = true;
|
||||
let version = match env::var("CARGO_PKG_VERSION") {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
healthy = false;
|
||||
String::from("unknown")
|
||||
}
|
||||
let healthy = true;
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let info = SystemInfo {
|
||||
version: version.to_string(),
|
||||
healthy,
|
||||
};
|
||||
|
||||
let info = SystemInfo { version, healthy };
|
||||
|
||||
HttpResponse::Ok().json(info)
|
||||
}
|
||||
|
||||
pub fn init_routes(config: &mut web::ServiceConfig) {
|
||||
config.service(web::scope("/system").service(info));
|
||||
pub fn init_routes(config: &mut ServiceConfig) {
|
||||
config.service(scope::scope("/system").service(info));
|
||||
}
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
use crate::db;
|
||||
use crate::{account::hash, error::ApiResult};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[allow(unused_imports)] // Import is used in schema examples
|
||||
use serde_json::json;
|
||||
use sqlx::{Postgres, QueryBuilder};
|
||||
use crate::{account::hash, error::ApiResult};
|
||||
use crate::db;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub const ADMIN_ROLE: &str = "ADMIN";
|
||||
pub const USER_ROLE: &str = "USER";
|
||||
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 email: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub password: String,
|
||||
#[serde(rename = "firstName")]
|
||||
pub first_name: String,
|
||||
#[serde(rename = "lastName")]
|
||||
pub last_name: String,
|
||||
}
|
||||
|
||||
@@ -20,53 +37,77 @@ impl RegisterRequest {
|
||||
pub fn to_user(self) -> ApiResult<User> {
|
||||
let password_hash = hash(&self.password)?;
|
||||
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,
|
||||
role: USER_ROLE.to_string(),
|
||||
first_name: self.first_name,
|
||||
last_name: self.last_name,
|
||||
avatar: None,
|
||||
updated_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 email: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UserResponse {
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
#[serde(rename = "firstName")]
|
||||
pub first_name: String,
|
||||
#[serde(rename = "lastName")]
|
||||
pub last_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar: Option<String>,
|
||||
#[serde(rename = "emailVerified")]
|
||||
pub email_verified: bool,
|
||||
}
|
||||
|
||||
impl From<User> for UserResponse {
|
||||
fn from(user: User) -> Self {
|
||||
UserResponse {
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
email_verified: user.email_verified,
|
||||
role: user.role,
|
||||
first_name: user.first_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 email: Option<String>,
|
||||
pub email_verified: Option<bool>,
|
||||
pub password: Option<String>,
|
||||
pub role: Option<String>,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
}
|
||||
|
||||
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 mut query_builder: QueryBuilder<Postgres> =
|
||||
@@ -87,6 +128,11 @@ impl UpdateUser {
|
||||
query_builder.push("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 {
|
||||
push_comma(&mut query_builder);
|
||||
let password_hash = hash(password)?;
|
||||
@@ -108,16 +154,19 @@ impl UpdateUser {
|
||||
query_builder.push("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);
|
||||
query_builder.push("updated_at = ");
|
||||
query_builder.push_bind(Utc::now());
|
||||
|
||||
query_builder.push(" WHERE email = ");
|
||||
query_builder.push_bind(email.to_string());
|
||||
query_builder.push(" WHERE username = ");
|
||||
query_builder.push_bind(username);
|
||||
query_builder.push(" RETURNING *");
|
||||
|
||||
dbg!(&query_builder.sql());
|
||||
|
||||
let query = query_builder.build_query_as::<User>();
|
||||
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 email: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub password_hash: String,
|
||||
pub role: String,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub avatar: Option<String>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub async fn select(email: &str) -> Option<Self> {
|
||||
pub async fn select(username: &str) -> Option<Self> {
|
||||
let pool = db::pool();
|
||||
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
|
||||
r#"
|
||||
SELECT * FROM {} WHERE email = LOWER($1)
|
||||
SELECT * FROM {} WHERE username = $1
|
||||
"#,
|
||||
TABLE_NAME
|
||||
))
|
||||
.bind(email)
|
||||
.bind(username)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
log::error!("Unable to find user '{}': {}", email, err);
|
||||
log::error!("Unable to find user '{}': {}", username, err);
|
||||
None
|
||||
});
|
||||
|
||||
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 {
|
||||
let pool = db::pool();
|
||||
|
||||
@@ -175,24 +247,30 @@ impl User {
|
||||
let user: User = sqlx::query_as::<_, Self>(&format!(
|
||||
r#"
|
||||
INSERT INTO {} (
|
||||
username,
|
||||
email,
|
||||
email_verified,
|
||||
password_hash,
|
||||
role,
|
||||
first_name,
|
||||
last_name,
|
||||
avatar,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *
|
||||
"#,
|
||||
TABLE_NAME,
|
||||
))
|
||||
.bind(&self.username)
|
||||
.bind(&self.email)
|
||||
.bind(&self.email_verified)
|
||||
.bind(&self.password_hash)
|
||||
.bind(&self.role)
|
||||
.bind(&self.first_name)
|
||||
.bind(&self.last_name)
|
||||
.bind(&self.avatar)
|
||||
.bind(self.created_at)
|
||||
.bind(self.updated_at)
|
||||
.fetch_one(pool)
|
||||
|
||||
@@ -152,7 +152,9 @@
|
||||
// }
|
||||
// }
|
||||
|
||||
pub fn init_routes(_config: &mut actix_web::web::ServiceConfig) {
|
||||
use utoipa_actix_web::service_config::ServiceConfig;
|
||||
|
||||
pub fn init_routes(_config: &mut ServiceConfig) {
|
||||
// config.service(
|
||||
// web::scope("users")
|
||||
// .service(get_favorites)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Change Password
|
||||
type: http
|
||||
seq: 4
|
||||
seq: 6
|
||||
}
|
||||
|
||||
put {
|
||||
@@ -11,7 +11,9 @@ put {
|
||||
}
|
||||
|
||||
body:json {
|
||||
"New Password"
|
||||
{
|
||||
"password": "New Password"
|
||||
}
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
18
bruno/Account/Confirm Password Reset.bru
Normal file
18
bruno/Account/Confirm Password Reset.bru
Normal file
@@ -0,0 +1,18 @@
|
||||
meta {
|
||||
name: Confirm Password Reset
|
||||
type: http
|
||||
seq: 8
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{API_URL}}/account/password/verify
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"token": "token",
|
||||
"password": "New Password"
|
||||
}
|
||||
}
|
||||
11
bruno/Account/Get Profile.bru
Normal file
11
bruno/Account/Get Profile.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Get Profile
|
||||
type: http
|
||||
seq: 10
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{API_URL}}/account/profile
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Login
|
||||
type: http
|
||||
seq: 2
|
||||
seq: 4
|
||||
}
|
||||
|
||||
post {
|
||||
@@ -12,7 +12,7 @@ post {
|
||||
|
||||
body:json {
|
||||
{
|
||||
"email": "admin@example.com",
|
||||
"password": "CHANGEME"
|
||||
"username": "user",
|
||||
"password": "changeme"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Logout
|
||||
type: http
|
||||
seq: 3
|
||||
seq: 5
|
||||
}
|
||||
|
||||
post {
|
||||
@@ -12,7 +12,7 @@ post {
|
||||
|
||||
body:json {
|
||||
{
|
||||
"email": "john.doe@gmail.com",
|
||||
"password": "fake_password123"
|
||||
"email": "user@gmail.com",
|
||||
"password": "changeme"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Validate Session
|
||||
name: Refresh Session
|
||||
type: http
|
||||
seq: 5
|
||||
seq: 9
|
||||
}
|
||||
|
||||
get {
|
||||
@@ -12,9 +12,10 @@ post {
|
||||
|
||||
body:json {
|
||||
{
|
||||
"email": "john.doe@gmail.com",
|
||||
"password": "fake_password123",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe"
|
||||
"username": "user",
|
||||
"email": "user@example.com",
|
||||
"password": "changeme",
|
||||
"firstName": "John",
|
||||
"lastName": "Doe"
|
||||
}
|
||||
}
|
||||
11
bruno/Account/Resend Email Confirmation.bru
Normal file
11
bruno/Account/Resend Email Confirmation.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Resend Email Confirmation
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{API_URL}}/account/register/resend
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
17
bruno/Account/Reset Password.bru
Normal file
17
bruno/Account/Reset Password.bru
Normal 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"
|
||||
}
|
||||
}
|
||||
17
bruno/Account/Verify Email Confirmation.bru
Normal file
17
bruno/Account/Verify Email Confirmation.bru
Normal file
@@ -0,0 +1,17 @@
|
||||
meta {
|
||||
name: Verify Email Confirmation
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{API_URL}}/account/register/verify
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"token": "token"
|
||||
}
|
||||
}
|
||||
3
bruno/Account/folder.bru
Normal file
3
bruno/Account/folder.bru
Normal file
@@ -0,0 +1,3 @@
|
||||
meta {
|
||||
name: Account
|
||||
}
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{API_URL}}/airports?page=1&limit=1000&metars=true
|
||||
url: {{API_URL}}/airports?page=1&limit=1000
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -13,7 +13,7 @@ get {
|
||||
params:query {
|
||||
page: 1
|
||||
limit: 1000
|
||||
metars: true
|
||||
~metars: true
|
||||
~icaos: 00AA
|
||||
~icaos: KHEF,KJYO,KMRB,KOKV
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ post {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ body:json {
|
||||
"latitude": 0,
|
||||
"longitude": 0,
|
||||
"runways": [],
|
||||
"frequencies": [],
|
||||
"communications": [],
|
||||
"public": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{API_URL}}/metars?icaos=KJYO,KOKV,KMRB,KHEF,KIAD&force=true
|
||||
url: {{API_URL}}/metars?icaos=KIAD
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
params:query {
|
||||
icaos: KJYO,KOKV,KMRB,KHEF,KIAD
|
||||
force: true
|
||||
icaos: KIAD
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,8 +25,7 @@ services:
|
||||
volumes:
|
||||
- ./ssl:/etc/nginx/ssl/
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
- default
|
||||
<<: *default_restart
|
||||
|
||||
postgres:
|
||||
@@ -36,14 +35,14 @@ services:
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_NAME}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
- postgres_logs:/var/log
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
networks:
|
||||
- backend
|
||||
- default
|
||||
profiles:
|
||||
- backend
|
||||
<<: *default_restart
|
||||
@@ -61,7 +60,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- backend
|
||||
- default
|
||||
profiles:
|
||||
- backend
|
||||
<<: *default_restart
|
||||
@@ -80,7 +79,7 @@ services:
|
||||
- "${MINIO_PORT:-9000}:9000"
|
||||
- "${MINIO_INTERNAL_PORT:-9001}:9001"
|
||||
networks:
|
||||
- backend
|
||||
- default
|
||||
profiles:
|
||||
- backend
|
||||
command: server --console-address ":9001" /data
|
||||
@@ -102,8 +101,10 @@ services:
|
||||
REDIS_PORT: 6379
|
||||
MINIO_HOST: aviation-minio
|
||||
MINIO_PORT: 9000
|
||||
TEMPLATE_DIR: /templates
|
||||
volumes:
|
||||
- ./ssl:/ssl
|
||||
- ./templates:/templates
|
||||
ports:
|
||||
- "${API_PORT:-5000}:5000"
|
||||
depends_on:
|
||||
@@ -111,32 +112,50 @@ services:
|
||||
- redis
|
||||
- minio
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
- default
|
||||
profiles:
|
||||
- api
|
||||
<<: *default_restart
|
||||
|
||||
ui-dev:
|
||||
image: gitea.bensherriff.com/bsherriff/aviation-ui:latest
|
||||
container_name: aviation-ui-dev
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file: *env
|
||||
# Development Containers
|
||||
# ui-dev:
|
||||
# image: gitea.bensherriff.com/bsherriff/aviation-ui:latest
|
||||
# container_name: aviation-ui-dev
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
# env_file: *env
|
||||
# environment:
|
||||
# - VITE_NODE_ENV=${VITE_NODE_ENV:-development}
|
||||
# ports:
|
||||
# - "${UI_PORT:-3000}:3000"
|
||||
# volumes:
|
||||
# - ./ui/src:/app/src
|
||||
# - ./ui/public:/app/public
|
||||
# - ./ui/styles:/app/styles
|
||||
# networks:
|
||||
# - default
|
||||
# profiles:
|
||||
# - dev
|
||||
# command: ["npm", "run", "dev"]
|
||||
# <<: *default_restart
|
||||
mailpit:
|
||||
image: axllent/mailpit
|
||||
container_name: mailpit
|
||||
environment:
|
||||
- VITE_NODE_ENV=${VITE_NODE_ENV:-development}
|
||||
MP_MAX_MESSAGES: 5000
|
||||
MP_DATABASE: /data/mailpit.db
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||
ports:
|
||||
- "${UI_PORT:-3000}:3000"
|
||||
- "${MAILPIT_WEB_PORT:-8025}:8025"
|
||||
- "${MAILPIT_SMTP_PORT:-1025}:1025"
|
||||
volumes:
|
||||
- ./ui/src:/app/src
|
||||
- ./ui/public:/app/public
|
||||
- ./ui/styles:/app/styles
|
||||
- mailpit:/data
|
||||
networks:
|
||||
- frontend
|
||||
- default
|
||||
profiles:
|
||||
- frontend
|
||||
command: ["npm", "run", "dev"]
|
||||
- dev
|
||||
<<: *default_restart
|
||||
|
||||
volumes:
|
||||
@@ -144,7 +163,7 @@ volumes:
|
||||
postgres_logs:
|
||||
redis:
|
||||
minio:
|
||||
mailpit:
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
backend:
|
||||
default:
|
||||
|
||||
@@ -12,6 +12,22 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /api-docs/openapi.json {
|
||||
proxy_pass http://${NGINX_INTERNAL_HOST}:${API_PORT}/api-docs/openapi.json;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /swagger/ {
|
||||
proxy_pass http://${NGINX_INTERNAL_HOST}:${API_PORT}/swagger/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /minio/ {
|
||||
proxy_pass http://${NGINX_INTERNAL_HOST}:${MINIO_INTERNAL_PORT}/;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -32,6 +32,22 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /api-docs/openapi.json {
|
||||
proxy_pass http://${NGINX_INTERNAL_HOST}:${API_PORT}/api-docs/openapi.json;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /swagger/ {
|
||||
proxy_pass http://${NGINX_INTERNAL_HOST}:${API_PORT}/swagger/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /minio/ {
|
||||
proxy_pass http://${NGINX_INTERNAL_HOST}:${MINIO_INTERNAL_PORT}/;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
53
templates/confirm_email.html
Normal file
53
templates/confirm_email.html
Normal 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 didn’t 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> | © {{year}} Aviation Data
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
53
templates/password_reset.html
Normal file
53
templates/password_reset.html
Normal 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 didn’t 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> | © {{year}} Aviation Data
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,6 +10,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- The config file only exists in production environments -->
|
||||
<script src="./config.js"></script>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
309
ui/package-lock.json
generated
309
ui/package-lock.json
generated
@@ -1,22 +1,24 @@
|
||||
{
|
||||
"name": "aviation-ui",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aviation-ui",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@mantine/core": "^7.17.2",
|
||||
"@mantine/form": "^7.17.2",
|
||||
"@mantine/hooks": "^7.17.2",
|
||||
"@mantine/modals": "^7.17.2",
|
||||
"@mantine/notifications": "^7.17.2",
|
||||
"@mantine/core": "^8.0.0",
|
||||
"@mantine/dropzone": "^8.0.0",
|
||||
"@mantine/form": "^8.0.0",
|
||||
"@mantine/hooks": "^8.0.0",
|
||||
"@mantine/modals": "^8.0.0",
|
||||
"@mantine/notifications": "^8.0.0",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"d3": "^7.9.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
@@ -27,6 +29,7 @@
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/leaflet": "^1.9.16",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
@@ -62,15 +65,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.26.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
|
||||
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.25.9",
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.0.0"
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -194,9 +197,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
|
||||
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -204,9 +207,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
|
||||
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -224,27 +227,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz",
|
||||
"integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz",
|
||||
"integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/types": "^7.26.9"
|
||||
"@babel/template": "^7.27.1",
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz",
|
||||
"integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==",
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz",
|
||||
"integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.9"
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -286,27 +289,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
|
||||
"integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
|
||||
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
|
||||
"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/parser": "^7.26.9",
|
||||
"@babel/types": "^7.26.9"
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/parser": "^7.27.2",
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -342,14 +342,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz",
|
||||
"integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
|
||||
"integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.25.9",
|
||||
"@babel/helper-validator-identifier": "^7.25.9"
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1104,28 +1104,43 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/core": {
|
||||
"version": "7.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.17.2.tgz",
|
||||
"integrity": "sha512-R6MYhitJ0JEgrhadd31Nw9FhRaQwDHjXUs5YIlitKH/fTOz9gKSxKjzmNng3bEBQCcbEDOkZj3FRcBgTUh/F0Q==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.0.0.tgz",
|
||||
"integrity": "sha512-TskeJS2/+DbmUe85fXDoUAyErkSvR4YlbUl8MLqhjFBJUqwc72ZrLynmN13wuKtlVPakDYYjq4/IEDMReh3CYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.28",
|
||||
"clsx": "^2.1.1",
|
||||
"react-number-format": "^5.4.3",
|
||||
"react-remove-scroll": "^2.6.2",
|
||||
"react-textarea-autosize": "8.5.6",
|
||||
"react-textarea-autosize": "8.5.9",
|
||||
"type-fest": "^4.27.0"
|
||||
},
|
||||
"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-dom": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/form": {
|
||||
"version": "7.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/form/-/form-7.17.2.tgz",
|
||||
"integrity": "sha512-MxZPKXXhaZ7M1ZJOpS2wifhh186DMvNjcXa2bP04Tp9TdvTlbLAJZxKjZkQnGGgt8Atsf6/3gdeJMfG704Km6g==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.0.0.tgz",
|
||||
"integrity": "sha512-ErbbEFMEiRsK2Rn0jmFE5ohNJXHSMSbuJsL2vDUVsbIaXo6svw6ockw1WWGdiU8oEGqxM6Pd618yI9cJWNHF3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -1136,46 +1151,46 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/hooks": {
|
||||
"version": "7.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.17.2.tgz",
|
||||
"integrity": "sha512-tbErVcGZu0E4dSmE6N0k6Tv1y9R3SQmmQgwqorcc+guEgKMdamc36lucZGlJnSGUmGj+WLUgELkEQ0asdfYBDA==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.0.0.tgz",
|
||||
"integrity": "sha512-hrcgZMHUPsgu+VBfUVcJOqMG7Qi+AshYjFyc/qo0Cz8TEhqWmD0I1yJW+qj4sDTTDWRQC6kvI5c1h+87/9MvoA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/modals": {
|
||||
"version": "7.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-7.17.2.tgz",
|
||||
"integrity": "sha512-Ms8MYLJCZcxRnGfIQr4riGK2g5mpklxiEAU84vbptoAlQ2d5Iqu+CQ0XpDfamCQl/ltmPmYJYkrq52zhQWIS3w==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-8.0.0.tgz",
|
||||
"integrity": "sha512-yki3KzW9Pykf6hVSezWjeHC0FCiYD3mK2r2Sn6qE0ag+EeXZs1cbrqpjZHYov2rh6j0xzW2jnaoVbKEqYw1vUQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "7.17.2",
|
||||
"@mantine/hooks": "7.17.2",
|
||||
"@mantine/core": "8.0.0",
|
||||
"@mantine/hooks": "8.0.0",
|
||||
"react": "^18.x || ^19.x",
|
||||
"react-dom": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/notifications": {
|
||||
"version": "7.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.17.2.tgz",
|
||||
"integrity": "sha512-vg0L8cmihz0ODg4WJ9MAyK06WPt/6g67ksIUFxd4F8RfdJbIMLTsNG9yWoSfuhtXenUg717KaA917IWLjDSaqw==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.0.0.tgz",
|
||||
"integrity": "sha512-sWldvQmq4YJsknHURBNKkc3CAU0qDb0LuQGKIZGxqFlwEiXNIAI8mtfr7stgzVx+mteVW1g+HBb7FaZp07jRxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mantine/store": "7.17.2",
|
||||
"@mantine/store": "8.0.0",
|
||||
"react-transition-group": "4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "7.17.2",
|
||||
"@mantine/hooks": "7.17.2",
|
||||
"@mantine/core": "8.0.0",
|
||||
"@mantine/hooks": "8.0.0",
|
||||
"react": "^18.x || ^19.x",
|
||||
"react-dom": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/store": {
|
||||
"version": "7.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.17.2.tgz",
|
||||
"integrity": "sha512-UoMUYQK/z58hMueCkpDIXc49gPgrVO/zcpb0k+B7MFU51EIUiFzHLxLFBmWrgCAM6rzJORqN8JjyCd/PB9j4aw==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.0.0.tgz",
|
||||
"integrity": "sha512-42RWCsXMNuhpX+d/hwr5aHj+HWyi5ltbc0R0xdiUnAmqSB7CHbWxDDLh4+DbmqPrN9pTeYvpPGp3v/CG2vuGBg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.x || ^19.x"
|
||||
@@ -1553,12 +1568,6 @@
|
||||
"@babel/types": "^7.20.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
@@ -1881,6 +1890,23 @@
|
||||
"@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": {
|
||||
"version": "22.13.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
||||
@@ -3741,6 +3767,12 @@
|
||||
"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": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -4261,6 +4293,21 @@
|
||||
"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": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -4349,15 +4396,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.0.tgz",
|
||||
"integrity": "sha512-estOHrRlDMKdlQa6Mj32gIks4J+AxNsYoE0DbTTxiMy2mPzZuWSDU+N85/r1IlNR7kGfznF3VCUlvc5IUO+B9g==",
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz",
|
||||
"integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cookie": "^0.6.0",
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0",
|
||||
"turbo-stream": "2.4.0"
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -4395,9 +4440,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-textarea-autosize": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.6.tgz",
|
||||
"integrity": "sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw==",
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
|
||||
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
@@ -4437,12 +4482,6 @@
|
||||
"pify": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -4789,6 +4828,51 @@
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
||||
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.4.4",
|
||||
"picomatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/fdir": {
|
||||
"version": "6.4.4",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
|
||||
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -4821,12 +4905,6 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/turbo-stream": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
|
||||
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -5033,15 +5111,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz",
|
||||
"integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==",
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
"picomatch": "^4.0.2",
|
||||
"postcss": "^8.5.3",
|
||||
"rollup": "^4.30.1"
|
||||
"rollup": "^4.34.9",
|
||||
"tinyglobby": "^0.2.13"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
@@ -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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -11,15 +11,17 @@
|
||||
"format": "prettier --write src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^7.17.2",
|
||||
"@mantine/form": "^7.17.2",
|
||||
"@mantine/hooks": "^7.17.2",
|
||||
"@mantine/modals": "^7.17.2",
|
||||
"@mantine/notifications": "^7.17.2",
|
||||
"@mantine/core": "^8.0.0",
|
||||
"@mantine/dropzone": "^8.0.0",
|
||||
"@mantine/form": "^8.0.0",
|
||||
"@mantine/hooks": "^8.0.0",
|
||||
"@mantine/modals": "^8.0.0",
|
||||
"@mantine/notifications": "^8.0.0",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"d3": "^7.9.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
@@ -30,6 +32,7 @@
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/leaflet": "^1.9.16",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
|
||||
@@ -14,40 +14,15 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-button {
|
||||
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 {
|
||||
.custom-control a.active {
|
||||
background-color: #228be6;
|
||||
color: #fff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.map-button.active:hover {
|
||||
background-color: #187ed7;
|
||||
}
|
||||
|
||||
.map-button:hover {
|
||||
background: #e6e6e6;
|
||||
.custom-control a {
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -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 'leaflet/dist/leaflet.css';
|
||||
import './App.css';
|
||||
@@ -10,10 +10,13 @@ import { Header } from '@components/Header';
|
||||
import AirportLayer from '@components/AirportLayer.tsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Airport } from '@lib/airport.types.ts';
|
||||
import AirportDrawer from '@components/AirportDrawer.tsx';
|
||||
import { getWeatherMapUrl } from '@lib/rainViewer.ts';
|
||||
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
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
@@ -25,9 +28,17 @@ L.Icon.Default.mergeOptions({
|
||||
shadowUrl: markerShadow
|
||||
});
|
||||
|
||||
const openStreetMapUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
const lightLayerUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
|
||||
const darkLayerUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
|
||||
export interface LayerInfo {
|
||||
url: string;
|
||||
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 dark2Url = 'https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer/tile/2/0/3.pbf';
|
||||
const defaultZoom = 6;
|
||||
@@ -38,7 +49,10 @@ function App() {
|
||||
const [rainViewerUrl, setRainViewerUrl] = useState<string | null>(null);
|
||||
const initialRadarValue = Cookies.get('showRadar') === 'true';
|
||||
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(() => {
|
||||
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() {
|
||||
useMapEvents({
|
||||
baselayerchange: (e) => {
|
||||
setBaseLayer(e.name);
|
||||
Cookies.set('selectedBaseLayer', e.name, { expires: 7 });
|
||||
const index = layerMap.findIndex((layer) => layer.name === e.name);
|
||||
setSelectedLayerIndex(`${index}`);
|
||||
Cookies.set('selectedLayer', `${index}`, { expires: 7 });
|
||||
setSelectedLayer(layerMap[index]);
|
||||
}
|
||||
});
|
||||
return null;
|
||||
@@ -70,7 +94,6 @@ function App() {
|
||||
<div className='App'>
|
||||
<Header />
|
||||
<div className='map-wrapper'>
|
||||
<AirportDrawer airport={airport} setAirport={setAirport} />
|
||||
<MapContainer
|
||||
className='leaflet-container'
|
||||
attributionControl={false}
|
||||
@@ -84,31 +107,41 @@ function App() {
|
||||
]}
|
||||
scrollWheelZoom={true}
|
||||
zoomControl={false}
|
||||
markerZoomAnimation={false}
|
||||
>
|
||||
<AirportDrawer airport={airport} setAirport={setAirport} />
|
||||
<LayersControl>
|
||||
<LayersControl.BaseLayer checked={baseLayer === 'Open Street Map'} name={'Open Street Map'}>
|
||||
<TileLayer url={openStreetMapUrl} />
|
||||
</LayersControl.BaseLayer>
|
||||
<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>
|
||||
{layerMap.map((layer, index) => (
|
||||
<LayersControl.BaseLayer key={index} checked={selectedLayerIndex === `${index}`} name={layer.name}>
|
||||
<TileLayer url={layer.url} />
|
||||
</LayersControl.BaseLayer>
|
||||
))}
|
||||
</LayersControl>
|
||||
<ScaleControl />
|
||||
{rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />}
|
||||
<ZoomControl position={'bottomright'} />
|
||||
<AirportLayer setAirport={setAirport} />
|
||||
<AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} />
|
||||
<BaseLayerChangeHandler />
|
||||
<LocateControl />
|
||||
<GroupControl
|
||||
buttons={[
|
||||
{
|
||||
title: 'Toggle radar',
|
||||
active: showRadar,
|
||||
onClick: toggleRadar,
|
||||
icon: <IconRadar />
|
||||
},
|
||||
{
|
||||
title: 'Toggle non‐METAR airports',
|
||||
active: showNoMetar,
|
||||
onClick: toggleShowNoMetar,
|
||||
icon: <IconBuildingAirport />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</MapContainer>
|
||||
<UnstyledButton
|
||||
onClick={toggleRadar}
|
||||
style={{ bottom: '80px' }}
|
||||
className={`map-button ${showRadar ? 'active' : ''}`}
|
||||
>
|
||||
R
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Header } from '@components/Header';
|
||||
import { Navigate } from 'react-router';
|
||||
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() {
|
||||
const { user } = useUserContext();
|
||||
|
||||
if (user == undefined) {
|
||||
return <Navigate to={'/'} />;
|
||||
if (user == undefined || user.role != 'ADMIN') {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
Todo: administration {user?.email}
|
||||
<AirportTable />
|
||||
<AirportDrop />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
3
ui/src/components/AirportDrawer/AirportDrawer.module.css
Normal file
3
ui/src/components/AirportDrawer/AirportDrawer.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.drawer {
|
||||
background: #32495f;
|
||||
}
|
||||
27
ui/src/components/AirportDrawer/CommunicationTable.tsx
Normal file
27
ui/src/components/AirportDrawer/CommunicationTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
ui/src/components/AirportDrawer/RunwayTable.tsx
Normal file
27
ui/src/components/AirportDrawer/RunwayTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
335
ui/src/components/AirportDrawer/index.tsx
Normal file
335
ui/src/components/AirportDrawer/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
});
|
||||
28
ui/src/components/AirportDrop/AirportDrop.module.css
Normal file
28
ui/src/components/AirportDrop/AirportDrop.module.css
Normal 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);
|
||||
}
|
||||
69
ui/src/components/AirportDrop/index.tsx
Normal file
69
ui/src/components/AirportDrop/index.tsx
Normal 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'n'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>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Airport, AirportCategory } from '@lib/airport.types.ts';
|
||||
import { useMapEvents } from 'react-leaflet';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { getAirports } from '@lib/airport.ts';
|
||||
import AirportMarker from '@components/AirportMarker.tsx';
|
||||
import { LeafletEvent } from 'leaflet';
|
||||
import { LayerInfo } from '@/App.tsx';
|
||||
import { LatLng } from 'leaflet';
|
||||
|
||||
interface Bounds {
|
||||
northEast: { lat: number; lon: number };
|
||||
southWest: { lat: number; lon: number };
|
||||
}
|
||||
|
||||
export default function AirportLayer({ setAirport }: { setAirport: (airport: Airport) => void }) {
|
||||
export default function AirportLayer({
|
||||
setAirport,
|
||||
showNoMetar,
|
||||
selectedLayer
|
||||
}: {
|
||||
setAirport: (airport: Airport) => void;
|
||||
showNoMetar: boolean;
|
||||
selectedLayer: LayerInfo;
|
||||
}) {
|
||||
const [airports, setAirports] = useState<Airport[]>([]);
|
||||
const lastBoundsRef = useRef<{ ne: LatLng; sw: LatLng } | null>(null);
|
||||
|
||||
function loadAirports(event: LeafletEvent) {
|
||||
const map = event.target;
|
||||
const bounds = map.getBounds();
|
||||
const debouncedLoad = useRef(
|
||||
debounce(async (map: any) => {
|
||||
const bounds = map.getBounds();
|
||||
const ne = bounds.getNorthEast();
|
||||
const sw = bounds.getSouthWest();
|
||||
lastBoundsRef.current = { ne, sw };
|
||||
|
||||
const boundsParam: Bounds = {
|
||||
northEast: {
|
||||
lat: bounds.getNorth(),
|
||||
lon: bounds.getEast()
|
||||
},
|
||||
southWest: {
|
||||
lat: bounds.getSouth(),
|
||||
lon: bounds.getWest()
|
||||
}
|
||||
};
|
||||
|
||||
getAirports({
|
||||
bounds: boundsParam,
|
||||
metars: true,
|
||||
categories: [AirportCategory.HELIPORT, AirportCategory.SMALL, AirportCategory.MEDIUM, AirportCategory.LARGE]
|
||||
})
|
||||
.then((response) => {
|
||||
setAirports(response.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching airports:', error);
|
||||
try {
|
||||
const resp = await getAirports({
|
||||
bounds: { northEast: ne, southWest: sw },
|
||||
metars: true,
|
||||
categories: [AirportCategory.HELIPORT, AirportCategory.SMALL, AirportCategory.MEDIUM, AirportCategory.LARGE]
|
||||
});
|
||||
setAirports(resp.data);
|
||||
} catch (err) {
|
||||
console.error('fetch error', err);
|
||||
setAirports([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 300)
|
||||
).current;
|
||||
|
||||
const map = useMapEvents({
|
||||
moveend: loadAirports
|
||||
move: () => debouncedLoad(map)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (map) {
|
||||
loadAirports({ target: map } as LeafletEvent);
|
||||
}
|
||||
}, [map]);
|
||||
|
||||
const categoryOrder: { [key in AirportCategory]?: number } = {
|
||||
[AirportCategory.LARGE]: 3,
|
||||
[AirportCategory.MEDIUM]: 2,
|
||||
[AirportCategory.SMALL]: 1,
|
||||
[AirportCategory.HELIPORT]: 0
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
||||
if (map) debouncedLoad(map);
|
||||
return () => {
|
||||
debouncedLoad.cancel();
|
||||
};
|
||||
}, [map, debouncedLoad]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{sortedAirports.map((airport, index) => (
|
||||
<AirportMarker key={index} airport={airport} index={index} setAirport={setAirport} />
|
||||
{airports.map((airport, index) => (
|
||||
<div key={index}>
|
||||
{(showNoMetar || airport.latest_metar != undefined) && (
|
||||
<AirportMarker airport={airport} index={index} setAirport={setAirport} selectedLayer={selectedLayer} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,17 +2,21 @@ import { Airport, AirportCategory } from '@lib/airport.types.ts';
|
||||
import { Marker, Popup } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import { useRef } from 'react';
|
||||
import { getMarkerColor } from '@lib/metar.types.ts';
|
||||
import { LayerInfo } from '@/App.tsx';
|
||||
|
||||
export default function AirportMarker({
|
||||
index,
|
||||
airport,
|
||||
setAirport
|
||||
setAirport,
|
||||
selectedLayer
|
||||
}: {
|
||||
index: number;
|
||||
airport: Airport;
|
||||
setAirport: (airport: Airport) => void;
|
||||
selectedLayer: LayerInfo;
|
||||
}) {
|
||||
const icon = createCustomIcon(airport);
|
||||
const icon = createCustomIcon(airport, selectedLayer);
|
||||
const markerRef = useRef<L.Marker>(null);
|
||||
|
||||
return (
|
||||
@@ -27,29 +31,14 @@ export default function AirportMarker({
|
||||
mouseout: () => markerRef.current?.closePopup()
|
||||
}}
|
||||
>
|
||||
<Popup closeButton={false} autoPan={false}>
|
||||
<Popup closeButton={false} autoPan={false} interactive={false}>
|
||||
{airport.icao} - {airport.name}
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkerInfo(flightCategory: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'): [string, number] {
|
||||
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 {
|
||||
function createCustomIcon(airport: Airport, selectedLayer: LayerInfo): L.DivIcon {
|
||||
if (airport.category === AirportCategory.HELIPORT) {
|
||||
return L.divIcon({
|
||||
html: `
|
||||
@@ -73,12 +62,12 @@ function createCustomIcon(airport: Airport): L.DivIcon {
|
||||
} else {
|
||||
// Default to a filled circle.
|
||||
const flightCategory = airport.latest_metar?.flight_category || 'UNKN';
|
||||
const info = getMarkerInfo(flightCategory);
|
||||
const color = getMarkerColor(flightCategory);
|
||||
if (flightCategory == 'UNKN') {
|
||||
return L.divIcon({
|
||||
html: `
|
||||
<div style="
|
||||
background-color: ${info[0]};
|
||||
background-color: ${color};
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
@@ -93,11 +82,11 @@ function createCustomIcon(airport: Airport): L.DivIcon {
|
||||
return L.divIcon({
|
||||
html: `
|
||||
<div style="
|
||||
background-color: ${info[0]};
|
||||
background-color: ${color};
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
border: 2px solid ${selectedLayer.markerOutline};
|
||||
z-index: {info[1]}">
|
||||
</div>
|
||||
`,
|
||||
|
||||
19
ui/src/components/AirportTable/AirportTable.module.css
Normal file
19
ui/src/components/AirportTable/AirportTable.module.css
Normal 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);
|
||||
}
|
||||
51
ui/src/components/AirportTable/index.tsx
Normal file
51
ui/src/components/AirportTable/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
ui/src/components/CustomControl.tsx
Normal file
68
ui/src/components/CustomControl.tsx
Normal 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;
|
||||
}
|
||||
26
ui/src/components/Footer/Footer.module.css
Normal file
26
ui/src/components/Footer/Footer.module.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.footer {
|
||||
background: #32495f;
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
|
||||
|
||||
@media (max-width: $mantine-breakpoint-xs) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
@media (max-width: $mantine-breakpoint-xs) {
|
||||
margin-top: var(--mantine-spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: light-dark(var(--mantine-color-white));
|
||||
}
|
||||
53
ui/src/components/Footer/index.tsx
Normal file
53
ui/src/components/Footer/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import classes from './Footer.module.css';
|
||||
import { Divider, Group, Text } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { systemInfo } from '@lib/system.ts';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
|
||||
const links = [
|
||||
{ link: `/swagger/`, newTab: true, label: 'API Docs' },
|
||||
{ link: '/cookies', label: 'Cookies' },
|
||||
{ link: '/privacy', label: 'Privacy' },
|
||||
{ link: '/terms', label: 'Terms' },
|
||||
{ link: '/contact', label: 'Contact' }
|
||||
];
|
||||
|
||||
export function Footer() {
|
||||
const [version, setVersion] = useState('0.0.0');
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const items = links.map((link) => (
|
||||
<a className={classes.link} key={link.label} href={link.link} target={link.newTab ? `_blank` : ''}>
|
||||
<Text size='sm'>{link.label}</Text>
|
||||
</a>
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
systemInfo().then((info) => {
|
||||
if (info != undefined) {
|
||||
setVersion(info.version);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={classes.footer}>
|
||||
<Group className={classes.inner}>
|
||||
<Group>
|
||||
<Text size='sm'>
|
||||
API{' '}
|
||||
<a className={classes.link} href={'https://gitea.bensherriff.com/bsherriff/aviation'} target={'_blank'}>
|
||||
v{version}
|
||||
</a>
|
||||
</Text>
|
||||
<Divider orientation={'vertical'} />
|
||||
<Text size='sm'>© {new Date().getFullYear()} Aviation Data</Text>
|
||||
</Group>
|
||||
{!isMobile && (
|
||||
<Group gap='xs' justify='flex-end' wrap='nowrap'>
|
||||
{items}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
ui/src/components/GroupControl.tsx
Normal file
76
ui/src/components/GroupControl.tsx
Normal 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;
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
.header {
|
||||
height: 56px;
|
||||
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));
|
||||
}
|
||||
|
||||
.user {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.inner {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
|
||||
@@ -17,46 +17,46 @@ import Cookies from 'js-cookie';
|
||||
interface HeaderModalProps {
|
||||
type?: string;
|
||||
toggle: (input: string | undefined) => void;
|
||||
login: ({ email, password }: { email: string; password: string }) => Promise<boolean>;
|
||||
login: ({ username, password }: { username: string; password: string }) => Promise<boolean>;
|
||||
register: ({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
username,
|
||||
password
|
||||
}: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function HeaderModal({ type, toggle, login, register }: HeaderModalProps) {
|
||||
function passwordValidator(value: string) {
|
||||
if (value.trim().length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
if (value.trim().length < 6) {
|
||||
return 'Password must be at least 6 characters';
|
||||
}
|
||||
if (value.trim().length >= 128) {
|
||||
return 'Password must be at most 128 characters';
|
||||
}
|
||||
if (!/(\d)/.test(value)) {
|
||||
return 'Password must contain at least one number';
|
||||
}
|
||||
if (!/[a-z]/.test(value)) {
|
||||
return 'Password must contain at least one lowercase letter';
|
||||
}
|
||||
if (!/[A-Z]/.test(value)) {
|
||||
return 'Password must contain at least one uppercase letter';
|
||||
}
|
||||
if (!/[!@#$%^&*]/.test(value)) {
|
||||
return 'Password must contain at least one special character';
|
||||
}
|
||||
// if (!/(\d)/.test(value)) {
|
||||
// return 'Password must contain at least one number';
|
||||
// }
|
||||
// if (!/[a-z]/.test(value)) {
|
||||
// return 'Password must contain at least one lowercase letter';
|
||||
// }
|
||||
// if (!/[A-Z]/.test(value)) {
|
||||
// return 'Password must contain at least one uppercase letter';
|
||||
// }
|
||||
// if (!/[!@#$%^&*]/.test(value)) {
|
||||
// return 'Password must contain at least one special character';
|
||||
// }
|
||||
return null;
|
||||
}
|
||||
|
||||
function emailValidator(value: string) {
|
||||
if (value.trim().length == 0) {
|
||||
return 'Email is required';
|
||||
return null;
|
||||
}
|
||||
if (!/^\S+@\S+$/.test(value)) {
|
||||
return 'Invalid email';
|
||||
@@ -68,12 +68,14 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
|
||||
initialValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
username: '',
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
validate: {
|
||||
firstName: (value) => (value.trim().length > 0 ? null : 'First name is required'),
|
||||
lastName: (value) => (value.trim().length > 0 ? null : 'Last name is required'),
|
||||
username: (value) => (value.trim().length > 0 ? null : 'Username is required'),
|
||||
email: emailValidator,
|
||||
password: passwordValidator
|
||||
}
|
||||
@@ -81,7 +83,7 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
|
||||
|
||||
const loginForm = useForm({
|
||||
initialValues: {
|
||||
email: Cookies.get('email') || '',
|
||||
username: Cookies.get('username') || '',
|
||||
password: '',
|
||||
remember: Cookies.get('remember') === 'true'
|
||||
}
|
||||
@@ -150,14 +152,20 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
|
||||
{...registerForm.getInputProps('lastName')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Email'
|
||||
placeholder='you@example.com'
|
||||
label='Username'
|
||||
placeholder='Your username'
|
||||
required
|
||||
{...registerForm.getInputProps('username')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Email'
|
||||
description={'Optional for email verification and updates'}
|
||||
placeholder='you@example.com'
|
||||
{...registerForm.getInputProps('email')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
description='Passwords must be at least 8 characters long, contain at least one number, one uppercase letter, one lowercase letter, and one special character.'
|
||||
// description='Passwords must be at least 8 characters long, contain at least one number, one uppercase letter, one lowercase letter, and one special character.'
|
||||
placeholder='Your password'
|
||||
required
|
||||
mt='md'
|
||||
@@ -184,9 +192,9 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
|
||||
onSubmit={loginForm.onSubmit(async (values) => {
|
||||
Cookies.set('remember', 'true', { expires: 365 });
|
||||
if (values.remember) {
|
||||
Cookies.set('email', values.email, { expires: 365 });
|
||||
Cookies.set('username', values.username, { expires: 365 });
|
||||
} else {
|
||||
Cookies.remove('email');
|
||||
Cookies.remove('username');
|
||||
}
|
||||
const success = await login(values);
|
||||
if (success) {
|
||||
@@ -194,7 +202,12 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
|
||||
}
|
||||
})}
|
||||
>
|
||||
<TextInput label='Email' placeholder='you@example.com' required {...loginForm.getInputProps('email')} />
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Your username'
|
||||
required
|
||||
{...loginForm.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
placeholder='Your password'
|
||||
|
||||
@@ -19,9 +19,9 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP
|
||||
<UnstyledButton>
|
||||
<Group>
|
||||
<Avatar src={profilePicture ? URL.createObjectURL(profilePicture) : undefined} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div tabIndex={-1} style={{ flex: 1, userSelect: 'none' }}>
|
||||
<Text size='sm' fw={500}>
|
||||
{user.first_name} {user.last_name}
|
||||
{user.firstName} {user.lastName}
|
||||
</Text>
|
||||
<Text c='dimmed' size='xs' style={{ textTransform: 'uppercase' }}>
|
||||
{user.role}
|
||||
@@ -62,7 +62,7 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP
|
||||
)}
|
||||
</FileButton>
|
||||
<Text ta='center' fz='lg' fw={500} mt='sm'>
|
||||
{user.first_name} {user.last_name}
|
||||
{user.firstName} {user.lastName}
|
||||
</Text>
|
||||
<Text ta='center' fz='sm' c='dimmed' style={{ textTransform: 'uppercase' }}>
|
||||
{user.role}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Avatar, Box, Burger, Button, Group, Text } from '@mantine/core';
|
||||
import { useDisclosure, useToggle } from '@mantine/hooks';
|
||||
import { Autocomplete, Avatar, Box, Burger, Button, Group, Text } from '@mantine/core';
|
||||
import { useDisclosure, useMediaQuery, useToggle } from '@mantine/hooks';
|
||||
import classes from './Header.module.css';
|
||||
import { HeaderModal } from '@components/Header/HeaderModal.tsx';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
@@ -18,6 +18,7 @@ export function Header() {
|
||||
const { user, setUser } = useUserContext();
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
const [modalType, modalToggle] = useToggle([undefined, 'login', 'register', 'reset']);
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
// const [active, setActive] = useState(links[0].link);
|
||||
|
||||
// const navItems = links.map((link) => (
|
||||
@@ -35,12 +36,12 @@ export function Header() {
|
||||
// </a>
|
||||
// ));
|
||||
|
||||
async function loginUser({ email, password }: { email: string; password: string }): Promise<boolean> {
|
||||
const loginResponse = await login(email, password);
|
||||
async function loginUser({ username, password }: { username: string; password: string }): Promise<boolean> {
|
||||
const loginResponse = await login(username, password);
|
||||
if (loginResponse) {
|
||||
setUser(loginResponse);
|
||||
notifications.show({
|
||||
title: `Welcome back ${loginResponse.first_name}!`,
|
||||
title: `Welcome back ${loginResponse.firstName}!`,
|
||||
message: `You have been logged in.`,
|
||||
color: 'green',
|
||||
autoClose: 2000,
|
||||
@@ -68,12 +69,14 @@ export function Header() {
|
||||
async function registerUser({
|
||||
firstName,
|
||||
lastName,
|
||||
username,
|
||||
email,
|
||||
password
|
||||
}: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
password: string;
|
||||
}): Promise<boolean> {
|
||||
const id = notifications.show({
|
||||
@@ -84,19 +87,20 @@ export function Header() {
|
||||
withCloseButton: false
|
||||
});
|
||||
const registerResponse = await register({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
username: username,
|
||||
email: email,
|
||||
password: password
|
||||
});
|
||||
if (registerResponse) {
|
||||
const loginResponse = await login(email, password);
|
||||
const loginResponse = await login(username, password);
|
||||
if (loginResponse) {
|
||||
setUser(loginResponse);
|
||||
notifications.update({
|
||||
id,
|
||||
title: `Account created`,
|
||||
message: `Welcome ${loginResponse.first_name}!`,
|
||||
message: `Welcome ${loginResponse.firstName}!`,
|
||||
color: 'green',
|
||||
autoClose: 2000,
|
||||
loading: false
|
||||
@@ -130,28 +134,31 @@ export function Header() {
|
||||
<Box>
|
||||
<header className={classes.header}>
|
||||
<Group justify='space-between' h='100%'>
|
||||
<Burger opened={opened} onClick={toggle} hiddenFrom='sm' size='sm' />
|
||||
<Group align='center' gap='xs'>
|
||||
<Link to='/'>
|
||||
<Avatar src='/logo.svg' alt='logo' onClick={toggle} />
|
||||
</Link>
|
||||
<Text>Aviation Data</Text>
|
||||
<Text size={'xl'}>Aviation Data</Text>
|
||||
</Group>
|
||||
{/*<Group gap={5} visibleFrom='sm' className={classes.navGroup}>*/}
|
||||
{/* {navItems}*/}
|
||||
{/*</Group>*/}
|
||||
<Group align='center' gap='xs'>
|
||||
{user ? (
|
||||
<HeaderUser user={user} profilePicture={undefined} logout={logoutUser} />
|
||||
) : (
|
||||
<Group className={'user'}>
|
||||
<Button variant='default' onClick={() => modalToggle('login')}>
|
||||
Login
|
||||
</Button>
|
||||
<Button onClick={() => modalToggle('register')}>Signup</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
{!isMobile && (
|
||||
<Group align='center' gap='xs'>
|
||||
<Autocomplete placeholder={'Enter airport name or ICAO'} limit={5} />
|
||||
{user ? (
|
||||
<HeaderUser user={user} profilePicture={undefined} logout={logoutUser} />
|
||||
) : (
|
||||
<Group className={'user'}>
|
||||
<Button variant='default' onClick={() => modalToggle('login')}>
|
||||
Login
|
||||
</Button>
|
||||
<Button onClick={() => modalToggle('register')}>Signup</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{isMobile && <Burger opened={opened} onClick={toggle} size='sm' />}
|
||||
</Group>
|
||||
</header>
|
||||
</Box>
|
||||
|
||||
30
ui/src/components/LocateControl.tsx
Normal file
30
ui/src/components/LocateControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
ui/src/components/NotFound/Illustration.tsx
Normal file
12
ui/src/components/NotFound/Illustration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
ui/src/components/NotFound/NotFound.module.css
Normal file
43
ui/src/components/NotFound/NotFound.module.css
Normal 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);
|
||||
}
|
||||
27
ui/src/components/NotFound/index.tsx
Normal file
27
ui/src/components/NotFound/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Header } from '@components/Header';
|
||||
import { useUserContext } from '@components/context/UserContext.tsx';
|
||||
import { Navigate } from 'react-router';
|
||||
import { NotFound } from '@components/NotFound';
|
||||
|
||||
export function Profile() {
|
||||
const { user } = useUserContext();
|
||||
|
||||
if (user == undefined) {
|
||||
return <Navigate to={'/'} />;
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
Todo: profile {user?.email}
|
||||
Todo: profile {user?.firstName}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
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 { Center, Loader } from '@mantine/core';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
const sessionExpirationName = 'session_expiration';
|
||||
|
||||
export function UserProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().then((refreshUser) => {
|
||||
if (refreshUser) {
|
||||
setUser(refreshUser);
|
||||
const sessionExpiration = Cookies.get(sessionExpirationName);
|
||||
|
||||
if (sessionExpiration != undefined) {
|
||||
const date = new Date(parseInt(sessionExpiration) * 1000);
|
||||
const now = new Date();
|
||||
if (date > now) {
|
||||
profile().then((refreshUser) => {
|
||||
if (refreshUser) {
|
||||
setUser(refreshUser);
|
||||
} else {
|
||||
setUser(undefined);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setUser(undefined);
|
||||
Cookies.remove(sessionExpirationName);
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getRequest, postRequest } from '.';
|
||||
import { RegisterUser, User } from './account.types';
|
||||
|
||||
export async function login(email: string, password: string): Promise<User | undefined> {
|
||||
const response = await postRequest('account/login', { email, password });
|
||||
export async function login(username: string, password: string): Promise<User | undefined> {
|
||||
const response = await postRequest('account/login', { username, password });
|
||||
if (response?.status === 200) {
|
||||
return response.json();
|
||||
} else {
|
||||
@@ -23,8 +23,8 @@ export async function logout() {
|
||||
return await postRequest('account/logout', {});
|
||||
}
|
||||
|
||||
export async function refresh(): Promise<User | undefined> {
|
||||
const response = await getRequest('account/session');
|
||||
export async function profile(): Promise<User | undefined> {
|
||||
const response = await getRequest('account/profile');
|
||||
if (response?.status === 200) {
|
||||
return response.json();
|
||||
} else {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
export interface RegisterUser {
|
||||
email: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
email: string;
|
||||
username: string;
|
||||
emailVerified: boolean;
|
||||
role: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
profile_picture?: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
profilePicture?: string;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user