37 Commits

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

41
.env
View File

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

4
.gitignore vendored
View File

@@ -1,5 +1,8 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
postgres/
postgres_logs/
.vscode/
.idea/
@@ -8,7 +11,6 @@
node_modules
target/
dist/
Cargo.lock
ssl/
.DS_Store

4775
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

4
Cargo.toml Normal file
View File

@@ -0,0 +1,4 @@
[workspace]
members = [ "crates/adsb", "crates/api", "crates/lib", "crates/scheduler" ]
resolver = "2"

View File

@@ -12,10 +12,10 @@ help: ## This info
@cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@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
@@ -96,7 +106,7 @@ build: version=$(if $(v),$(v),latest)
build: folder=$(if $(f),$(f),nginx)
build: registry=$(if $(r),$(r),gitea.bensherriff.com/bsherriff)
build: image=${registry}/aviation-${folder}:${version}
build: ## Build a specific docker image (`make build f=httpd`)
build: ## Build a specific docker image from a folder
docker buildx build \
-f ${folder}/Dockerfile \
-t ${image} \
@@ -111,7 +121,11 @@ push: folder=$(if $(f),$(f),nginx)
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`)
push: ## Build and push a specific docker image from a folder
docker buildx create \
--use \
--name default-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}

View File

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

148
Taskfile.yml Normal file
View File

@@ -0,0 +1,148 @@
# https://taskfile.dev
version: '3'
dotenv: ['.env.local', '.env']
vars:
version: '{{ coalesce .version .v "latest" }}'
folder: '{{ coalesce .folder .f "nginx" }}'
registry: '{{ coalesce .registry .r "gitea.bensherriff.com/bsherriff" }}'
platform: '{{ coalesce .platform .p "linux/amd64,linux/arm64" }}'
image: '{{.registry}}/aviation-{{.folder}}:{{.version}}'
build_date:
sh: date -u +%Y-%m-%dT%H:%M:%SZ
vcs_ref:
sh: git rev-parse HEAD
context: '{{ coalesce .context .ctx "." }}'
dockerfile: >
{{- if or (eq .folder "nginx") (eq .folder "ui") -}}
{{.folder}}/Dockerfile
{{- else if eq .folder "api" -}}
crates/api/Dockerfile
{{- else if eq .folder "scheduler" -}}
crates/scheduler/Dockerfile
{{- else -}}
{{ fail (printf "Invalid folder '%s'. Valid: nginx, ui, api, scheduler" .folder) }}
{{- end -}}
tasks:
default:
cmds:
- task: docker-up
silent: true
test:
cmds:
- task: docker-backend
- task: dev-servers
dev-servers:
deps:
- task: run-api
- task: run-scheduler
- task: run-ui
# API Commands
build-api:
dir: crates/api
cmd: cargo build
format-api:
dir: crates/api
cmd: cargo fmt
run-api:
dir: crates/api
cmd: cargo run
silent: true
# Scheduler Commands
build-scheduler:
dir: crates/scheduler
cmd: cargo build
format-scheduler:
dir: crates/scheduler
cmd: cargo fmt
run-scheduler:
dir: crates/scheduler
cmd: cargo run
silent: true
# UI Commands
build-ui:
dir: ui
cmd: npm run build
format-ui:
dir: ui
cmd: npm run format
clean-ui:
dir: ui
cmd: rm -rf node_modules dist stats.html
run-ui:
dir: ui
cmd: npm run dev
silent: true
# Docker Commands
docker-backend:
cmd: docker compose --profile backend up -d
docker-up:
cmd: docker compose --profile backend --profile api up -d
docker-down:
cmd: docker compose --profile backend --profile api down
docker-clean:
cmd: docker compose --profile backend --profile api down -v
docker-refresh:
cmds:
- task: docker-clean
- task: docker-up
build:
desc: Build a specific docker image from a folder
cmds:
- |
docker buildx build \
-f {{.dockerfile}} \
-t {{.image}} \
--load \
--build-arg BUILD_DATE={{.build_date}} \
--build-arg BUILD_VERSION={{.version}} \
--build-arg VCS_REF={{.vcs_ref}} \
{{.context}}
push:
desc: Build and push a specific docker image from a folder
cmds:
- |
docker buildx create \
--use \
--driver docker-container \
--bootstrap \
--name default-builder \
--platform {{.platform}} \
|| true
ignore_error: true
- |
docker buildx build \
-f {{.dockerfile}} \
--platform {{.platform}} \
-t {{.image}} \
--push \
--build-arg BUILD_DATE={{.build_date}} \
--build-arg BUILD_VERSION={{.version}} \
--build-arg VCS_REF={{.vcs_ref}} \
{{.context}}
.
psql:
cmd: docker exec -it aviation-postgres psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -P pager=off
cert:
cmds:
- ./scripts/generate_ca_cert.sh
- ./scripts/generate_server_cert.sh ${TLS_HOST} nginx
- ./scripts/generate_server_cert.sh ${API_HOST} api
silent: true
cert-clean:
cmds:
- rm -rf ./data/certificates
silent: true

View File

@@ -1,37 +0,0 @@
[package]
name = "api"
version = "0.1.0"
edition = "2021"
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"] }
dotenv = "0.15.0"
sqlx = { version = "0.8.3", 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"] }
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"] }
regex = "1.11.1"
futures-util = "0.3.31"
rust-s3 = "0.35.1"
rand = "0.9.0"
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"] }

View File

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

View File

@@ -1,234 +0,0 @@
use actix_web::{post, web, HttpResponse, ResponseError, HttpRequest, put, get};
use crate::{
account::{verify_hash, Session, SESSION_COOKIE_NAME},
error::Error,
users::{LoginRequest, RegisterRequest, User, UserResponse},
};
use crate::account::Auth;
use crate::users::UpdateUser;
#[post("/register")]
async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse {
let register_user = user.into_inner();
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() {
Ok(user) => user,
Err(err) => return ResponseError::error_response(&err),
};
match insert_user.insert().await {
Ok(user) => {
let user_response: UserResponse = user.into();
log::info!(
"Successful user registration [Email: {}] [IP Address: {}]",
email,
ip_address
);
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,
ip_address
);
HttpResponse::Conflict().finish()
} else {
log::error!("attemptFailed to register user [Email: {}]: {}", email, err);
ResponseError::error_response(&err)
}
}
}
}
#[post("/login")]
async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
let email = &request.email;
let ip_address = req.peer_addr().unwrap().ip().to_string();
let query_user = match User::select(&email).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_cookie = session.cookie();
// Save the session to the database
if let Err(err) = session.store().await {
log::error!(
"Login attempt failure [Email: {}] [IP Address: {}]: {}",
email,
ip_address,
err
);
return ResponseError::error_response(&Error::new(500, err.to_string()));
}
log::info!(
"Successful login attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
let user_response: UserResponse = query_user.into();
HttpResponse::Ok()
.cookie(session_cookie)
.json(user_response)
} else {
log::error!(
"Invalid login attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
HttpResponse::Unauthorized().finish()
}
}
#[post("/logout")]
async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
let email = auth.user.email;
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Delete the session from the store
match req.cookie(SESSION_COOKIE_NAME) {
Some(cookie) => {
let session_id = cookie.value().to_string();
if let Err(err) = Session::delete(&session_id).await {
log::error!(
"Logout attempt failure [Email: {}] [IP Address: {}]: {}",
email,
ip_address,
err
);
return ResponseError::error_response(&Error::new(500, err.to_string()));
}
}
None => {
log::error!(
"Invalid logout attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
return ResponseError::error_response(&Error::new(400, "Invalid session".to_string()));
}
}
log::info!(
"Successful logout attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
HttpResponse::Ok().cookie(Session::empty_cookie()).finish()
}
#[get("/session")]
async fn validate_session(req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Verify a session cookie exists
match req.cookie(SESSION_COOKIE_NAME) {
// Validate the session
Some(cookie) => {
let session_id = cookie.value().to_string();
let session = match Session::replace(&session_id, &ip_address).await {
Ok(session) => session,
Err(err) => {
log::error!(
"Invalid session validate attempt [Session: {}] [IP Address: {}]",
session_id,
ip_address
);
return HttpResponse::Unauthorized()
.cookie(Session::empty_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 session_cookie = session.cookie();
log::info!(
"Successful session validate attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
HttpResponse::Ok()
.cookie(session_cookie)
.json(user_response)
}
None => HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.finish(),
}
}
#[put("/password")]
async fn change_password(
password: web::Json<String>,
req: HttpRequest,
auth: Auth,
) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
let email = auth.user.email;
if let None = User::select(&email).await {
return HttpResponse::Unauthorized().finish();
};
let update_user = UpdateUser {
email: None,
password: Some(password.into_inner()),
role: None,
first_name: None,
last_name: None,
};
match update_user.update(&email).await {
Ok(user) => {
let response: UserResponse = user.into();
log::info!(
"Successful password change attempt [Email: {}] [IP Address: {}]",
&email,
ip_address
);
HttpResponse::Ok().json(response)
}
Err(err) => {
log::error!(
"Invalid password change attempt [Email: {}] [IP Address: {}]: {}",
&email,
ip_address,
err
);
ResponseError::error_response(&Error::new(500, err.to_string()))
}
}
}
#[post("/password-reset")]
async fn password_reset(req: HttpRequest, _auth: Auth) -> HttpResponse {
let _ip_address = req.peer_addr().unwrap().ip().to_string();
HttpResponse::Ok().finish()
}
pub fn init_routes(config: &mut web::ServiceConfig) {
config.service(
web::scope("account")
.service(register)
.service(login)
.service(logout)
.service(change_password)
.service(validate_session),
);
}

View File

@@ -1,150 +0,0 @@
use actix_web::cookie::{time::Duration, Cookie};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use redis::{AsyncCommands, RedisResult};
use tokio::task;
use crate::{
db::redis_async_connection,
error::{Error, ApiResult},
};
use super::{csprng, hash, verify_hash};
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
pub const SESSION_COOKIE_NAME: &str = "session";
#[derive(Debug, Serialize, Deserialize)]
pub struct Session {
pub session_id: String,
pub email: 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 new(take: usize, email: &str, ip_address: &str, ttl: Option<i64>) -> Self {
let now = Utc::now();
Self {
session_id: csprng(take),
email: email.to_string(),
ip_address: hash(&ip_address).unwrap(),
expires_at: match ttl {
Some(ttl) => Some(now + chrono::Duration::seconds(ttl)),
None => None,
},
}
}
pub async fn store(&self) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let key = self.session_id.clone();
let value = serde_json::to_string(self)?;
let result: RedisResult<()> = match self.expires_at {
Some(expires_at) => {
let ttl = expires_at.timestamp() - Utc::now().timestamp();
conn.set_ex(key, &value, ttl as u64).await
}
None => conn.set(key, value).await,
};
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn get(session_id: &str) -> ApiResult<Self> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<Option<String>> = conn.get(session_id).await;
match result {
Ok(Some(value)) => Ok(serde_json::from_str(&value)?),
Ok(None) => Err(Error::new(401, format!("Missing session {}", session_id))),
Err(err) => Err(err.into()),
}
}
pub async fn replace(session_id: &str, ip_address: &str) -> ApiResult<Self> {
let mut session = Self::verify(session_id, ip_address).await?;
let session_id_owned = session_id.to_owned();
task::spawn(async move {
if let Err(err) = Self::delete(&session_id_owned).await {
log::error!(
"Error deleting old session in replace session call: {}",
err
);
};
});
session = Session::default(&session.email, ip_address);
session.store().await?;
Ok(session)
}
pub async fn delete(session_id: &str) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<()> = conn.del(session_id).await;
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn verify(session_id: &str, ip_address: &str) -> ApiResult<Self> {
let session = Self::get(session_id).await?;
// Check if the IP Address matches the Session's IP Address
if verify_hash(ip_address, &session.ip_address) {
Ok(session)
} else {
Err(Error::new(401, "IP Address does not match".to_string()))
}
}
pub fn 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_COOKIE_NAME, self.session_id.clone())
.path("/")
.max_age(Duration::seconds(ttl))
.secure(true)
.http_only(true)
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
log::trace!(
"Development cookie [Email: {}]: {}",
self.email,
self.session_id
);
cookie.set_secure(false);
cookie.set_http_only(false);
}
}
cookie
}
pub fn empty_cookie() -> Cookie<'static> {
let mut cookie = Cookie::build(SESSION_COOKIE_NAME, "")
.path("/")
.max_age(Duration::seconds(-1))
.secure(true)
.http_only(true)
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
cookie.set_secure(false);
cookie.set_http_only(false);
}
}
cookie
}
}

View File

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

View File

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

View File

@@ -1,186 +0,0 @@
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;
#[post("/import")]
async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
if let Err(err) = verify_role(&auth, ADMIN_ROLE) {
return ResponseError::error_response(&err);
};
while let Some(item) = payload.next().await {
let mut bytes = web::BytesMut::new();
let mut field = match item {
Ok(field) => field,
Err(err) => return ResponseError::error_response(&err),
};
// Build bytes from chunks
while let Some(chunk) = field.next().await {
let data = match chunk {
Ok(data) => data,
Err(err) => {
log::error!("Failed to get chunk: {}", err);
return ResponseError::error_response(&err);
}
};
bytes.extend_from_slice(&data);
}
// Convert bytes to Vec<Airport>
let airports: Vec<Airport> = match serde_json::from_slice(&bytes) {
Ok(a) => a,
Err(err) => {
log::error!("Failed to parse JSON: {}", err);
return ResponseError::error_response(&err);
}
};
match Airport::insert_all(airports).await {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
};
}
HttpResponse::Ok().finish()
}
#[get("")]
async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let mut query = match web::Query::<AirportQuery>::from_query(req.query_string()) {
Ok(q) => q.into_inner(),
Err(err) => {
log::error!("{}", err);
AirportQuery::default()
}
};
let total = Airport::count(&query).await;
let page = query.page.unwrap_or(1);
let mut limit = query.limit.unwrap_or(total as u32);
if limit > 1000 {
limit = 1000
}
query.limit = Some(limit);
query.page = Some(page);
let client = &data.client;
match Airport::select_all(client, &query).await {
Ok(airports) => HttpResponse::Ok().json(Paged {
data: airports,
page,
limit,
total,
}),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[get("/{icao}")]
async fn get_airport(
data: web::Data<AppState>,
icao: web::Path<String>,
req: HttpRequest,
) -> HttpResponse {
let metar = match web::Query::<AirportQuery>::from_query(req.query_string()) {
Ok(q) => q.metars.unwrap_or_else(|| false),
Err(err) => {
log::error!("{}", err);
false
}
};
let client = &data.client;
match Airport::select(client, &icao.into_inner(), metar).await {
Some(airport) => HttpResponse::Ok().json(airport),
None => HttpResponse::NotFound().finish(),
}
}
#[post("")]
async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
};
match airport.insert().await {
Ok(a) => HttpResponse::Ok().json(a),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[put("/{icao}")]
async fn update_airport(
icao: web::Path<String>,
airport: web::Json<UpdateAirport>,
auth: Auth,
) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
};
match Airport::update(&icao.into_inner(), &airport.into_inner()).await {
Ok(a) => HttpResponse::Ok().json(a),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[delete("")]
async fn delete_airports(auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
};
match Airport::delete_all().await {
Ok(_) => HttpResponse::NoContent().finish(),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[delete("/{icao}")]
async fn delete_airport(icao: web::Path<String>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
};
match Airport::delete(&icao.into_inner()).await {
Ok(_) => HttpResponse::NoContent().finish(),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
pub fn init_routes(config: &mut web::ServiceConfig) {
config.service(
web::scope("airports")
.service(import_airports)
.service(get_airports)
.service(get_airport)
.service(insert_airport)
.service(update_airport)
.service(delete_airports)
.service(delete_airport),
);
}

View File

@@ -1,177 +0,0 @@
use crate::error::ApiResult;
use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection, RedisResult};
use s3::{Bucket, Region, creds::Credentials, BucketConfiguration, request::ResponseData};
use serde::{Deserialize, Serialize};
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();
static BUCKET: OnceLock<Bucket> = OnceLock::new();
pub async fn initialize() -> ApiResult<()> {
// Setup Postgres pool connection
let pool = {
let user = std::env::var("POSTGRES_USER").unwrap_or("aviation".to_string());
let password = std::env::var("POSTGRES_PASSWORD").expect("POSTGRES_PASSWORD must be set");
let host: String = std::env::var("POSTGRES_HOST").expect("POSTGRES_HOST must be set");
let port = std::env::var("POSTGRES_PORT").unwrap_or("5432".to_string());
let name = std::env::var("POSTGRES_NAME").unwrap_or("aviation".to_string());
let db_url = format!(
"postgres://{}:{}@{}:{}/{}",
&user, &password, &host, &port, &name
);
log::info!(
"Connecting to database at postgres://{}:*****@{}:{}/{}...",
&user,
&host,
&port,
&name
);
PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(30))
.connect(&db_url)
.await?
};
match POOL.set(pool) {
Ok(_) => log::info!("Database connection established"),
Err(_) => log::warn!("Database pool already initialized"),
}
// Setup Redis connection
let redis = {
let host = std::env::var("REDIS_HOST").unwrap_or("localhost".to_string());
let port = std::env::var("REDIS_PORT").unwrap_or("6379".to_string());
let url = format!("redis://{}:{}", host, port);
log::info!("Connecting to redis at {}", &url);
RedisClient::open(url).expect("Failed to create redis client")
};
match REDIS.set(redis) {
Ok(_) => log::info!("Redis connection established"),
Err(_) => log::warn!("Redis client already initialized"),
}
// Setup Bucket connection
let bucket = {
let protocol = std::env::var("MINIO_PROTOCOL").unwrap_or("http".to_string());
let host = std::env::var("MINIO_HOST").unwrap_or("localhost".to_string());
let port = std::env::var("MINIO_PORT").unwrap_or("9000".to_string());
let user = std::env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set");
let password = std::env::var("MINIO_ROOT_PASSWORD").expect("MINIO_ROOT_PASSWORD is not set");
let bucket_name = std::env::var("MINIO_BUCKET").unwrap_or("aviation".to_string());
let url = format!("{}://{}:{}", protocol, host, port);
let region = Region::Custom {
region: "".to_string(),
endpoint: url.to_string(),
};
let credentials = Credentials {
access_key: Some(user),
secret_key: Some(password),
security_token: None,
session_token: None,
expiration: None,
};
let bucket = Bucket::new(&bucket_name, region.clone(), credentials.clone())?.with_path_style();
log::info!("Checking for object in bucket at {}", &region.endpoint());
match bucket.head_object("/").await {
Ok(_) => bucket,
Err(_) => {
log::debug!("Creating '{}' bucket", &bucket_name);
let response = match Bucket::create_with_path_style(
&bucket_name,
region,
credentials,
BucketConfiguration::default(),
)
.await
{
Ok(response) => response,
Err(err) => {
log::error!("Failed to create bucket '{}': {}", &bucket_name, err);
return Err(err.into());
}
};
response.bucket
}
}
};
match BUCKET.set(*bucket) {
Ok(_) => log::info!("Bucket connection initialized"),
Err(_) => log::warn!("Bucket connection already initialized"),
}
// Run migrations
match run_migrations().await {
Ok(_) => log::debug!("Successfully ran database migrations"),
Err(e) => log::error!("Failed to run migrations: {}", e),
}
log::info!("Database initialized");
Ok(())
}
pub fn pool() -> &'static Pool<Postgres> {
POOL.get().unwrap()
}
fn redis() -> &'static RedisClient {
REDIS.get().unwrap()
}
pub fn redis_connection() -> RedisResult<redis::Connection> {
let conn = redis().get_connection()?;
Ok(conn)
}
pub async fn redis_async_connection() -> RedisResult<RedisConnection> {
let conn = redis().get_multiplexed_async_connection().await?;
Ok(conn)
}
async fn run_migrations() -> ApiResult<()> {
log::debug!("Running database migrations");
let pool = pool();
sqlx::migrate!().run(pool).await?;
Ok(())
}
pub async fn upload_file(path: &str, content: &[u8]) -> ApiResult<ResponseData> {
let response = BUCKET.get().unwrap().put_object(path, content).await?;
Ok(response)
}
pub async fn get_file(path: &str) -> ApiResult<Vec<u8>> {
let response = BUCKET.get().unwrap().get_object(path).await?;
let bytes = response.bytes();
Ok(bytes.to_vec())
}
pub async fn delete_file(path: &str) -> ApiResult<ResponseData> {
let response = BUCKET.get().unwrap().delete_object(path).await?;
Ok(response)
}
#[derive(Serialize, Deserialize)]
pub struct Paged<T> {
pub data: T,
pub page: u32,
pub limit: u32,
pub total: i64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Coordinate {
pub lon: f64,
pub lat: f64,
}

View File

@@ -1,206 +0,0 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use log::warn;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt;
pub type ApiResult<T> = Result<T, Error>;
#[derive(Debug, Deserialize, Serialize)]
pub struct Error {
pub status: u16,
pub details: String,
}
impl Error {
pub fn new(status: u16, message: String) -> Self {
Self {
status,
details: message,
}
}
pub fn to_http_response(&self) -> HttpResponse {
let status = StatusCode::from_u16(self.status).unwrap_or_else(|err| {
warn!("{}", err);
StatusCode::INTERNAL_SERVER_ERROR
});
HttpResponse::build(status).body(self.details.to_string())
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.details.as_str())
}
}
impl std::error::Error for Error {
fn description(&self) -> &str {
&self.details
}
}
impl ResponseError for Error {
fn error_response(&self) -> HttpResponse {
let status =
StatusCode::from_u16(self.status).unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR);
let status_code = status.as_u16();
let details = match status_code {
401 => String::from("Unauthorized"),
code if code < 500 => self.details.clone(),
_ => {
log::error!("Internal server error: {}", self.details);
String::from("Internal Server Error")
}
};
HttpResponse::build(status).json(json!({ "status": status_code, "details": details }))
}
}
impl From<std::io::Error> for Error {
fn from(error: std::io::Error) -> Self {
Self::new(500, format!("Unknown IO error: {}", error))
}
}
impl From<chrono::ParseError> for Error {
fn from(error: chrono::ParseError) -> Self {
Self::new(500, format!("Parse error: {}", error))
}
}
impl From<core::num::ParseIntError> for Error {
fn from(error: core::num::ParseIntError) -> Self {
Self::new(500, format!("Parse error: {}", error))
}
}
impl From<core::num::ParseFloatError> for Error {
fn from(error: core::num::ParseFloatError) -> Self {
Self::new(500, format!("Parse error: {}", error))
}
}
impl From<std::env::VarError> for Error {
fn from(error: std::env::VarError) -> Self {
Self::new(
500,
format!("Unknown environment variable error: {}", error),
)
}
}
impl From<reqwest::Error> for Error {
fn from(error: reqwest::Error) -> Self {
match error.status() {
Some(status_code) => {
if status_code.is_client_error() {
Self::new(500, format!("Client reqwest error: {}", error))
} else if status_code.is_server_error() {
Self::new(500, format!("Server reqwest error: {}", error))
} else {
Self::new(500, format!("Unknown reqwest error: {:?}", error))
}
}
_ => Self::new(500, format!("Unknown reqwest error: {:?}", error)),
}
}
}
impl From<serde_json::Error> for Error {
fn from(error: serde_json::Error) -> Self {
Self::new(500, format!("Unknown serde_json error: {}", error))
}
}
impl From<argon2::password_hash::Error> for Error {
fn from(error: argon2::password_hash::Error) -> Self {
Self::new(500, format!("Unknown argon2 error: {}", error))
}
}
impl From<redis::RedisError> for Error {
fn from(error: redis::RedisError) -> Self {
Self::new(500, format!("Unknown redis error: {}", error))
}
}
impl From<s3::error::S3Error> for Error {
fn from(error: s3::error::S3Error) -> Self {
match error {
s3::error::S3Error::Credentials(err) => {
Self::new(500, format!("Unknown s3 credentials error: {}", err))
}
s3::error::S3Error::FromUtf8(err) => {
Self::new(500, format!("Unknown s3 from utf8 error: {}", err))
}
s3::error::S3Error::FmtError(err) => Self::new(500, format!("Unknown s3 fmt error: {}", err)),
s3::error::S3Error::HeaderToStr(err) => {
Self::new(500, format!("Unknown s3 header to str error: {}", err))
}
s3::error::S3Error::HmacInvalidLength(err) => Self::new(
500,
format!("Unknown s3 hmac invalid length error: {}", err),
),
s3::error::S3Error::Http(error) => Self::new(error.status_code().as_u16(), error.to_string()),
_ => {
let re = Regex::new(r"HTTP (\d{3})").unwrap();
// Apply the regex to the input string
if let Some(captures) = re.captures(&error.to_string()) {
if let Some(http_code_str) = captures.get(1) {
if let Ok(http_code) = http_code_str.as_str().parse::<u16>() {
return Self::new(http_code, error.to_string());
}
}
}
Self::new(500, format!("Unknown s3 error: {}", error))
}
}
}
}
impl From<sqlx::Error> for Error {
fn from(error: sqlx::Error) -> Self {
match error {
sqlx::Error::RowNotFound => Error::new(404, "Not found".to_string()),
sqlx::Error::ColumnIndexOutOfBounds { .. } => Error::new(422, error.to_string()),
sqlx::Error::ColumnNotFound { .. } => Error::new(422, error.to_string()),
sqlx::Error::ColumnDecode { .. } => Error::new(422, error.to_string()),
sqlx::Error::Decode(_) => Error::new(422, error.to_string()),
sqlx::Error::PoolTimedOut => Error::new(503, error.to_string()),
sqlx::Error::PoolClosed => Error::new(503, error.to_string()),
sqlx::Error::Tls(_) => Error::new(500, error.to_string()),
sqlx::Error::Io(_) => Error::new(500, error.to_string()),
sqlx::Error::Protocol(_) => Error::new(500, error.to_string()),
sqlx::Error::Configuration(_) => Error::new(500, error.to_string()),
sqlx::Error::AnyDriverError(_) => Error::new(500, error.to_string()),
sqlx::Error::Database(err) => {
if let Some(code) = err.code() {
match code.trim() {
// Unique violation
"23505" => return Error::new(409, err.to_string()),
_ => (),
}
}
Error::new(500, err.to_string())
}
sqlx::Error::Migrate(_) => Error::new(500, error.to_string()),
sqlx::Error::TypeNotFound { type_name } => {
Error::new(500, format!("Type not found: {}", type_name))
}
sqlx::Error::WorkerCrashed => Error::new(500, error.to_string()),
_ => Error::new(500, error.to_string()),
}
}
}
impl From<sqlx::migrate::MigrateError> for Error {
fn from(error: sqlx::migrate::MigrateError) -> Self {
Error::new(500, error.to_string())
}
}

View File

@@ -1,142 +0,0 @@
use std::env;
use std::time::Duration;
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};
mod account;
mod airports;
mod db;
mod error;
mod metars;
mod scheduler;
mod system;
mod users;
#[derive(Debug, Clone)]
struct AppState {
client: reqwest::Client,
}
#[actix_web::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
initialize_environment()?;
db::initialize().await?;
// scheduler::update_airports();
// Initialize admin user
let admin_email = env::var("ADMIN_EMAIL");
let admin_password = env::var("ADMIN_PASSWORD");
if admin_email.is_ok() && admin_password.is_ok() {
let email = admin_email.unwrap();
if User::select(&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" {
log::warn!(
"Default admin credentials are in use, update the ADMIN_EMAIL and ADMIN_PASSWORD."
);
}
let admin_user = User {
email,
password_hash,
role: ADMIN_ROLE.to_string(),
first_name: "Admin".to_string(),
last_name: "".to_string(),
updated_at: Default::default(),
created_at: Default::default(),
};
match admin_user.insert().await {
Ok(_) => log::debug!("Default administrator was successfully created"),
Err(err) => {
log::warn!("{}", err);
}
};
}
}
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());
let server = match HttpServer::new(move || {
let cors = Cors::default()
.allow_any_origin()
.allow_any_method()
.allow_any_header()
.supports_credentials()
.max_age(3600);
App::new()
.wrap(cors)
.wrap(Logger::default())
.app_data(web::Data::new(state.clone()))
.service(
web::scope("api")
.configure(airports::init_routes)
.configure(metars::init_routes)
.configure(account::init_routes)
.configure(users::init_routes)
.configure(system::init_routes),
)
})
.bind(format!("{}:{}", host, port))
{
Ok(b) => {
log::info!("Server bound to {}:{}", host, port);
b
}
Err(err) => {
log::error!("Could not bind server: {}", err);
return Err(err.into());
}
};
if let Err(err) = server.run().await {
return Err(err.into());
}
Ok(())
}
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();
// 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);
}
}
}
}
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,api=info"));
Ok(())
}

View File

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

View File

@@ -1,37 +0,0 @@
use crate::metars::Metar;
use actix_web::{get, web, HttpResponse, HttpRequest};
use log::error;
use serde::{Deserialize, Serialize};
use crate::AppState;
#[derive(Debug, Serialize, Deserialize)]
struct FindAllParameters {
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();
let icao_option = &parameters.icaos;
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 = &parameters.force.unwrap_or(false);
let client = &data.client;
let metars = match Metar::find_all(client, &icaos, force).await {
Ok(a) => a,
Err(err) => {
error!("{}", err);
return err.to_http_response();
}
};
HttpResponse::Ok().json(metars)
}
pub fn init_routes(config: &mut web::ServiceConfig) {
config.service(find_all);
}

View File

@@ -1,74 +0,0 @@
// use tokio::time::{sleep, Duration};
// use crate::airports::{AirportDb, AirportFilter};
// use crate::metars::Metar;
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;
// }
// });
}

View File

@@ -1,29 +0,0 @@
use std::env;
use actix_web::{get, web, HttpResponse};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct SystemInfo {
version: String,
healthy: bool,
}
#[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 info = SystemInfo { version, healthy };
HttpResponse::Ok().json(info)
}
pub fn init_routes(config: &mut web::ServiceConfig) {
config.service(web::scope("/system").service(info));
}

View File

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

View File

@@ -1,165 +0,0 @@
// use actix_multipart::Multipart;
// use actix_web::{get, post, delete, web, HttpResponse, ResponseError};
// use futures_util::StreamExt;
// use crate::{
// auth::Auth,
// db::{delete_file, get_file, upload_file},
// error::ServiceError,
// users::User,
// };
// #[get("/favorites")]
// async fn get_favorites(auth: Auth) -> HttpResponse {
// match User::get_by_email(&auth.user.email) {
// Ok(user) => return HttpResponse::Ok().json(user.favorites),
// Err(err) => return ResponseError::error_response(&err),
// }
// }
// #[post("/favorites/{icao}")]
// async fn add_favorite(icao: web::Path<String>, auth: Auth) -> HttpResponse {
// match User::get_by_email(&auth.user.email) {
// Ok(user) => {
// if user.favorites.contains(&icao) {
// // Check if the airport ICAO is already in the user's favorites
// return HttpResponse::Conflict().finish();
// } else {
// // Add the airport ICAO to the user's favorites
// let mut favorites = user.favorites;
// favorites.push(icao.into_inner());
// match User::update_favorites(&user.email, favorites) {
// Ok(_) => return HttpResponse::Ok().finish(),
// Err(err) => return ResponseError::error_response(&err),
// }
// }
// }
// Err(err) => return ResponseError::error_response(&err),
// }
// }
// #[delete("/favorites/{icao}")]
// async fn delete_favorite(icao: web::Path<String>, auth: Auth) -> HttpResponse {
// let icao: String = icao.into_inner();
// match User::get_by_email(&auth.user.email) {
// Ok(user) => {
// if user.favorites.contains(&icao) {
// // Check if the airport ICAO is already in the user's favorites
// let mut favorites = user.favorites;
// favorites.retain(|x| x != &icao);
// match User::update_favorites(&user.email, favorites) {
// Ok(_) => return HttpResponse::Ok().finish(),
// Err(err) => return ResponseError::error_response(&err),
// }
// } else {
// // Remove the airport ICAO from the user's favorites
// return HttpResponse::Conflict().finish();
// }
// }
// Err(err) => return ResponseError::error_response(&err),
// }
// }
// #[post("/picture")]
// async fn set_picture(mut payload: Multipart, auth: Auth) -> HttpResponse {
// while let Some(item) = payload.next().await {
// let mut bytes = web::BytesMut::new();
// let mut field = match item {
// Ok(field) => field,
// Err(err) => return ResponseError::error_response(&err),
// };
// let content_type = field.content_disposition();
// let filename = match content_type.unwrap().get_filename() {
// Some(name) => match name.split(".").last() {
// Some(ext) => match ext {
// "apng" | "avif" | "gif" | "jpg" | "jpeg" | "jfif" | "pjpeg" | "pjp" | "png" | "svg"
// | "webp" => name,
// _ => {
// return ResponseError::error_response(&ServiceError::new(
// 400,
// "File extension is not supported".to_string(),
// ))
// }
// },
// None => {
// return ResponseError::error_response(&ServiceError::new(
// 400,
// "Unknown file extension".to_string(),
// ))
// }
// },
// None => {
// return ResponseError::error_response(&ServiceError::new(
// 400,
// "File name is not provided".to_string(),
// ))
// }
// };
// let path = format!("users/{}/{}", auth.user.email, filename);
// while let Some(chunk) = field.next().await {
// let data = match chunk {
// Ok(data) => data,
// Err(err) => return ResponseError::error_response(&err),
// };
// bytes.extend_from_slice(&data);
// }
// match upload_file(&path, &bytes).await {
// Ok(_) => {
// match User::update_profile_picture(&auth.user.email, Some(&path)) {
// Ok(_) => {}
// Err(err) => return ResponseError::error_response(&err),
// };
// }
// Err(err) => return ResponseError::error_response(&err),
// };
// }
// HttpResponse::Ok().finish()
// }
// #[get("/picture")]
// async fn get_picture(auth: Auth) -> HttpResponse {
// let user = match User::get_by_email(&auth.user.email) {
// Ok(user) => user,
// Err(err) => return ResponseError::error_response(&err),
// };
// if let Some(path) = user.profile_picture {
// match get_file(&path).await {
// Ok(bytes) => HttpResponse::Ok().body(bytes),
// Err(err) => ResponseError::error_response(&err),
// }
// } else {
// HttpResponse::NotFound().finish()
// }
// }
// #[delete("/picture")]
// async fn delete_picture(auth: Auth) -> HttpResponse {
// let user = match User::get_by_email(&auth.user.email) {
// Ok(user) => user,
// Err(err) => return ResponseError::error_response(&err),
// };
// if let Some(path) = user.profile_picture {
// match delete_file(&path).await {
// Ok(_) => match User::update_profile_picture(&auth.user.email, None) {
// Ok(_) => HttpResponse::Ok().finish(),
// Err(err) => ResponseError::error_response(&err),
// },
// Err(err) => ResponseError::error_response(&err),
// }
// } else {
// HttpResponse::NotFound().finish()
// }
// }
pub fn init_routes(_config: &mut actix_web::web::ServiceConfig) {
// config.service(
// web::scope("users")
// .service(get_favorites)
// .service(add_favorite)
// .service(delete_favorite)
// .service(set_picture)
// .service(get_picture)
// .service(delete_picture),
// );
}

View File

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

View File

@@ -0,0 +1,18 @@
meta {
name: Confirm Password Reset
type: http
seq: 9
}
post {
url: {{API_URL}}/account/password/verify
body: json
auth: none
}
body:json {
{
"token": "token",
"password": "New Password"
}
}

View File

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

View File

@@ -0,0 +1,18 @@
meta {
name: Login Admin (Default)
type: http
seq: 5
}
post {
url: {{API_URL}}/account/login
body: json
auth: none
}
body:json {
{
"username": "admin",
"password": "changeme"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
meta {
name: Resend Email Confirmation
type: http
seq: 2
}
post {
url: {{API_URL}}/account/register/resend
body: none
auth: none
}

View File

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

View File

@@ -0,0 +1,17 @@
meta {
name: Verify Email Confirmation
type: http
seq: 3
}
post {
url: {{API_URL}}/account/register/verify
body: json
auth: none
}
body:json {
{
"token": "token"
}
}

3
bruno/Account/folder.bru Normal file
View File

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

View File

@@ -5,7 +5,7 @@ meta {
}
get {
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
}

View File

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

View File

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

View File

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

475
crates/adsb/Cargo.lock generated Normal file
View File

@@ -0,0 +1,475 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adsb"
version = "0.1.0"
dependencies = [
"clap",
"ctrlc",
"env_logger",
"log",
"rusb",
]
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
[[package]]
name = "anstyle-parse"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
dependencies = [
"anstyle",
"once_cell",
"windows-sys",
]
[[package]]
name = "bitflags"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
[[package]]
name = "cc"
version = "1.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362"
dependencies = [
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "clap"
version = "4.5.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "colorchoice"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "ctrlc"
version = "3.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "697b5419f348fd5ae2478e8018cb016c00a5881c7f46c717de98ffd135a5651c"
dependencies = [
"nix",
"windows-sys",
]
[[package]]
name = "env_filter"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "jiff"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a064218214dc6a10fbae5ec5fa888d80c45d611aba169222fc272072bf7aef6"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde",
]
[[package]]
name = "jiff-static"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "199b7932d97e325aff3a7030e141eafe7f2c6268e1d1b24859b753a627f45254"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "libc"
version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libusb1-sys"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "portable-atomic"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
dependencies = [
"portable-atomic",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "rusb"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4"
dependencies = [
"libc",
"libusb1-sys",
]
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"

11
crates/adsb/Cargo.toml Normal file
View File

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

10
crates/adsb/README.md Normal file
View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

231
crates/adsb/src/main.rs Normal file
View File

@@ -0,0 +1,231 @@
#![feature(extern_types)]
mod constants;
mod device;
mod error;
mod frame;
mod hex;
use std::ffi::{c_char, c_int, c_void, CStr};
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;
#[link(name = "rtlsdr")]
unsafe extern "C" {
fn rtlsdr_get_device_count() -> u32;
fn rtlsdr_get_device_name(index: u32) -> *const c_char;
fn rtlsdr_get_device_usb_strings(
index: u32,
manufact: *mut c_char,
product: *mut c_char,
serial: *mut c_char,
) -> c_int;
type rtlsdr_dev_t;
fn rtlsdr_open(dev: *mut *mut rtlsdr_dev_t, index: u32) -> c_int;
fn rtlsdr_close(dev: *mut rtlsdr_dev_t) -> c_int;
fn rtlsdr_set_sample_rate(dev: *mut rtlsdr_dev_t, rate: u32) -> c_int;
fn rtlsdr_set_center_freq(dev: *mut rtlsdr_dev_t, freq: u32) -> c_int;
fn rtlsdr_set_agc_mode(dev: *mut rtlsdr_dev_t, on: c_int) -> c_int;
fn rtlsdr_set_tuner_gain_mode(dev: *mut rtlsdr_dev_t, manual: c_int) -> c_int;
fn rtlsdr_reset_buffer(dev: *mut rtlsdr_dev_t) -> c_int;
fn rtlsdr_read_sync(
dev: *mut rtlsdr_dev_t,
buf: *mut c_void,
len: c_int,
n_read: *mut c_int,
) -> c_int;
}
struct Device {
ptr: *mut rtlsdr_dev_t,
}
impl Device {
fn open(index: u32) -> Result<Self, String> {
let mut dev: *mut rtlsdr_dev_t = std::ptr::null_mut();
let rc = unsafe { rtlsdr_open(&mut dev as *mut _, index) };
if rc != 0 {
return Err(format!("Failed to open device: {}", rc));
}
Ok(Self { ptr: dev })
}
fn set_sample_rate(&mut self, rate: u32) -> Result<(), String> {
let rc = unsafe { rtlsdr_set_sample_rate(self.ptr, rate) };
if rc != 0 {
return Err(format!("Failed to set sample rate: {}", rc));
}
Ok(())
}
fn set_center_freq(&mut self, freq: u32) -> Result<(), String> {
let rc = unsafe { rtlsdr_set_center_freq(self.ptr, freq) };
if rc != 0 {
return Err(format!("Failed to set center freq: {}", rc));
}
Ok(())
}
fn set_agc(&mut self, on: bool) -> Result<(), String> {
let rc = unsafe { rtlsdr_set_agc_mode(self.ptr, if on { 1 } else { 0 }) };
if rc != 0 {
return Err(format!("Failed to set AGC: {}", rc));
}
Ok(())
}
// manual = false -> auto-gain, manual = true -> set a gain
fn set_tuner_gain_mode(&mut self, manual: bool) -> Result<(), String> {
let rc = unsafe { rtlsdr_set_tuner_gain_mode(self.ptr, if manual { 1 } else { 0 }) };
if rc != 0 {
return Err(format!("Failed to set tuner gain mode: {}", rc));
}
Ok(())
}
fn set_buffer(&mut self) -> Result<(), String> {
let rc = unsafe { rtlsdr_reset_buffer(self.ptr) };
if rc != 0 {
return Err(format!("Failed to reset buffer: {}", rc));
}
Ok(())
}
// Returns the number of bytes placed in buf (I/Q interleaved, u8
fn read_sync(&mut self, buf: &mut [u8]) -> Result<usize, String> {
let mut n_read: c_int = 0;
let rc = unsafe {
rtlsdr_read_sync(
self.ptr,
buf.as_mut_ptr() as *mut c_void,
buf.len() as c_int,
&mut n_read as *mut _,
)
};
if rc != 0 {
return Err(format!("Failed to read sync: {}", rc));
}
Ok(n_read as usize)
}
}
impl Drop for Device {
fn drop(&mut self) {
if !self.ptr.is_null() {
unsafe { rtlsdr_close(self.ptr) };
self.ptr = std::ptr::null_mut();
}
}
}
fn cstr_from_ptr(ptr: *const c_char) -> String {
if ptr.is_null() {
return String::new();
}
unsafe { CStr::from_ptr(ptr).to_string_lossy().into_owned() }
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
unsafe {
let count = rtlsdr_get_device_count();
log::debug!("Found {} devices", count);
for i in 0..count {
let name = cstr_from_ptr(rtlsdr_get_device_name(i));
log::debug!("Device {}: {}", i, name);
}
}
Ok(())
}
// #[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");
// }
// }

File diff suppressed because it is too large Load Diff

29
crates/api/Cargo.toml Normal file
View File

@@ -0,0 +1,29 @@
[package]
name = "api"
version = "0.1.3"
edition = "2024"
authors = ["Ben Sherriff <ben@bensherriff.com>"]
repository = "https://gitea.bensherriff.com/bsherriff/aviation"
readme = "../../README.md"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
lib = { path = "../lib" }
actix-web = "4.11.0"
actix-cors = "0.7.1"
actix-multipart = "0.7.2"
chrono = { version = "0.4.42", features = ["serde"] }
dotenv = "0.15.0"
env_logger = "0.11.8"
serde = {version = "1.0.219", features = ["derive"]}
serde_json = "1.0.142"
tokio = { version = "1.47.1", features = ["macros", "rt", "time"] }
log = "0.4.28"
argon2 = "0.5.3"
futures-util = "0.3.31"
utoipa = { version = "5.4.0", features = ["chrono", "uuid", "actix_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] }
utoipa-actix-web = "0.1.2"
lettre = { version = "0.11.18", features = ["builder", "smtp-transport", "tokio1-native-tls"] }
handlebars = "6.3.2"

View File

@@ -4,9 +4,9 @@
FROM rust:bookworm AS builder
WORKDIR /builder
COPY api/migrations ./migrations
COPY api/src ./src
COPY api/Cargo.toml ./
COPY crates/lib /lib
COPY crates/api/src ./src
COPY crates/api/Cargo.toml ./
RUN apt-get update && apt-get install -y cmake
RUN cargo build --release

View File

@@ -1,10 +1,12 @@
use std::future::Future;
use std::pin::Pin;
use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload, http};
use serde::{Serialize, Deserialize};
use crate::{error::Error, users::User};
use super::{Session, SESSION_COOKIE_NAME};
use std::sync::Arc;
use super::{SESSION_COOKIE_NAME, Session};
use crate::error::{ApiResult, Error};
use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http, web};
use serde::{Deserialize, Serialize};
use lib::accounts::User;
use lib::state::AppState;
#[derive(Debug, Serialize, Deserialize)]
pub struct Auth {
@@ -18,29 +20,37 @@ impl FromRequest for Auth {
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
// Check for API key
let state = match req.app_data::<web::Data<AppState>>() {
Some(state) => state,
None => return Box::pin(
async { Err(Error::new(500, "Internal server error".to_string()).into()) },
)
};
// Check for an API key
match req
.headers()
.get(http::header::AUTHORIZATION)
.map(|h| h.to_str().unwrap().split_at(7).1.to_string())
{
Some(key_id) => {
let state = Arc::clone(&state);
let fut = async move {
// Check if the Session API key exists
let api_key = match Session::get(&key_id).await {
let api_key = match Session::get(&state, &key_id).await {
Ok(session) => session,
Err(err) => {
log::error!("Invalid session auth attempt: {}", err);
return Err(Error::new(401, "API Key does not exist".to_string()).into());
}
};
match User::select(&api_key.email).await {
match User::select(&state.pool, &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);
@@ -77,15 +87,16 @@ impl FromRequest for Auth {
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Verify the session
let state = Arc::clone(&state); // state: Arc<State>
let fut = async move {
match Session::verify(&session_id, &ip_address).await {
Ok(session) => match User::select(&session.email).await {
match Session::verify(&state, &session_id, &ip_address).await {
Ok(session) => match User::select(&state.pool, &session.username).await {
Some(user) => Ok(Auth {
session_id: Some(session_id),
api_key: None,
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()),
}
@@ -93,3 +104,16 @@ impl FromRequest for Auth {
Box::pin(fut)
}
}
impl Auth {
pub fn verify_role(&self, role: &str) -> ApiResult<()> {
if self.user.role == role {
Ok(())
} else {
Err(Error {
status: 403,
details: "User does not have permission to perform this action.".to_string(),
})
}
}
}

View File

@@ -0,0 +1,151 @@
use lib::accounts::{csprng, hash};
use crate::error::{ApiResult, Error};
use crate::smtp;
use chrono::{Datelike, Utc};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::{env, fs};
use lib::state::AppState;
#[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, state: &AppState, ttl_secs: i64) -> ApiResult<()> {
let key = self.token.clone();
let value = serde_json::to_string(self)?;
let now = Utc::now();
let expires_at = now + chrono::Duration::seconds(ttl_secs);
let ttl = expires_at.timestamp() - now.timestamp();
let _ = state.set_ex(&key, &value, ttl as u64).await?;
Ok(())
}
pub async fn get(state: &AppState, token: &str) -> ApiResult<Self> {
let result: Option<String> = state.get(token).await?;
match result {
Some(value) => Ok(serde_json::from_str(&value)?),
None => Err(Error::new(404, format!("Missing email token {}", token))),
}
}
pub async fn delete(state: &AppState, token: &str) -> ApiResult<()> {
let _ = state.del(token).await;
Ok(())
}
}
#[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(state: &AppState, email: &str, ip_address: &str) -> ApiResult<()> {
let token = csprng(128);
let email_token = EmailToken::new(email.to_string(), token, &ip_address);
email_token.store(state, 86400).await?;
let base_url = env::var("EXTERNAL_URL")?;
let link = format!("{base_url}/profile/confirm?token={}", email_token.token);
let subject = "Confirm your email address";
let plain = format!(
"Hello,\n\n\
Thanks for registering! Click the link below to confirm your email address:\n\n\
{link}\n\n\
If you didnt sign up for an Aviation Data account, please ignore this.\n\n\
Cheers,\n\
The Aviation Data Team",
link = link
);
let ctx = SimpleEmailCtx {
logo_url: format!("{}/logo.svg", base_url),
link: link.clone(),
domain: base_url,
year: Utc::now().year(),
};
let template_dir = env::var("TEMPLATE_DIR")?;
let tpl_path = Path::new(&template_dir).join("confirm_email.html");
let template_html = fs::read_to_string(&tpl_path)?;
let html = smtp::registry()
.render_template(&template_html, &ctx)
.unwrap();
if let Err(err) = smtp::send_email(&email, subject, plain, html).await {
log::error!(
"Invalid email confirmation attempt [Email: {}] [IP Address: {}]: {}",
email,
ip_address,
err
);
let _ = EmailToken::delete(state, &email_token.token);
return Err(err);
}
Ok(())
}

View File

@@ -0,0 +1,7 @@
mod auth;
mod email_token;
mod session;
pub use auth::*;
pub use email_token::*;
pub use session::*;

View File

@@ -0,0 +1,181 @@
use lib::accounts::{csprng, hash, verify_hash};
use crate::error::{ApiResult, Error};
use actix_web::cookie::{Cookie, time::Duration};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use lib::error::CoreResult;
use lib::state::AppState;
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 username: String,
pub ip_address: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
impl Session {
pub fn default(username: &str, ip_address: &str) -> Self {
Self::new(64, username, ip_address, Some(DEFAULT_SESSION_TTL))
}
pub fn new(take: usize, username: &str, ip_address: &str, ttl: Option<i64>) -> Self {
let now = Utc::now();
Self {
session_id: csprng(take),
username: username.to_string(),
ip_address: hash(&ip_address).unwrap(),
expires_at: match ttl {
Some(ttl) => Some(now + chrono::Duration::seconds(ttl)),
None => None,
},
}
}
pub async fn store(&self, state: &AppState) -> ApiResult<()> {
let key = self.session_id.clone();
let value = serde_json::to_string(self)?;
let result: CoreResult<()> = match self.expires_at {
Some(expires_at) => {
let ttl = expires_at.timestamp() - Utc::now().timestamp();
state.set_ex(&key, &value, ttl as u64).await
}
None => state.set(&key, &value).await,
};
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn get(state: &AppState, session_id: &str) -> ApiResult<Self> {
let result: Option<String> = state.get(session_id).await?;
match result {
Some(value) => Ok(serde_json::from_str(&value)?),
None => Err(Error::new(401, format!("Missing session {}", session_id))),
}
}
pub async fn replace(state: &AppState, session_id: &str, ip_address: &str) -> ApiResult<Self> {
let mut session = Self::verify(state, session_id, ip_address).await?;
let session_id_owned = session_id.to_owned();
Self::delete(state, &session_id_owned).await?;
session = Session::default(&session.username, ip_address);
session.store(state).await?;
Ok(session)
}
pub async fn delete(state: &AppState, session_id: &str) -> ApiResult<()> {
let result: CoreResult<()> = state.del(session_id).await;
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn verify(state: &AppState, session_id: &str, ip_address: &str) -> ApiResult<Self> {
let session = Self::get(state, session_id).await?;
// Check if the IP Address matches the Session's IP Address
if verify_hash(ip_address, &session.ip_address) {
Ok(session)
} else {
Err(Error::new(401, "IP Address does not match".to_string()))
}
}
pub fn 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_COOKIE_NAME, self.session_id.clone())
.path("/")
.max_age(Duration::seconds(ttl))
.secure(true)
.http_only(true)
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
log::trace!(
"Session cookie [User: {}]: {}",
self.username,
self.session_id
);
cookie.set_secure(false);
cookie.set_http_only(false);
}
}
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("/")
.max_age(Duration::seconds(-1))
.secure(true)
.http_only(true)
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
cookie.set_secure(false);
cookie.set_http_only(false);
}
}
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
}
}

133
crates/api/src/error.rs Normal file
View File

@@ -0,0 +1,133 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use log::warn;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt;
use lib::error::{CoreError, CoreErrorKind};
pub type ApiResult<T> = Result<T, Error>;
#[derive(Debug, Deserialize, Serialize)]
pub struct Error {
pub status: u16,
pub details: String,
}
impl Error {
pub fn new(status: u16, details: String) -> Self {
Self {
status,
details,
}
}
pub fn to_http_response(&self) -> HttpResponse {
let status = StatusCode::from_u16(self.status).unwrap_or_else(|err| {
warn!("{}", err);
StatusCode::INTERNAL_SERVER_ERROR
});
HttpResponse::build(status).body(self.details.to_string())
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.details.as_str())
}
}
impl std::error::Error for Error {
fn description(&self) -> &str {
&self.details
}
}
impl ResponseError for Error {
fn error_response(&self) -> HttpResponse {
let status =
StatusCode::from_u16(self.status).unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR);
let status_code = status.as_u16();
let details = match status_code {
401 => String::from("Unauthorized"),
code if code < 500 => self.details.clone(),
_ => {
log::error!("Internal server error: {}", self.details);
String::from("Internal Server Error")
}
};
HttpResponse::build(status).json(json!({ "status": status_code, "details": details }))
}
}
impl From<std::io::Error> for Error {
fn from(error: std::io::Error) -> Self {
Self::new(500, format!("Unknown IO error: {:?}", error))
}
}
impl From<std::env::VarError> for Error {
fn from(error: std::env::VarError) -> Self {
Self::new(
500,
format!("Unknown environment variable error: {:?}", error),
)
}
}
impl From<serde_json::Error> for Error {
fn from(error: serde_json::Error) -> Self {
Self::new(500, format!("Unknown serde_json error: {:?}", error))
}
}
impl From<argon2::password_hash::Error> for Error {
fn from(error: argon2::password_hash::Error) -> Self {
Self::new(500, format!("Unknown argon2 error: {:?}", error))
}
}
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)
}
}
impl From<CoreError> for Error {
fn from(error: CoreError) -> Self {
match error.kind {
CoreErrorKind::NotFound => Self::new(404, error.to_string()),
CoreErrorKind::InvalidInput => Self::new(400, error.to_string()),
CoreErrorKind::Conflict => Self::new(409, error.to_string()),
CoreErrorKind::Unauthorized => Self::new(401, error.to_string()),
CoreErrorKind::Forbidden => Self::new(403, error.to_string()),
CoreErrorKind::PreconditionFailed => Self::new(412, error.to_string()),
CoreErrorKind::Timeout => Self::new(408, error.to_string()),
CoreErrorKind::Cancelled => Self::new(499, error.to_string()),
CoreErrorKind::Unavailable => Self::new(503, error.to_string()),
CoreErrorKind::Internal => Self::new(500, error.to_string()),
CoreErrorKind::External => Self::new(502, error.to_string()),
_ => Self::new(500, error.to_string()),
}
}
}

152
crates/api/src/main.rs Normal file
View File

@@ -0,0 +1,152 @@
use lib::accounts::{User, ADMIN_ROLE, hash};
use actix_cors::Cors;
use actix_web::{App, HttpServer, middleware::Logger, web};
use dotenv::from_filename;
use std::env;
use utoipa::openapi::SecurityRequirement;
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
use utoipa_actix_web::{AppExt, scope};
use utoipa_swagger_ui::{Config, SwaggerUi};
use lib::state::AppState;
mod accounts;
mod error;
mod routes;
mod smtp;
mod system;
mod utils;
#[actix_web::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
initialize_environment()?;
let state = AppState::new().await?;
// 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_username.is_ok() && admin_email.is_ok() && admin_password.is_ok() {
let username = admin_username.unwrap();
let email = admin_email.unwrap();
if User::select_by_email(&state.pool, &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" {
log::warn!(
"Default admin credentials are in use, update the ADMIN_USERNAME, ADMIN_EMAIL, and ADMIN_PASSWORD."
);
}
let admin_user = User {
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(),
};
match admin_user.insert(&state.pool).await {
Ok(_) => log::debug!("Default administrator was successfully created"),
Err(err) => {
log::warn!("{}", err);
}
};
}
}
let host = "0.0.0.0";
let port = env::var("API_PORT").unwrap_or("5000".to_string());
let server = match HttpServer::new(move || {
let cors = Cors::default()
.allow_any_origin()
.allow_any_method()
.allow_any_header()
.supports_credentials()
.max_age(3600);
let (app, mut api) = App::new()
.wrap(cors)
.wrap(Logger::default())
.app_data(web::Data::new(state.clone()))
.into_utoipa_app()
.service(
scope::scope("/api")
.configure(routes::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))
{
Ok(b) => {
log::info!("Server bound to {}:{}", host, port);
b
}
Err(err) => {
log::error!("Could not bind server: {}", err);
return Err(err.into());
}
};
if let Err(err) = server.run().await {
return Err(err.into());
}
Ok(())
}
fn initialize_environment() -> std::io::Result<()> {
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);
}
}
}
}
Ok(())
}
init_dir("..")?;
init_dir(".")?;
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,api=info"));
Ok(())
}

View File

@@ -0,0 +1,622 @@
use lib::accounts::{csprng, LoginRequest, RegisterRequest, UpdateUser, User, UserResponse, UserFavorite, verify_hash};
use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web};
use serde::Deserialize;
use utoipa::ToSchema;
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
use lib::error::CoreErrorKind;
use lib::state::AppState;
use crate::accounts::{send_confirm_email, send_password_reset_email, Auth, EmailToken, Session, SESSION_COOKIE_NAME};
use crate::error::Error;
#[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(state: web::Data<AppState>, user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse {
let register_user = user.into_inner();
let username = register_user.username.clone();
let email = register_user.email.clone();
let ip_address = req.peer_addr().unwrap().ip().to_string();
let insert_user: User = match register_user.to_user().map_err(Error::from) {
Ok(user) => user,
Err(err) => return ResponseError::error_response(&err),
};
match insert_user.insert(&state.pool).await.map_err(Error::from) {
Ok(user) => {
let user_response: UserResponse = user.into();
log::info!(
"Successful user registration [User: {}] [IP Address: {}]",
username,
ip_address
);
// Send confirmation email
if let Some(email) = email {
if !email.is_empty() {
tokio::task::spawn_local(async move {
if let Err(err) = send_confirm_email(&state, &email, &ip_address).await {
log::error!("Failed to send confirmation email: {}", err);
};
});
}
}
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 [User: {}] [IP Address: {}]",
username,
ip_address
);
HttpResponse::Conflict().finish()
} else {
log::error!("Failed to register user [User: {}]: {}", username, err);
ResponseError::error_response(&err)
}
}
}
}
#[derive(Debug, Deserialize, ToSchema)]
struct ConfirmEmail {
token: String,
}
#[utoipa::path(
tag = "account",
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(
state: web::Data<AppState>,
request: web::Json<ConfirmEmail>,
req: HttpRequest,
) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
let token = &request.token;
let email_token = match EmailToken::get(&state, token).await {
Ok(password_reset) => {
if let Err(err) = EmailToken::delete(&state, &password_reset.token).await {
return ResponseError::error_response(&err);
};
password_reset
}
Err(_) => {
return HttpResponse::NotFound().finish();
}
};
match User::select_by_email(&state.pool, &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(&state.pool, &user.username).await.map_err(Error::from) {
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(state: web::Data<AppState>, req: HttpRequest, auth: Auth) -> HttpResponse {
let email = auth.user.email;
let ip_address = req.peer_addr().unwrap().ip().to_string();
match email {
Some(email) => {
let user = match User::select_by_email(&state.pool, &email).await {
Some(query_user) => query_user,
None => return HttpResponse::Unauthorized().finish(),
};
// Cannot reverify if the user is already verified
if user.email_verified {
return HttpResponse::Conflict().finish();
}
// Send reverify confirmation email
if let Err(err) = send_confirm_email(&state, &email, &ip_address).await {
log::error!("Failed to send reverify confirmation email: {}", err);
return HttpResponse::InternalServerError().finish();
};
HttpResponse::Ok().finish()
}
None => HttpResponse::NotFound().finish(),
}
}
#[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(state: web::Data<AppState>, request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
let username = &request.username;
let ip_address = req.peer_addr().unwrap().ip().to_string();
let query_user = match User::select(&state.pool, &username).await {
Some(query_user) => query_user,
None => return HttpResponse::Unauthorized().finish(),
};
if verify_hash(&request.password, &query_user.password_hash) {
// Create a session
let session = Session::default(&query_user.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(&state).await {
log::error!(
"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 [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 [User: {}] [IP Address: {}]",
username,
ip_address
);
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(state: web::Data<AppState>, req: HttpRequest, auth: Auth) -> HttpResponse {
let username = auth.user.username;
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Delete the session from the store
match req.cookie(SESSION_COOKIE_NAME) {
Some(cookie) => {
let session_id = cookie.value().to_string();
if let Err(err) = Session::delete(&state, &session_id).await {
log::error!(
"Logout attempt failure [User: {}] [IP Address: {}]: {}",
username,
ip_address,
err
);
return ResponseError::error_response(&Error::new(500, err.to_string()));
}
}
None => {
log::error!(
"Invalid logout attempt [User: {}] [IP Address: {}]",
username,
ip_address
);
return ResponseError::error_response(&Error::new(400, "Invalid session".to_string()));
}
}
log::info!(
"Successful logout attempt [User: {}] [IP Address: {}]",
username,
ip_address
);
HttpResponse::Ok()
.cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish()
}
#[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(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Verify a session cookie exists
match req.cookie(SESSION_COOKIE_NAME) {
// Validate the session
Some(cookie) => {
let session_id = cookie.value().to_string();
let session = match Session::get(&state, &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(&state.pool, &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(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Verify a session cookie exists
match req.cookie(SESSION_COOKIE_NAME) {
// Validate the session
Some(cookie) => {
let session_id = cookie.value().to_string();
let session = match Session::replace(&state, &session_id, &ip_address).await {
Ok(session) => session,
Err(_) => {
log::error!(
"Invalid session validate 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 session_cookie = session.cookie();
let session_exp_cookie = session.expiration_cookie();
log::info!(
"Successful session validate attempt [User: {}] [IP Address: {}]",
username,
ip_address
);
HttpResponse::Ok()
.cookie(session_cookie)
.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(
state: web::Data<AppState>,
request: web::Json<ChangePassword>,
req: HttpRequest,
auth: Auth,
) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
let username = auth.user.username;
if let None = User::select(&state.pool, &username).await {
return HttpResponse::Unauthorized().finish();
};
let update_user = UpdateUser {
email: None,
email_verified: None,
password: Some(request.password.clone()),
role: None,
first_name: None,
last_name: None,
avatar: None,
};
match update_user.update(&state.pool, &username).await.map_err(Error::from) {
Ok(user) => {
let response: UserResponse = user.into();
log::info!(
"Successful password change attempt [User: {}] [IP Address: {}]",
&username,
ip_address
);
HttpResponse::Ok().json(response)
}
Err(err) => {
log::error!(
"Invalid password change attempt [User: {}] [IP Address: {}]: {}",
&username,
ip_address,
err
);
ResponseError::error_response(&err)
}
}
}
#[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(state: web::Data<AppState>, request: web::Json<PasswordReset>, req: HttpRequest) -> HttpResponse {
let email = &request.email;
let ip_address = req.peer_addr().unwrap().ip().to_string();
let token = csprng(128);
// Silently return if the user's email does not exist
if let None = User::select_by_email(&state.pool, &email).await {
return HttpResponse::Ok().finish();
};
let email_token = EmailToken::new(email.clone(), token, &ip_address);
if let Err(err) = email_token.store(&state, 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()
}
#[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(
state: web::Data<AppState>,
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(&state, token).await {
Ok(password_reset) => {
if let Err(err) = EmailToken::delete(&state, &password_reset.token).await {
return ResponseError::error_response(&err);
};
password_reset
}
Err(err) => {
return HttpResponse::NotFound().json(err);
}
};
HttpResponse::Ok().finish()
}
#[utoipa::path(
tag = "account",
responses(
(status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[get("/profile/favorites")]
async fn get_favorites(state: web::Data<AppState>, auth: Auth) -> HttpResponse {
let username = auth.user.username;
match UserFavorite::select_all(&state.pool, &username).await.map_err(Error::from) {
Ok(favorites) => HttpResponse::Ok().json(favorites),
Err(err) => ResponseError::error_response(&err),
}
}
#[utoipa::path(
tag = "account",
responses(
(status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[post("/profile/favorites/{icao}")]
async fn add_favorite(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse {
let username = auth.user.username;
match UserFavorite::insert(&state.pool, &username, &icao.into_inner()).await.map_err(Error::from) {
Ok(_) => HttpResponse::Ok().finish(),
Err(err) => ResponseError::error_response(&err),
}
}
#[utoipa::path(
tag = "account",
responses(
(status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[delete("/profile/favorites/{icao}")]
async fn remove_favorite(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse {
let username = auth.user.username;
match UserFavorite::delete(&state.pool, &username, &icao.into_inner()).await.map_err(Error::from) {
Ok(_) => HttpResponse::Ok().finish(),
Err(err) => ResponseError::error_response(&err),
}
}
pub fn init_routes(config: &mut ServiceConfig) {
config.service(
scope::scope("/account")
.service(register)
.service(confirm_email_registration)
.service(resend_email_verification)
.service(login)
.service(logout)
.service(get_profile)
.service(session_refresh)
.service(change_password)
.service(reset_password)
.service(confirm_password_reset)
.service(get_favorites)
.service(add_favorite)
.service(remove_favorite),
);
}

View File

@@ -0,0 +1,258 @@
use futures_util::stream::StreamExt as _;
use lib::{accounts::ADMIN_ROLE, airports::{Airport, AirportQuery, UpdateAirport}};
use actix_multipart::Multipart;
use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web};
use utoipa::ToSchema;
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
use lib::state::AppState;
use crate::accounts::Auth;
use crate::error::Error;
use crate::utils::Paged;
#[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(state: web::Data<AppState>, mut payload: Multipart, auth: Auth) -> HttpResponse {
if let Err(err) = &auth.verify_role(ADMIN_ROLE).map_err(Error::from) {
return ResponseError::error_response(err);
};
while let Some(item) = payload.next().await {
let mut bytes = web::BytesMut::new();
let mut field = match item {
Ok(field) => field,
Err(err) => return ResponseError::error_response(&err),
};
// Build bytes from chunks
while let Some(chunk) = field.next().await {
let data = match chunk {
Ok(data) => data,
Err(err) => {
log::error!("Failed to get chunk: {}", err);
return ResponseError::error_response(&err);
}
};
bytes.extend_from_slice(&data);
}
// Convert bytes to Vec<Airport>
let airports: Vec<Airport> = match serde_json::from_slice(&bytes) {
Ok(a) => a,
Err(err) => {
log::error!("Failed to parse JSON: {}", err);
return ResponseError::error_response(&err);
}
};
match Airport::insert_all(&state.pool, airports).await.map_err(Error::from) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
};
}
HttpResponse::Ok().finish()
}
#[utoipa::path(
tag = "airport",
params(
AirportQuery
),
responses(
(status = 200, description = "", body = [Airport]),
),
)]
#[get("")]
async fn get_airports(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let mut query = match web::Query::<AirportQuery>::from_query(req.query_string()) {
Ok(q) => q.into_inner(),
Err(err) => {
log::error!("{}", err);
AirportQuery::default()
}
};
let total = Airport::count(&state.pool, &query).await;
let page = query.page.unwrap_or(1);
let mut limit = query.limit.unwrap_or(total as u32);
if limit > 1000 {
limit = 1000
}
query.limit = Some(limit);
query.page = Some(page);
match Airport::select_all(&state.pool, &query).await.map_err(Error::from) {
Ok(airports) => HttpResponse::Ok().json(Paged {
data: airports,
page,
limit,
total,
}),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[utoipa::path(
tag = "airport",
responses(
(status = 200, description = "", body = Airport),
(status = 404, description = ""),
),
)]
#[get("/{icao}")]
async fn get_airport(state: web::Data<AppState>, icao: web::Path<String>, req: HttpRequest) -> HttpResponse {
let metar = match web::Query::<AirportQuery>::from_query(req.query_string()) {
Ok(q) => q.metars.unwrap_or_else(|| false),
Err(err) => {
log::error!("{}", err);
false
}
};
match Airport::select(&state.pool, &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(state: web::Data<AppState>, airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
let _ = match &auth.verify_role(ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(err),
};
match airport.insert(&state.pool).await.map_err(Error::from) {
Ok(a) => HttpResponse::Ok().json(a),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[utoipa::path(
tag = "airport",
responses(
(status = 200, description = "", body = Airport),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[put("/{icao}")]
async fn update_airport(
state: web::Data<AppState>,
icao: web::Path<String>,
airport: web::Json<UpdateAirport>,
auth: Auth,
) -> HttpResponse {
let _ = match &auth.verify_role(ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(err),
};
match Airport::update(&state.pool, &icao.into_inner(), &airport.into_inner()).await.map_err(Error::from) {
Ok(a) => HttpResponse::Ok().json(a),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[utoipa::path(
tag = "airport",
responses(
(status = 201, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[delete("")]
async fn delete_airports(state: web::Data<AppState>, auth: Auth) -> HttpResponse {
let _ = match &auth.verify_role(ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(err),
};
match Airport::delete_all(&state.pool).await.map_err(Error::from) {
Ok(_) => HttpResponse::NoContent().finish(),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[utoipa::path(
tag = "airport",
responses(
(status = 201, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[delete("/{icao}")]
async fn delete_airport(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse {
let _ = match &auth.verify_role(ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(err),
};
match Airport::delete(&state.pool, &icao.into_inner()).await.map_err(Error::from) {
Ok(_) => HttpResponse::NoContent().finish(),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
pub fn init_routes(config: &mut ServiceConfig) {
config.service(
scope::scope("/airports")
.service(import_airports)
.service(get_airports)
.service(get_airport)
.service(insert_airport)
.service(update_airport)
.service(delete_airports)
.service(delete_airport),
);
}

View File

@@ -0,0 +1,94 @@
use crate::AppState;
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;
use lib::metars::Metar;
use crate::accounts::Auth;
use crate::error::Error;
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[into_params(parameter_in = Query)]
struct MetarQuery {
icaos: Option<String>,
}
#[utoipa::path(
tag = "metar",
params(
MetarQuery,
),
responses(
(status = 200, description = "Successful Response", body = [Metar]),
),
)]
#[get("")]
async fn find_all(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
let icao_option = &parameters.icaos;
if let None = icao_option {
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_all_distinct(&state.pool, &icaos).await.map_err(Error::from) {
Ok(a) => a,
Err(err) => {
error!("{}", err);
return err.to_http_response();
}
};
HttpResponse::Ok().json(metars)
}
#[utoipa::path(
tag = "metar",
params(
MetarQuery,
),
responses(
(status = 200, description = "Successful Response", body = [Metar]),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[put("")]
async fn refresh_metars(state: web::Data<AppState>, req: HttpRequest, _auth: Auth) -> HttpResponse {
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
let icao_option = &parameters.icaos;
if let None = icao_option {
let empty_metars: Vec<Metar> = vec![];
return HttpResponse::Ok().json(empty_metars);
}
let icao_string = match icao_option {
Some(i) => i,
None => return HttpResponse::UnprocessableEntity().body("Missing icaos parameter"),
};
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect();
let metars = match Metar::get_or_update_metars(&state, &icaos).await.map_err(Error::from) {
Ok(a) => a,
Err(err) => {
error!("{}", err);
return err.to_http_response();
}
};
HttpResponse::Ok().json(metars)
}
pub fn init_routes(config: &mut ServiceConfig) {
config.service(
scope::scope("/metars")
.service(find_all)
.service(refresh_metars),
);
}

View File

@@ -0,0 +1,12 @@
use utoipa_actix_web::service_config::ServiceConfig;
mod accounts;
mod airports;
mod metars;
pub fn init_routes(config: &mut ServiceConfig) {
config
.configure(accounts::init_routes)
.configure(airports::init_routes)
.configure(metars::init_routes);
}

View File

@@ -0,0 +1,76 @@
use crate::error::ApiResult;
use handlebars::Handlebars;
use lettre::message::header::ContentType;
use lettre::message::{Mailbox, MultiPart, SinglePart};
use lettre::transport::smtp::AsyncSmtpTransportBuilder;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use std::env;
use std::sync::OnceLock;
use std::time::Duration;
static MAILER: OnceLock<AsyncSmtpTransport<Tokio1Executor>> = OnceLock::new();
static FROM_ADDRESS: OnceLock<Mailbox> = OnceLock::new();
static REGISTRY: OnceLock<Handlebars> = OnceLock::new();
fn mailer() -> &'static AsyncSmtpTransport<Tokio1Executor> {
MAILER.get_or_init(|| {
let server = env::var("SMTP_SERVER").expect("SMTP_SERVER missing");
let username = env::var("SMTP_USERNAME").expect("SMTP_USERNAME missing");
let password = env::var("SMTP_PASSWORD").expect("SMTP_PASSWORD missing");
let port = env::var("SMTP_PORT").expect("SMTP_PORT missing");
let creds = Credentials::new(username, password);
let builder: AsyncSmtpTransportBuilder;
if server == "localhost" || server == "127.0.0.1" {
builder = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&server);
log::warn!("Using a local SMTP server: {}", server);
} else {
builder = AsyncSmtpTransport::<Tokio1Executor>::relay(&server).expect("invalid SMTP_SERVER");
}
builder
.credentials(creds)
.port(port.parse().expect("SMTP_PORT invalid"))
.timeout(Some(Duration::from_secs(10)))
.build()
})
}
fn from_address() -> &'static Mailbox {
FROM_ADDRESS.get_or_init(|| {
let raw = env::var("SMTP_FROM").expect("SMTP_FROM missing");
let addr = raw.parse().expect("SMTP_FROM invalid");
Mailbox::new(Some("Aviation Data".into()), addr)
})
}
pub fn registry() -> &'static Handlebars<'static> {
REGISTRY.get_or_init(|| Handlebars::new())
}
pub async fn send_email(to: &str, subject: &str, header: String, html: String) -> ApiResult<()> {
let to_address = to.parse::<Address>()?;
let to_mailbox = Mailbox::new(None, to_address);
// Build the email
let email = Message::builder()
.from(from_address().clone())
.to(to_mailbox)
.subject(subject)
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(header),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html),
),
)?;
// Send the email
mailer().send(email).await?;
Ok(())
}

View File

@@ -0,0 +1,34 @@
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, 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 healthy = true;
let version = env!("CARGO_PKG_VERSION");
let info = SystemInfo {
version: version.to_string(),
healthy,
};
HttpResponse::Ok().json(info)
}
pub fn init_routes(config: &mut ServiceConfig) {
config.service(scope::scope("/system").service(info));
}

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

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

24
crates/lib/Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "lib"
version = "0.1.0"
edition = "2024"
[dependencies]
argon2 = "0.5.3"
chrono = { version = "0.4.42", features = ["serde"] }
log = "0.4.28"
rand = "0.9.2"
rand_chacha = "0.9.0"
serde = { version = "1.0.226", features = ["derive"] }
serde_json = "1.0.142"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
utoipa = { version = "5.4.0", features = ["chrono", "uuid", "actix_extras"] }
uuid = { version = "1.18.1", features = ["serde", "v4"] }
futures-util = "0.3.31"
flate2 = "1.1.2"
reqwest = "0.12.23"
regex = "1.11.2"
redis = { version = "0.32.5", features = ["tokio-comp", "connection-manager", "r2d2", "json"] }
governor = "0.10.1"
tokio = { version = "1.47.1", features = ["macros", "rt", "time"] }
rust-s3 = "0.37.0"

View File

@@ -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,20 @@ 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()
);
);
CREATE TABLE IF NOT EXISTS user_airport_favorites (
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
icao TEXT NOT NULL REFERENCES airports(icao) ON DELETE CASCADE,
PRIMARY KEY (username, icao)
);

View File

@@ -0,0 +1,9 @@
mod password_requirements;
mod user;
mod user_favorites;
mod utils;
pub use password_requirements::*;
pub use user::*;
pub use user_favorites::*;
pub use utils::*;

View File

@@ -0,0 +1,24 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PasswordRequirements {
pub max_length: Option<usize>,
pub min_length: Option<usize>,
pub lowercase_count: Option<usize>,
pub uppercase_count: Option<usize>,
pub numeric_count: Option<usize>,
pub special_count: Option<usize>,
}
impl Default for PasswordRequirements {
fn default() -> Self {
Self {
max_length: Some(128),
min_length: Some(6),
lowercase_count: None,
uppercase_count: None,
numeric_count: None,
special_count: None,
}
}
}

View File

@@ -1,74 +1,113 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use crate::{account::hash, error::ApiResult};
use crate::db;
#[allow(unused_imports)] // Import is used in schema examples
use serde_json::json;
use sqlx::{Pool, Postgres, QueryBuilder};
use utoipa::ToSchema;
use crate::accounts::hash;
use crate::error::CoreResult;
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,
}
impl RegisterRequest {
pub fn to_user(self) -> ApiResult<User> {
pub fn to_user(self) -> CoreResult<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> {
let pool = db::pool();
pub async fn update(&self, pool: &Pool<Postgres>, username: &str) -> CoreResult<User> {
let mut query_builder: QueryBuilder<Postgres> =
QueryBuilder::new(&format!("UPDATE {} SET ", TABLE_NAME));
@@ -87,6 +126,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 +152,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,40 +172,59 @@ 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> {
let pool = db::pool();
pub async fn select(pool: &Pool<Postgres>, username: &str) -> Option<Self> {
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
r#"
SELECT * FROM {} WHERE 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 count() -> i64 {
let pool = db::pool();
pub async fn select_by_email(pool: &Pool<Postgres>, email: &str) -> Option<Self> {
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
r#"
SELECT * FROM {} WHERE email = $1
"#,
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(pool: &Pool<Postgres>) -> i64 {
sqlx::query_scalar(&format!(
r#"
SELECT COUNT(*) FROM {}
@@ -170,29 +236,34 @@ impl User {
.unwrap_or_else(|_| 0)
}
pub async fn insert(&self) -> ApiResult<User> {
let pool = db::pool();
pub async fn insert(&self, pool: &Pool<Postgres>) -> CoreResult<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)

View File

@@ -0,0 +1,61 @@
use crate::error::CoreResult;
use serde::Deserialize;
use sqlx::{Pool, Postgres};
const TABLE_NAME: &str = "user_airport_favorites";
#[derive(Debug, Deserialize, sqlx::FromRow)]
pub struct UserFavorite {
pub username: String,
pub icao: String,
}
impl UserFavorite {
pub async fn select_all(pool: &Pool<Postgres>, username: &str) -> CoreResult<Vec<String>> {
let user_favorites: Vec<UserFavorite> = sqlx::query_as::<_, UserFavorite>(&format!(
r#"
SELECT * FROM {} WHERE username = $1
"#,
TABLE_NAME
))
.bind(username)
.fetch_all(pool)
.await?;
let favorites = user_favorites.iter().map(|uf| uf.icao.clone()).collect();
Ok(favorites)
}
pub async fn insert(pool: &Pool<Postgres>, username: &str, icao: &str) -> CoreResult<()> {
sqlx::query(&format!(
r#"
INSERT INTO {} (
username, icao
) VALUES ($1, $2)
"#,
TABLE_NAME
))
.bind(username)
.bind(icao)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete(pool: &Pool<Postgres>, username: &str, icao: &str) -> CoreResult<()> {
sqlx::query(&format!(
r#"
DELETE FROM {} WHERE username = $1 AND icao = $2
"#,
TABLE_NAME
))
.bind(username)
.bind(icao)
.execute(pool)
.await?;
Ok(())
}
}

View File

@@ -1,20 +1,11 @@
use argon2::{
password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use rand::distr::Alphanumeric;
use rand::prelude::*;
use rand::Rng;
use rand_chacha::ChaCha20Rng;
mod auth;
mod routes;
mod session;
pub use auth::*;
pub use session::*;
pub use routes::init_routes;
use crate::error::{Error, ApiResult};
use rand_chacha::rand_core::SeedableRng;
use crate::error::CoreResult;
pub fn csprng(take: usize) -> String {
// Generate a CSPRNG 128-bit (16 byte) ID using alphanumeric characters (a-z, A-Z, 0-9)
@@ -26,7 +17,7 @@ pub fn csprng(take: usize) -> String {
.collect()
}
pub fn hash(string: &str) -> ApiResult<String> {
pub fn hash(string: &str) -> CoreResult<String> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(string.as_bytes(), &salt)?
@@ -52,17 +43,6 @@ pub fn verify_hash(string: &str, hashed_string: &str) -> bool {
.is_ok()
}
pub fn verify_role(auth: &Auth, role: &str) -> ApiResult<()> {
if auth.user.role == role {
Ok(())
} else {
Err(Error {
status: 403,
details: "User does not have permission to perform this action.".to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -73,4 +53,4 @@ mod tests {
assert!(!verify_hash(&password, "bad_password"));
assert!(verify_hash("password", &password));
}
}
}

View File

@@ -1,19 +1,23 @@
use crate::airports::{
AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication,
UpdateRunway,
};
use chrono::{DateTime, Utc};
use futures_util::try_join;
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Postgres, QueryBuilder};
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,
};
use crate::db;
use crate::error::{ApiResult, Error};
use utoipa::{IntoParams, ToSchema};
use crate::error::{CoreError, CoreErrorKind, CoreResult};
use crate::metars::Metar;
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 +37,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 +79,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,
@@ -83,11 +136,11 @@ pub struct Bounds {
}
impl Bounds {
fn parse(input: &str) -> ApiResult<Bounds> {
fn parse(input: &str) -> CoreResult<Bounds> {
let parts: Vec<&str> = input.split(',').collect();
if parts.len() != 4 {
return Err(Error::new(
400,
return Err(CoreError::new(
CoreErrorKind::InvalidInput,
format!("Expected 4 fields in bounds but received {}", parts.len()),
));
}
@@ -121,9 +174,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 +193,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 +215,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 +246,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 +254,21 @@ impl From<AirportRow> for Airport {
}
impl Airport {
pub async fn select(client: &Client, icao: &str, metar: bool) -> Option<Self> {
let pool = db::pool();
pub async fn select(pool: &Pool<Postgres>, icao: &str, metar: bool) -> Option<Self> {
let airport_fut = async {
sqlx::query_as(&format!("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.to_uppercase())
.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(pool, &vec![icao.to_uppercase()]).await {
Ok(m) => Some(m.into_iter().nth(0)),
Err(err) => {
log::error!("{}", err);
@@ -219,11 +280,11 @@ impl Airport {
}
};
let runways_fut = Runway::select_all(icao);
let frequencies_fut = Frequency::select_all(icao);
let runways_fut = Runway::select_all(pool, icao);
let communications_fut = Communication::select_all(pool, 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 +302,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,21 +325,27 @@ 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>> {
let pool = db::pool();
let mut builder = QueryBuilder::<Postgres>::new("SELECT * FROM ");
builder.push(TABLE_NAME);
pub async fn select_all(pool: &Pool<Postgres>, query: &AirportQuery) -> CoreResult<Vec<Self>> {
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);
Self::push_condition_array(&mut builder, &mut has_where, "iata", &query.iatas);
let icaos = match &query.icaos {
Some(icaos) => Some(icaos.to_uppercase()),
None => None,
};
Self::push_condition_array(&mut builder, &mut has_where, "icao", &icaos);
let iatas = match &query.iatas {
Some(iatas) => Some(iatas.to_uppercase()),
None => None,
};
Self::push_condition_array(&mut builder, &mut has_where, "iata", &iatas);
Self::push_condition_array(
&mut builder,
&mut has_where,
@@ -297,13 +364,17 @@ impl Airport {
"municipality",
&query.municipalities,
);
Self::push_condition_array(&mut builder, &mut has_where, "local", &query.locals);
let locals = match &query.locals {
Some(locals) => Some(locals.to_uppercase()),
None => None,
};
Self::push_condition_array(&mut builder, &mut has_where, "local", &locals);
Self::push_condition_array(&mut builder, &mut has_where, "category", &query.categories);
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 ");
@@ -332,13 +403,13 @@ impl Airport {
return Ok(airports);
}
// Bulk update airport sub-fields
let icaos: Vec<String> = airports.iter().map(|a| a.icao.clone()).collect();
// Bulk update airport subfields
let icaos: Vec<String> = airports.iter().map(|a| a.icao.to_uppercase()).collect();
let runway_future = Runway::select_all_map(icaos.clone());
let frequency_future = Frequency::select_all_map(icaos.clone());
let runway_future = Runway::select_all_map(pool, &icaos);
let frequency_future = Communication::select_all_map(pool, &icaos);
let metar_future = if query.metars.unwrap_or(false) {
Some(Metar::find_all(client, &icaos, &false))
Some(Metar::get_all_distinct(pool, &icaos))
} else {
None
};
@@ -366,7 +437,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();
@@ -378,9 +449,7 @@ impl Airport {
Ok(airports)
}
pub async fn count(query: &AirportQuery) -> i64 {
let pool = db::pool();
pub async fn count(pool: &Pool<Postgres>, query: &AirportQuery) -> i64 {
let mut builder = QueryBuilder::<Postgres>::new("SELECT COUNT(*) FROM ");
builder.push(TABLE_NAME);
@@ -417,33 +486,33 @@ impl Airport {
sql_query.fetch_one(pool).await.unwrap_or_else(|_| 0)
}
pub async fn insert(&self) -> ApiResult<Self> {
let pool = db::pool();
pub async fn insert(&self, pool: &Pool<Postgres>) -> CoreResult<Self> {
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?;
Runway::insert_all(pool, &all_runway_rows).await?;
Communication::insert_all(pool, &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)
@@ -465,32 +534,30 @@ impl Airport {
Ok(airport.into())
}
pub async fn insert_all(airports: Vec<Self>) -> ApiResult<()> {
let pool = db::pool();
pub async fn insert_all(pool: &Pool<Postgres>, airports: Vec<Self>) -> CoreResult<()> {
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, \
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(format!(
"INSERT INTO {} (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) ",
TABLE_NAME
));
query_builder.push_values(chunk, |mut b, row| {
b.push_bind(&row.icao)
.push_bind(&row.iata)
@@ -503,6 +570,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,17 +584,29 @@ impl Airport {
query.execute(pool).await?;
}
Runway::insert_all(pool, &all_runway_rows).await?;
Communication::insert_all(pool, &all_frequency_rows).await?;
Ok(())
}
// TODO
pub async fn update(_icao: &str, _airport: &UpdateAirport) -> ApiResult<()> {
pub async fn update(pool: &Pool<Postgres>, icao: &str, airport: &UpdateAirport) -> CoreResult<()> {
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(())
}
pub async fn delete(icao: &str) -> ApiResult<()> {
let pool = db::pool();
pub async fn delete(pool: &Pool<Postgres>, icao: &str) -> CoreResult<()> {
sqlx::query(&format!(
r#"
DELETE FROM {} WHERE icao = $1
@@ -536,9 +620,7 @@ impl Airport {
Ok(())
}
pub async fn delete_all() -> ApiResult<()> {
let pool = db::pool();
pub async fn delete_all(pool: &Pool<Postgres>) -> CoreResult<()> {
sqlx::query(&format!(
r#"
DELETE FROM {} WHERE true
@@ -557,7 +639,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 +668,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;
@@ -605,9 +687,9 @@ impl Airport {
builder: &mut QueryBuilder<'a, Postgres>,
has_where: &mut bool,
field: &'a Option<String>,
) -> ApiResult<()> {
) -> CoreResult<()> {
// 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 +699,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(())

View File

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

View File

@@ -0,0 +1,125 @@
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Postgres, QueryBuilder};
use std::collections::HashMap;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::error::CoreResult;
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(pool: &Pool<Postgres>, icaos: &Vec<String>) -> CoreResult<HashMap<String, Vec<Self>>> {
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(pool: &Pool<Postgres>, icao: &str) -> CoreResult<Vec<Self>> {
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(pool: &Pool<Postgres>, communications: &Vec<CommunicationRow>) -> CoreResult<()> {
let chunk_size = 1000;
for chunk in communications.chunks(chunk_size) {
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(&format!(
"INSERT INTO {} (id, icao, frequency_id, name, frequencies_mhz, phone) ",
TABLE_NAME
));
query_builder.push_values(chunk, |mut b, row| {
b.push_bind(&row.id)
.push_bind(&row.icao)
.push_bind(&row.frequency_id)
.push_bind(&row.name)
.push_bind(&row.frequencies_mhz)
.push_bind(&row.phone);
});
let query = query_builder.build();
query.execute(pool).await?;
}
Ok(())
}
}

View File

@@ -1,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::*;

View File

@@ -1,13 +1,13 @@
use std::collections::HashMap;
use crate::error::CoreResult;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use sqlx::{Pool, Postgres, QueryBuilder};
use std::collections::HashMap;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::db;
use crate::error::ApiResult;
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 +26,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,9 +63,7 @@ impl Runway {
}
}
pub async fn select_all_map(icaos: Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
let pool = db::pool();
pub async fn select_all_map(pool: &Pool<Postgres>, icaos: &Vec<String>) -> CoreResult<HashMap<String, Vec<Self>>> {
let runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!(
r#"SELECT * FROM {} WHERE icao = ANY($1)"#,
TABLE_NAME
@@ -84,9 +82,7 @@ impl Runway {
Ok(runway_map)
}
pub async fn select_all(icao: &str) -> ApiResult<Vec<Self>> {
let pool = db::pool();
pub async fn select_all(pool: &Pool<Postgres>, icao: &str) -> CoreResult<Vec<Self>> {
let runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!(
r#"
SELECT * FROM {} WHERE icao = $1
@@ -99,8 +95,7 @@ impl Runway {
Ok(runway_rows.into_iter().map(From::from).collect())
}
pub async fn insert_all(runways: &Vec<RunwayRow>) -> ApiResult<()> {
let pool = db::pool();
pub async fn insert_all(pool: &Pool<Postgres>, runways: &Vec<RunwayRow>) -> CoreResult<()> {
let chunk_size = 1000;
for chunk in runways.chunks(chunk_size) {

220
crates/lib/src/error.rs Normal file
View File

@@ -0,0 +1,220 @@
use std::fmt::{Display, Formatter};
use std::sync::{MutexGuard, PoisonError};
use regex::Regex;
use serde::de::StdError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CoreErrorKind {
NotFound,
InvalidInput,
Conflict,
Unauthorized,
Forbidden,
PreconditionFailed,
Timeout,
Cancelled,
Unavailable,
Internal,
External,
}
#[derive(Debug)]
pub struct CoreError {
pub kind: CoreErrorKind,
pub message: String,
pub context: Vec<(&'static str, String)>,
source: Option<Box<dyn StdError>>
}
impl CoreError {
pub fn new(kind: CoreErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
context: vec![],
source: None,
}
}
pub fn with_source(kind: CoreErrorKind, message: impl Into<String>, source: impl StdError + Send + Sync + 'static) -> Self {
Self {
kind,
message: message.into(),
context: vec![],
source: Some(Box::new(source)),
}
}
pub fn context(mut self, context: Vec<(&'static str, String)>) -> Self {
self.context = context;
self
}
}
impl Display for CoreError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?} - {}", self.kind, self.message)
}
}
impl StdError for CoreError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
self.source.as_deref()
}
}
pub type CoreResult<T> = Result<T, CoreError>;
pub fn not_found(entity: &'static str, id: impl Into<String>) -> CoreError {
CoreError::new(CoreErrorKind::NotFound, format!("{entity} not found: {}", id.into()))
}
impl From<argon2::password_hash::Error> for CoreError {
fn from(error: argon2::password_hash::Error) -> Self {
Self::new(CoreErrorKind::External, error.to_string())
}
}
impl From<std::io::Error> for CoreError {
fn from(error: std::io::Error) -> Self {
Self::new(CoreErrorKind::External, format!("Unknown IO error: {:?}", error))
}
}
impl From<redis::RedisError> for CoreError {
fn from(error: redis::RedisError) -> Self {
Self::new(CoreErrorKind::External, format!("Unknown redis error: {:?}", error))
}
}
impl From<sqlx::Error> for CoreError {
fn from(error: sqlx::Error) -> Self {
match error {
sqlx::Error::RowNotFound => CoreError::new(CoreErrorKind::NotFound, "Not found".to_string()),
sqlx::Error::ColumnIndexOutOfBounds { .. } => CoreError::new(CoreErrorKind::InvalidInput, error.to_string()),
sqlx::Error::ColumnNotFound { .. } => CoreError::new(CoreErrorKind::NotFound, error.to_string()),
sqlx::Error::ColumnDecode { .. } => CoreError::new(CoreErrorKind::External, error.to_string()),
sqlx::Error::Decode(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
sqlx::Error::PoolTimedOut => CoreError::new(CoreErrorKind::External, error.to_string()),
sqlx::Error::PoolClosed => CoreError::new(CoreErrorKind::External, error.to_string()),
sqlx::Error::Tls(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
sqlx::Error::Io(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
sqlx::Error::Protocol(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
sqlx::Error::Configuration(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
sqlx::Error::AnyDriverError(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
sqlx::Error::Database(err) => {
if let Some(code) = err.code() {
match code.trim() {
// Unique violation
"23505" => return CoreError::new(CoreErrorKind::External, err.to_string()),
_ => (),
}
}
CoreError::new(CoreErrorKind::External, err.to_string())
}
sqlx::Error::Migrate(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
sqlx::Error::TypeNotFound { type_name } => {
CoreError::new(CoreErrorKind::External, format!("Type not found: {}", type_name))
}
sqlx::Error::WorkerCrashed => CoreError::new(CoreErrorKind::External, error.to_string()),
_ => CoreError::new(CoreErrorKind::External, error.to_string()),
}
}
}
impl From<reqwest::Error> for CoreError {
fn from(error: reqwest::Error) -> Self {
match error.status() {
Some(status_code) => {
if status_code.is_client_error() {
Self::new(CoreErrorKind::External, format!("Client reqwest error: {:?}", error))
} else if status_code.is_server_error() {
Self::new(CoreErrorKind::External, format!("Server reqwest error: {:?}", error))
} else {
Self::new(CoreErrorKind::External, format!("Unknown reqwest error: {:?}", error))
}
}
_ => Self::new(CoreErrorKind::External, format!("Unknown reqwest error: {:?}", error)),
}
}
}
impl From<s3::error::S3Error> for CoreError {
fn from(error: s3::error::S3Error) -> Self {
match error {
s3::error::S3Error::Credentials(err) => {
Self::new(CoreErrorKind::External, format!("Unknown s3 credentials error: {:?}", err))
}
s3::error::S3Error::FromUtf8(err) => {
Self::new(CoreErrorKind::External, format!("Unknown s3 from utf8 error: {:?}", err))
}
s3::error::S3Error::FmtError(err) => {
Self::new(CoreErrorKind::External, format!("Unknown s3 fmt error: {:?}", err))
}
s3::error::S3Error::HmacInvalidLength(err) => Self::new(
CoreErrorKind::External,
format!("Unknown s3 hmac invalid length error: {:?}", err),
),
_ => {
let re = Regex::new(r"HTTP (\d{3})").unwrap();
// Apply the regex to the input string
if let Some(captures) = re.captures(&error.to_string()) {
if let Some(http_code_str) = captures.get(1) {
if let Ok(http_code) = http_code_str.as_str().parse::<u16>() {
return Self::new(CoreErrorKind::External, error.to_string()).context(vec![("http_code", http_code.to_string())]);
}
}
}
Self::new(CoreErrorKind::External, format!("Unknown s3 error: {:?}", error))
}
}
}
}
impl From<std::env::VarError> for CoreError {
fn from(error: std::env::VarError) -> Self {
Self::new(
CoreErrorKind::External,
format!("Unknown environment variable error: {:?}", error),
)
}
}
impl From<serde_json::Error> for CoreError {
fn from(error: serde_json::Error) -> Self {
Self::new(CoreErrorKind::External, format!("Unknown serde_json error: {:?}", error))
}
}
impl<'a, T> From<PoisonError<MutexGuard<'a, T>>> for CoreError {
fn from(_: PoisonError<MutexGuard<'a, T>>) -> Self {
Self::new(CoreErrorKind::External, "Failed to acquire lock".to_string())
}
}
impl From<core::num::ParseIntError> for CoreError {
fn from(error: core::num::ParseIntError) -> Self {
Self::new(CoreErrorKind::External, format!("Integer parse error: {:?}", error))
}
}
impl From<core::num::ParseFloatError> for CoreError {
fn from(error: core::num::ParseFloatError) -> Self {
Self::new(CoreErrorKind::External, format!("Float parse error: {:?}", error))
}
}
impl From<regex::Error> for CoreError {
fn from(error: regex::Error) -> Self {
Self::new(CoreErrorKind::External, error.to_string())
}
}
impl From<chrono::ParseError> for CoreError {
fn from(error: chrono::ParseError) -> Self {
Self::new(CoreErrorKind::External, format!("Chrono parse error: {:?}", error))
}
}

View File

@@ -0,0 +1,92 @@
use crate::error::{CoreResult, CoreError, CoreErrorKind};
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) -> CoreResult<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() -> CoreResult<Self> {
Self::new(60)
}
pub async fn get(&self, url: &str, etag: Option<String>) -> CoreResult<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(CoreError::new(
CoreErrorKind::External,
format!("Request returned status {}", response.status()),
));
}
Ok(response)
}
}

6
crates/lib/src/lib.rs Normal file
View File

@@ -0,0 +1,6 @@
pub mod accounts;
pub mod airports;
pub mod metars;
pub mod http_client;
pub mod state;
pub mod error;

View File

@@ -0,0 +1,57 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::CoreResult;
use crate::metars::model::Metar;
use crate::state::AppState;
#[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(state: &AppState, icao: String, status: bool) -> Self {
match Self::get(state, &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(state: &AppState, icao: &str) -> Option<MetarCheck> {
let result: CoreResult<Option<String>> = state.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, state: &AppState) -> CoreResult<()> {
let value = serde_json::to_string(&self)?;
state.set(self.icao.as_str(), &value).await?;
Ok(())
}
}

View File

@@ -0,0 +1,7 @@
mod metar_check;
mod model;
mod utils;
pub use metar_check::*;
pub use model::*;
pub use utils::*;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
use crate::error::{CoreError, CoreErrorKind, CoreResult};
use chrono::{Datelike, NaiveDate, Utc};
pub fn parse_metar_time(observation_time: &str) -> CoreResult<String> {
if observation_time.len() != 7 {
return Err(CoreError::new(
CoreErrorKind::InvalidInput,
format!("Unable to parse observation time in {}", observation_time),
));
}
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(|| {
CoreError::new(
CoreErrorKind::InvalidInput,
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(CoreError::new(
CoreErrorKind::InvalidInput,
format!(
"Invalid time for time '{}': hour {}, minute {}",
observation_time, observation_hour, observation_minute
),
));
}
};
let obs_datetime = if candidate_date > current_time {
// Subtract one month. (Handle year rollover carefully.)
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(|| {
CoreError::new(
CoreErrorKind::InvalidInput,
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())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDateTime;
#[test]
fn test_parse_metar_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 = parse_metar_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) => {}
}
}
}
}
}
}

167
crates/lib/src/state.rs Normal file
View File

@@ -0,0 +1,167 @@
use std::env;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use redis::aio::ConnectionManager;
use redis::AsyncTypedCommands;
use s3::{Bucket, BucketConfiguration, Region};
use s3::creds::Credentials;
use sqlx::{Pool, Postgres};
use sqlx::postgres::PgPoolOptions;
use crate::error::CoreResult;
use crate::http_client::HttpClient;
#[derive(Clone)]
pub struct AppState {
pub client: HttpClient,
pub pool: Pool<Postgres>,
pub connection_manager: Arc<Mutex<ConnectionManager>>,
pub bucket: Box<Bucket>,
}
impl AppState {
pub async fn new() -> CoreResult<Self> {
let client = HttpClient::default()?;
let pool: Pool<Postgres> = {
let user = env::var("POSTGRES_USER").unwrap_or("raac".to_string());
let password = env::var("POSTGRES_PASSWORD").expect("POSTGRES_PASSWORD must be set");
let host: String = env::var("POSTGRES_HOST").expect("POSTGRES_HOST must be set");
let port = env::var("POSTGRES_PORT").unwrap_or("5432".to_string());
let name = env::var("POSTGRES_DB").unwrap_or("raac".to_string());
let url = format!(
"postgres://{}:{}@{}:{}/{}",
&user, &password, &host, &port, &name
);
log::info!(
"Connecting to database at postgres://{}:*****@{}:{}/{}...",
&user,
&host,
&port,
&name
);
let connections = env::var("POSTGRES_CONNECTIONS")
.unwrap_or("5".to_string())
.parse::<u32>()
.unwrap_or(5);
let timeout = env::var("POSTGRES_TIMEOUT")
.unwrap_or("30".to_string())
.parse::<u64>()
.unwrap_or(30);
PgPoolOptions::new()
.max_connections(connections)
.acquire_timeout(Duration::from_secs(timeout))
.connect(&url)
.await
.expect("Failed to create postgres pool")
};
let run_migrations = env::var("POSTGRES_MIGRATE")
.unwrap_or("true".to_string())
.parse::<bool>()
.unwrap_or(true);
if run_migrations {
log::debug!("Running database migrations...");
match sqlx::migrate!().run(&pool).await {
Ok(_) => log::debug!("Database migrations completed"),
Err(err) => log::error!("Failed to run database migrations: {}", err),
};
}
let connection_manager: ConnectionManager = {
let host = env::var("VALKEY_HOST").unwrap_or("localhost".to_string());
let port = env::var("VALKEY_PORT").unwrap_or("6379".to_string());
let url = format!("redis://{}:{}", host, port);
log::info!("Connecting to in-memory datastore at {}...", &url);
let client = redis::Client::open(url).expect("Failed to create in-memory datastore client");
ConnectionManager::new(client)
.await
.expect("Failed to create in-memory datastore connection manager")
};
// Setup Bucket connection
let bucket = {
let protocol = std::env::var("MINIO_PROTOCOL").unwrap_or("http".to_string());
let host = std::env::var("MINIO_HOST").unwrap_or("localhost".to_string());
let port = std::env::var("MINIO_PORT").unwrap_or("9000".to_string());
let user = std::env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set");
let password = std::env::var("MINIO_ROOT_PASSWORD").expect("MINIO_ROOT_PASSWORD is not set");
let bucket_name = std::env::var("MINIO_BUCKET").unwrap_or("aviation".to_string());
let url = format!("{}://{}:{}", protocol, host, port);
let region = Region::Custom {
region: "".to_string(),
endpoint: url.to_string(),
};
let credentials = Credentials {
access_key: Some(user),
secret_key: Some(password),
security_token: None,
session_token: None,
expiration: None,
};
let bucket = Bucket::new(&bucket_name, region.clone(), credentials.clone())?.with_path_style();
log::info!("Checking for object in bucket at {}", &region.endpoint());
match bucket.head_object("/").await {
Ok(_) => bucket,
Err(_) => {
log::debug!("Creating '{}' bucket", &bucket_name);
let response = match Bucket::create_with_path_style(
&bucket_name,
region,
credentials,
BucketConfiguration::default(),
)
.await
{
Ok(response) => response,
Err(err) => {
log::error!("Failed to create bucket '{}': {}", &bucket_name, err);
return Err(err.into());
}
};
response.bucket
}
}
};
Ok(Self {
client,
pool,
connection_manager: Arc::new(Mutex::new(connection_manager)),
bucket,
})
}
pub async fn set(&self, key: &str, value: &str) -> CoreResult<()> {
let mut connection_manager = self.connection_manager.lock()?;
connection_manager.set(key, value).await?;
Ok(())
}
pub async fn set_ex(&self, key: &str, value: &str, seconds: u64) -> CoreResult<()> {
let mut connection_manager = self.connection_manager.lock()?;
connection_manager.set_ex(key, value, seconds).await?;
Ok(())
}
pub async fn get(&self, key: &str) -> CoreResult<Option<String>> {
let mut connection_manager = self.connection_manager.lock()?;
match connection_manager.get(key).await {
Ok(value) => Ok(value),
Err(_) => Ok(None),
}
}
pub async fn del(&self, key: &str) -> CoreResult<()> {
let mut connection_manager = self.connection_manager.lock()?;
connection_manager.del(key).await?;
Ok(())
}
}

View File

@@ -0,0 +1,11 @@
[package]
name = "scheduler"
version = "0.1.0"
edition = "2024"
[dependencies]
lib = { path = "../lib" }
chrono = "0.4.42"
tokio = { version = "1.47.1", features = ["rt", "rt-multi-thread"] }
log = "0.4.28"
env_logger = "0.11.8"

View File

@@ -0,0 +1,24 @@
# =========
# Builder
# =========
FROM rust:bookworm AS builder
WORKDIR /builder
COPY crates/lib /lib
COPY crates/scheduler/src ./src
COPY crates/scheduler/Cargo.toml ./
RUN apt-get update && apt-get install -y cmake
RUN cargo build --release
# =========
# Runtime
# =========
FROM debian:bookworm-slim AS runtime
WORKDIR /scheduler
RUN apt-get update && apt-get install -y openssl libpq-dev ca-certificates
USER root
COPY --from=builder /builder/target/release/scheduler /usr/local/bin/scheduler
CMD ["scheduler"]

View File

@@ -0,0 +1,56 @@
use chrono::{DateTime, Utc};
use std::env;
use std::time::{Duration, Instant};
use env_logger::Builder;
use log::LevelFilter;
use tokio::time::interval;
use lib::metars::Metar;
use lib::state::AppState;
#[tokio::main]
pub async fn main() {
Builder::new()
.filter_level(LevelFilter::Info) // Set a default log level
.filter_module("scheduler", LevelFilter::Trace)
.filter_module("lib", LevelFilter::Trace)
.init();
let state = match AppState::new().await {
Ok(state) => state,
Err(err) => {
log::error!("Failed to create state: {}", err);
return;
}
};
let seconds = env::var("METAR_INTERVAL")
.unwrap_or("300".to_string())
.parse::<u64>()
.unwrap_or(300);
// Create an interval ticker
let mut interval = interval(Duration::from_secs(seconds));
let mut etag = None;
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(&state, 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
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@ services:
container_name: aviation-nginx
build:
context: .
dockerfile: Dockerfile
dockerfile: nginx/Dockerfile
env_file: *env
environment:
SSL_CERT_PATH: /etc/nginx/ssl/localhost.crt
@@ -25,8 +25,7 @@ services:
volumes:
- ./ssl:/etc/nginx/ssl/
networks:
- frontend
- backend
- web
<<: *default_restart
postgres:
@@ -36,38 +35,34 @@ 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
profiles:
- backend
<<: *default_restart
redis:
image: gitea.bensherriff.com/homelab/redis:8.0-M03
container_name: aviation-redis
valkey:
image: valkey/valkey:8.1.3
container_name: aviation-valkey
volumes:
- redis:/data
- valkey:/data
ports:
- "${REDIS_PORT:-6379}:6379"
- "${VALKEY_PORT:-6379}:6379"
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
test: [ "CMD", "valkey-cli", "--raw", "incr", "ping" ]
interval: 10s
timeout: 5s
retries: 3
networks:
- backend
profiles:
- backend
<<: *default_restart
minio:
image: gitea.bensherriff.com/homelab/minio:RELEASE.2025-02-28T09-55-16Z
image: minio/minio:RELEASE.2025-07-23T15-54-02Z
container_name: aviation-minio
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
@@ -79,8 +74,6 @@ services:
ports:
- "${MINIO_PORT:-9000}:9000"
- "${MINIO_INTERNAL_PORT:-9001}:9001"
networks:
- backend
profiles:
- backend
command: server --console-address ":9001" /data
@@ -91,60 +84,71 @@ services:
container_name: aviation-api
build:
context: .
dockerfile: Dockerfile
dockerfile: crates/api/Dockerfile
env_file: *env
environment:
SSL_CA_PATH: /ssl/ca.pem
API_PORT: 5000
POSTGRES_HOST: aviation-postgres
POSTGRES_PORT: 5432
REDIS_HOST: aviation-redis
REDIS_PORT: 6379
VALKEY_HOST: aviation-valkey
VALKEY_PORT: 6379
MINIO_HOST: aviation-minio
MINIO_PORT: 9000
TEMPLATE_DIR: /templates
volumes:
- ./ssl:/ssl
- ./templates:/templates
ports:
- "${API_PORT:-5000}:5000"
depends_on:
- postgres
- redis
- valkey
- minio
networks:
- frontend
- backend
profiles:
- api
<<: *default_restart
ui-dev:
image: gitea.bensherriff.com/bsherriff/aviation-ui:latest
container_name: aviation-ui-dev
scheduler:
image: gitea.bensherriff.com/bsherriff/aviation-scheduler:latest
container_name: aviation-scheduler
build:
context: .
dockerfile: Dockerfile
dockerfile: crates/scheduler/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:
- frontend
POSTGRES_HOST: aviation-postgres
POSTGRES_PORT: 5432
depends_on:
- postgres
profiles:
- frontend
command: ["npm", "run", "dev"]
- api
<<: *default_restart
mailpit:
image: axllent/mailpit
container_name: mailpit
environment:
MP_MAX_MESSAGES: 5000
MP_DATABASE: /data/mailpit.db
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
ports:
- "${MAILPIT_WEB_PORT:-8025}:8025"
- "${MAILPIT_SMTP_PORT:-1025}:1025"
volumes:
- mailpit:/data
profiles:
- dev
<<: *default_restart
volumes:
postgres:
postgres_logs:
redis:
valkey:
minio:
mailpit:
networks:
frontend:
backend:
web:
driver: bridge

View File

@@ -4,31 +4,33 @@ worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
# allow HTTP/2 on the frontend
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
keepalive_timeout 65;
sendfile on;
tcp_nopush on;
#gzip on;
keepalive_timeout 65;
# Set client limit to 100 MB
client_max_body_size 100M;
gzip on;
include /etc/nginx/conf.d/*.conf;
# Set client limit to 100 MB
client_max_body_size 100M;
include /etc/nginx/conf.d/*.conf;
}

View File

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

View File

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

3
rust-toolchain.toml Normal file
View File

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

6
rustfmt.toml Normal file
View File

@@ -0,0 +1,6 @@
indent_style = "Block"
reorder_imports = true
imports_layout = "HorizontalVertical"
imports_granularity = "Crate"
group_imports = "One"
tab_spaces = 2

View File

@@ -2,6 +2,7 @@
force=0
push=0
push_all=0
API_VERSION=$(sed -n 's/^version *= *"\([^"]*\)".*/\1/p' "$(pwd)"/api/Cargo.toml)
UI_VERSION=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(pwd)"/ui/package.json)
@@ -17,6 +18,13 @@ for arg in "$@"; do
push=1
shift
;;
-a|--push-all)
push_all=1
shift
;;
*)
shift
;;
esac
done
@@ -69,3 +77,14 @@ if echo "$changed_files" | grep -q "^api/"; then
fi
fi
fi
# Push all tags
if [ $push_all -eq 1 ]; then
if [ $force -eq 1 ]; then
echo "Force-pushing ALL tags to remote"
git push -f origin --tags
else
echo "Pushing ALL tags to remote"
git push origin --tags
fi
fi

View File

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

View File

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

View File

@@ -10,6 +10,7 @@
</head>
<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
View File

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

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