Refactor to break out scheduler

This commit is contained in:
2025-10-23 20:23:03 -04:00
parent 84312d0b50
commit a9dc5ffdc1
66 changed files with 5796 additions and 705 deletions

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

@@ -4,6 +4,28 @@ version: '3'
dotenv: ['.env.local', '.env'] 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: tasks:
default: default:
cmds: cmds:
@@ -18,66 +40,101 @@ tasks:
dev-servers: dev-servers:
deps: deps:
- task: run-api - task: run-api
- task: run-scheduler
- task: run-ui - task: run-ui
# API Commands # API Commands
build-api: build-api:
dir: api dir: crates/api
cmds: cmd: cargo build
- cargo build
format-api: format-api:
dir: api dir: crates/api
cmds: cmd: cargo fmt
- cargo fmt
run-api: run-api:
dir: api dir: crates/api
cmds: cmd: cargo run
- 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 silent: true
# UI Commands # UI Commands
build-ui: build-ui:
dir: ui dir: ui
cmds: cmd: npm run build
- npm run build
format-ui: format-ui:
dir: ui dir: ui
cmds: cmd: npm run format
- npm run format
clean-ui: clean-ui:
dir: ui dir: ui
cmds: cmd: rm -rf node_modules dist stats.html
- rm -rf node_modules dist stats.html
run-ui: run-ui:
dir: ui dir: ui
cmds: cmd: npm run dev
- npm run dev
silent: true silent: true
# Docker Commands # Docker Commands
docker-backend: docker-backend:
cmds: cmd: docker compose --profile backend up -d
- docker compose --profile backend up -d
docker-up: docker-up:
cmds: cmd: docker compose --profile backend --profile api up -d
- docker compose --profile backend --profile api up -d
docker-down: docker-down:
cmds: cmd: docker compose --profile backend --profile api down
- docker compose --profile backend --profile api down
docker-clean: docker-clean:
cmds: cmd: docker compose --profile backend --profile api down -v
- docker compose --profile backend --profile api down -v
docker-refresh: docker-refresh:
cmds: cmds:
- task: docker-clean - task: docker-clean
- task: docker-up - task: docker-up
docker-build:
build:
desc: Build a specific docker image from a folder
cmds: cmds:
- docker compose build - |
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: psql:
cmds: cmd: docker exec -it aviation-postgres psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -P pager=off
- docker exec -it aviation-postgres psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -P pager=off
cert: cert:
cmds: cmds:

View File

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

View File

@@ -1,245 +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;
use std::sync::{MutexGuard, PoisonError};
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<chrono::ParseError> for Error {
fn from(error: chrono::ParseError) -> Self {
Self::new(500, format!("Chrono parse error: {:?}", error))
}
}
impl From<core::num::ParseIntError> for Error {
fn from(error: core::num::ParseIntError) -> Self {
Self::new(500, format!("Integer parse error: {:?}", error))
}
}
impl From<core::num::ParseFloatError> for Error {
fn from(error: core::num::ParseFloatError) -> Self {
Self::new(500, format!("Float 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())
}
}
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<regex::Error> for Error {
fn from(error: regex::Error) -> Self {
Self::new(500, error.to_string())
}
}
impl<'a, T> From<PoisonError<MutexGuard<'a, T>>> for Error {
fn from(_: PoisonError<MutexGuard<'a, T>>) -> Self {
Self::new(500, "Failed to acquire lock".to_string())
}
}

View File

@@ -1,43 +0,0 @@
use crate::metars::Metar;
use chrono::{DateTime, Utc};
use std::env;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::time::interval;
use crate::state::AppState;
pub fn run(state: Arc<AppState>) {
tokio::spawn(async move {
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
);
}
});
}

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Change Password name: Change Password
type: http type: http
seq: 6 seq: 7
} }
put { put {

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Confirm Password Reset name: Confirm Password Reset
type: http type: http
seq: 8 seq: 9
} }
post { post {

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Get Profile name: Get Profile
type: http type: http
seq: 10 seq: 11
} }
get { get {

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 { meta {
name: Logout name: Logout
type: http type: http
seq: 5 seq: 6
} }
post { post {

View File

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

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Reset Password name: Reset Password
type: http type: http
seq: 7 seq: 8
} }
post { post {

View File

@@ -444,10 +444,11 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]] [[package]]
name = "attohttpc" name = "attohttpc"
version = "0.28.5" version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07a9b245ba0739fc90935094c29adbaee3f977218b5fb95e822e261cda7f56a3" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9"
dependencies = [ dependencies = [
"base64",
"http 1.3.1", "http 1.3.1",
"log", "log",
"native-tls", "native-tls",
@@ -464,9 +465,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]] [[package]]
name = "aws-creds" name = "aws-creds"
version = "0.37.0" version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f84143206b9c72b3c5cb65415de60c7539c79cd1559290fddec657939131be0" checksum = "b13804829a843b3f26e151c97acbb315ee1177a2724690edfcd28f1894146200"
dependencies = [ dependencies = [
"attohttpc", "attohttpc",
"home", "home",
@@ -474,18 +475,18 @@ dependencies = [
"quick-xml", "quick-xml",
"rust-ini", "rust-ini",
"serde", "serde",
"thiserror 1.0.69", "thiserror",
"time", "time",
"url", "url",
] ]
[[package]] [[package]]
name = "aws-region" name = "aws-region"
version = "0.25.5" version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9aed3f9c7eac9be28662fdb3b0f4d1951e812f7c64fed4f0327ba702f459b3b" checksum = "5532f65342f789f9c1b7078ea9c9cd9293cd62dcc284fa99adc4a1c9ba43469c"
dependencies = [ dependencies = [
"thiserror 1.0.69", "thiserror",
] ]
[[package]] [[package]]
@@ -599,6 +600,15 @@ dependencies = [
"bytes", "bytes",
] ]
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.23" version = "1.2.23"
@@ -660,6 +670,19 @@ dependencies = [
"tokio-util", "tokio-util",
] ]
[[package]]
name = "compact_str"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"ryu",
"static_assertions",
]
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -1362,7 +1385,7 @@ dependencies = [
"pest_derive", "pest_derive",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.12", "thiserror",
] ]
[[package]] [[package]]
@@ -1467,17 +1490,6 @@ dependencies = [
"itoa", "itoa",
] ]
[[package]]
name = "http-body"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
dependencies = [
"bytes",
"http 0.2.12",
"pin-project-lite",
]
[[package]] [[package]]
name = "http-body" name = "http-body"
version = "1.0.1" version = "1.0.1"
@@ -1497,7 +1509,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"http 1.3.1", "http 1.3.1",
"http-body 1.0.1", "http-body",
"pin-project-lite", "pin-project-lite",
] ]
@@ -1513,29 +1525,6 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "0.14.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http 0.2.12",
"http-body 0.4.6",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"socket2 0.5.9",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.6.0" version = "1.6.0"
@@ -1547,7 +1536,7 @@ dependencies = [
"futures-util", "futures-util",
"h2 0.4.10", "h2 0.4.10",
"http 1.3.1", "http 1.3.1",
"http-body 1.0.1", "http-body",
"httparse", "httparse",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
@@ -1564,7 +1553,7 @@ checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"http 1.3.1", "http 1.3.1",
"hyper 1.6.0", "hyper",
"hyper-util", "hyper-util",
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
@@ -1573,19 +1562,6 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "hyper-tls"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes",
"hyper 0.14.32",
"native-tls",
"tokio",
"tokio-native-tls",
]
[[package]] [[package]]
name = "hyper-tls" name = "hyper-tls"
version = "0.6.0" version = "0.6.0"
@@ -1594,7 +1570,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [ dependencies = [
"bytes", "bytes",
"http-body-util", "http-body-util",
"hyper 1.6.0", "hyper",
"hyper-util", "hyper-util",
"native-tls", "native-tls",
"tokio", "tokio",
@@ -1613,8 +1589,8 @@ dependencies = [
"futures-channel", "futures-channel",
"futures-util", "futures-util",
"http 1.3.1", "http 1.3.1",
"http-body 1.0.1", "http-body",
"hyper 1.6.0", "hyper",
"ipnet", "ipnet",
"libc", "libc",
"percent-encoding", "percent-encoding",
@@ -2006,9 +1982,9 @@ dependencies = [
[[package]] [[package]]
name = "md5" name = "md5"
version = "0.7.0" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
[[package]] [[package]]
name = "memchr" name = "memchr"
@@ -2034,9 +2010,9 @@ dependencies = [
[[package]] [[package]]
name = "minidom" name = "minidom"
version = "0.15.2" version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f45614075738ce1b77a1768912a60c0227525971b03e09122a05b8a34a2a6278" checksum = "e394a0e3c7ccc2daea3dffabe82f09857b6b510cb25af87d54bf3e910ac1642d"
dependencies = [ dependencies = [
"rxml", "rxml",
] ]
@@ -2094,6 +2070,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "ntapi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.6" version = "0.4.6"
@@ -2172,6 +2157,25 @@ dependencies = [
"libm", "libm",
] ]
[[package]]
name = "objc2-core-foundation"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
dependencies = [
"bitflags",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a"
dependencies = [
"libc",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "object" name = "object"
version = "0.36.7" version = "0.36.7"
@@ -2309,7 +2313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6"
dependencies = [ dependencies = [
"memchr", "memchr",
"thiserror 2.0.12", "thiserror",
"ucd-trie", "ucd-trie",
] ]
@@ -2460,9 +2464,9 @@ dependencies = [
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.32.0" version = "0.38.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89"
dependencies = [ dependencies = [
"memchr", "memchr",
"serde", "serde",
@@ -2650,13 +2654,14 @@ dependencies = [
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
"futures-util",
"h2 0.4.10", "h2 0.4.10",
"http 1.3.1", "http 1.3.1",
"http-body 1.0.1", "http-body",
"http-body-util", "http-body-util",
"hyper 1.6.0", "hyper",
"hyper-rustls", "hyper-rustls",
"hyper-tls 0.6.0", "hyper-tls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
@@ -2671,12 +2676,14 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams",
"web-sys", "web-sys",
] ]
@@ -2761,9 +2768,9 @@ dependencies = [
[[package]] [[package]]
name = "rust-s3" name = "rust-s3"
version = "0.35.1" version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3df3f353b1f4209dcf437d777cda90279c397ab15a0cd6fd06bd32c88591533" checksum = "94f9b973bd4097f5bb47e5827dcb9fb5dc17e93879e46badc27d2a4e9a4e5588"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"aws-creds", "aws-creds",
@@ -2771,27 +2778,25 @@ dependencies = [
"base64", "base64",
"bytes", "bytes",
"cfg-if", "cfg-if",
"futures", "futures-util",
"hex", "hex",
"hmac", "hmac",
"http 0.2.12", "http 1.3.1",
"hyper 0.14.32",
"hyper-tls 0.5.0",
"log", "log",
"maybe-async", "maybe-async",
"md5", "md5",
"minidom", "minidom",
"native-tls",
"percent-encoding", "percent-encoding",
"quick-xml", "quick-xml",
"reqwest",
"serde", "serde",
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"sha2", "sha2",
"thiserror 1.0.69", "sysinfo",
"thiserror",
"time", "time",
"tokio", "tokio",
"tokio-native-tls",
"tokio-stream", "tokio-stream",
"url", "url",
] ]
@@ -2865,20 +2870,22 @@ checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
[[package]] [[package]]
name = "rxml" name = "rxml"
version = "0.9.1" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a98f186c7a2f3abbffb802984b7f1dfd65dac8be1aafdaabbca4137f53f0dff7" checksum = "65bc94b580d0f5a6b7a2d604e597513d3c673154b52ddeccd1d5c32360d945ee"
dependencies = [ dependencies = [
"bytes", "bytes",
"rxml_validation", "rxml_validation",
"smartstring",
] ]
[[package]] [[package]]
name = "rxml_validation" name = "rxml_validation"
version = "0.9.1" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22a197350ece202f19a166d1ad6d9d6de145e1d2a8ef47db299abe164dbd7530" checksum = "826e80413b9a35e9d33217b3dcac04cf95f6559d15944b93887a08be5496c4a4"
dependencies = [
"compact_str",
]
[[package]] [[package]]
name = "ryu" name = "ryu"
@@ -3078,17 +3085,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"static_assertions",
"version_check",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.5.9" version = "0.5.9"
@@ -3178,7 +3174,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"smallvec", "smallvec",
"thiserror 2.0.12", "thiserror",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
@@ -3262,7 +3258,7 @@ dependencies = [
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror 2.0.12", "thiserror",
"tracing", "tracing",
"uuid", "uuid",
"whoami", "whoami",
@@ -3301,7 +3297,7 @@ dependencies = [
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror 2.0.12", "thiserror",
"tracing", "tracing",
"uuid", "uuid",
"whoami", "whoami",
@@ -3327,7 +3323,7 @@ dependencies = [
"serde", "serde",
"serde_urlencoded", "serde_urlencoded",
"sqlx-core", "sqlx-core",
"thiserror 2.0.12", "thiserror",
"tracing", "tracing",
"url", "url",
"uuid", "uuid",
@@ -3412,6 +3408,20 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "sysinfo"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07cec4dc2d2e357ca1e610cfb07de2fa7a10fc3e9fe89f72545f3d244ea87753"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows",
]
[[package]] [[package]]
name = "system-configuration" name = "system-configuration"
version = "0.6.1" version = "0.6.1"
@@ -3446,33 +3456,13 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.12" version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [ dependencies = [
"thiserror-impl 2.0.12", "thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
] ]
[[package]] [[package]]
@@ -3651,7 +3641,7 @@ dependencies = [
"bytes", "bytes",
"futures-util", "futures-util",
"http 1.3.1", "http 1.3.1",
"http-body 1.0.1", "http-body",
"iri-string", "iri-string",
"pin-project-lite", "pin-project-lite",
"tower", "tower",
@@ -3984,6 +3974,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.77" version = "0.3.77"
@@ -4054,6 +4057,28 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.61.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-link 0.1.1",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
"windows-core",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.61.2" version = "0.61.2"
@@ -4067,6 +4092,17 @@ dependencies = [
"windows-strings 0.4.2", "windows-strings 0.4.2",
] ]
[[package]]
name = "windows-future"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core",
"windows-link 0.1.1",
"windows-threading",
]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.0" version = "0.60.0"
@@ -4101,6 +4137,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
[[package]]
name = "windows-numerics"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core",
"windows-link 0.1.1",
]
[[package]] [[package]]
name = "windows-registry" name = "windows-registry"
version = "0.4.0" version = "0.4.0"
@@ -4213,6 +4259,15 @@ dependencies = [
"windows_x86_64_msvc 0.53.0", "windows_x86_64_msvc 0.53.0",
] ]
[[package]]
name = "windows-threading"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
dependencies = [
"windows-link 0.1.1",
]
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.48.5" version = "0.48.5"

View File

@@ -4,37 +4,26 @@ version = "0.1.3"
edition = "2024" edition = "2024"
authors = ["Ben Sherriff <ben@bensherriff.com>"] authors = ["Ben Sherriff <ben@bensherriff.com>"]
repository = "https://gitea.bensherriff.com/bsherriff/aviation" repository = "https://gitea.bensherriff.com/bsherriff/aviation"
readme = "../README.md" readme = "../../README.md"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
lib = { path = "../lib" }
actix-web = "4.11.0" actix-web = "4.11.0"
actix-cors = "0.7.1" actix-cors = "0.7.1"
actix-multipart = "0.7.2" actix-multipart = "0.7.2"
chrono = { version = "0.4.41", features = ["serde"] } chrono = { version = "0.4.42", features = ["serde"] }
dotenv = "0.15.0" dotenv = "0.15.0"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
env_logger = "0.11.8" env_logger = "0.11.8"
reqwest = "0.12.23"
serde = {version = "1.0.219", features = ["derive"]} serde = {version = "1.0.219", features = ["derive"]}
serde_json = "1.0.142" serde_json = "1.0.142"
tokio = { version = "1.47.1", features = ["macros", "rt", "time"] } tokio = { version = "1.47.1", features = ["macros", "rt", "time"] }
uuid = { version = "1.18.0", features = ["serde", "v4"] } log = "0.4.28"
log = "0.4.27"
argon2 = "0.5.3" argon2 = "0.5.3"
redis = { version = "0.32.5", features = ["tokio-comp", "connection-manager", "r2d2", "json"] }
regex = "1.11.1"
futures-util = "0.3.31" futures-util = "0.3.31"
rust-s3 = "0.35.1"
rand = "0.9.2"
rand_chacha = "0.9.0"
futures = "0.3.31"
utoipa = { version = "5.4.0", features = ["chrono", "uuid", "actix_extras"] } utoipa = { version = "5.4.0", features = ["chrono", "uuid", "actix_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] } utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] }
utoipa-actix-web = "0.1.2" utoipa-actix-web = "0.1.2"
webpki-roots = "1.0.0"
lettre = { version = "0.11.18", features = ["builder", "smtp-transport", "tokio1-native-tls"] } lettre = { version = "0.11.18", features = ["builder", "smtp-transport", "tokio1-native-tls"] }
handlebars = "6.3.2" handlebars = "6.3.2"
governor = "0.10.1"
flate2 = "1.1.2"

View File

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

View File

@@ -2,11 +2,11 @@ use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use super::{SESSION_COOKIE_NAME, Session}; use super::{SESSION_COOKIE_NAME, Session};
use crate::account::user::User; use crate::error::{ApiResult, Error};
use crate::error::Error;
use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http, web}; use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http, web};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::state::AppState; use lib::accounts::User;
use lib::state::AppState;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Auth { pub struct Auth {
@@ -104,3 +104,16 @@ impl FromRequest for Auth {
Box::pin(fut) 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

@@ -1,12 +1,11 @@
use crate::account::{csprng, hash}; use lib::accounts::{csprng, hash};
use crate::error::{ApiResult, Error}; use crate::error::{ApiResult, Error};
use crate::smtp; use crate::smtp;
use chrono::{Datelike, Utc}; use chrono::{Datelike, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::Path; use std::path::Path;
use std::{env, fs}; use std::{env, fs};
use redis::aio::ConnectionManager; use lib::state::AppState;
use crate::state::AppState;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct EmailToken { pub struct EmailToken {
@@ -30,7 +29,9 @@ impl EmailToken {
let now = Utc::now(); let now = Utc::now();
let expires_at = now + chrono::Duration::seconds(ttl_secs); let expires_at = now + chrono::Duration::seconds(ttl_secs);
let ttl = expires_at.timestamp() - now.timestamp(); let ttl = expires_at.timestamp() - now.timestamp();
state.set_ex(&key, &value, ttl as u64).await let _ = state.set_ex(&key, &value, ttl as u64).await?;
Ok(())
} }
pub async fn get(state: &AppState, token: &str) -> ApiResult<Self> { pub async fn get(state: &AppState, token: &str) -> ApiResult<Self> {
@@ -42,7 +43,8 @@ impl EmailToken {
} }
pub async fn delete(state: &AppState, token: &str) -> ApiResult<()> { pub async fn delete(state: &AppState, token: &str) -> ApiResult<()> {
state.del(token).await let _ = state.del(token).await;
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

@@ -1,12 +1,10 @@
use super::{csprng, hash, verify_hash}; use lib::accounts::{csprng, hash, verify_hash};
use crate::error::{ApiResult, Error}; use crate::error::{ApiResult, Error};
use actix_web::cookie::{Cookie, time::Duration}; use actix_web::cookie::{Cookie, time::Duration};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use redis::{AsyncCommands, RedisResult};
use redis::aio::ConnectionManager;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::task; use lib::error::CoreResult;
use crate::state::AppState; use lib::state::AppState;
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
pub const SESSION_COOKIE_NAME: &str = "session"; pub const SESSION_COOKIE_NAME: &str = "session";
@@ -42,7 +40,7 @@ impl Session {
pub async fn store(&self, state: &AppState) -> ApiResult<()> { pub async fn store(&self, state: &AppState) -> ApiResult<()> {
let key = self.session_id.clone(); let key = self.session_id.clone();
let value = serde_json::to_string(self)?; let value = serde_json::to_string(self)?;
let result: ApiResult<()> = match self.expires_at { let result: CoreResult<()> = match self.expires_at {
Some(expires_at) => { Some(expires_at) => {
let ttl = expires_at.timestamp() - Utc::now().timestamp(); let ttl = expires_at.timestamp() - Utc::now().timestamp();
state.set_ex(&key, &value, ttl as u64).await state.set_ex(&key, &value, ttl as u64).await
@@ -73,7 +71,7 @@ impl Session {
} }
pub async fn delete(state: &AppState, session_id: &str) -> ApiResult<()> { pub async fn delete(state: &AppState, session_id: &str) -> ApiResult<()> {
let result: ApiResult<()> = state.del(session_id).await; let result: CoreResult<()> = state.del(session_id).await;
match result { match result {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(err) => Err(err.into()), Err(err) => Err(err.into()),

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()),
}
}
}

View File

@@ -1,32 +1,26 @@
use crate::account::User; use lib::accounts::{User, ADMIN_ROLE, hash};
use crate::account::{ADMIN_ROLE, hash};
use actix_cors::Cors; use actix_cors::Cors;
use actix_web::{App, HttpServer, middleware::Logger, web}; use actix_web::{App, HttpServer, middleware::Logger, web};
use dotenv::from_filename; use dotenv::from_filename;
use std::env; use std::env;
use std::sync::Arc;
use utoipa::openapi::SecurityRequirement; use utoipa::openapi::SecurityRequirement;
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}; use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
use utoipa_actix_web::{AppExt, scope}; use utoipa_actix_web::{AppExt, scope};
use utoipa_swagger_ui::{Config, SwaggerUi}; use utoipa_swagger_ui::{Config, SwaggerUi};
use crate::state::AppState; use lib::state::AppState;
mod account; mod accounts;
mod airports;
mod error; mod error;
mod http_client; mod routes;
mod metars;
mod scheduler;
mod smtp; mod smtp;
mod system; mod system;
mod state;
mod utils; mod utils;
#[actix_web::main] #[actix_web::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
initialize_environment()?; initialize_environment()?;
let state = Arc::new(AppState::new().await?);
scheduler::run(state.clone()); let state = AppState::new().await?;
// Initialize admin user // Initialize admin user
let admin_username = env::var("ADMIN_USERNAME"); let admin_username = env::var("ADMIN_USERNAME");
@@ -82,9 +76,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.into_utoipa_app() .into_utoipa_app()
.service( .service(
scope::scope("/api") scope::scope("/api")
.configure(airports::init_routes) .configure(routes::init_routes)
.configure(metars::init_routes)
.configure(account::init_routes)
.configure(system::init_routes), .configure(system::init_routes),
) )
.split_for_parts(); .split_for_parts();

View File

@@ -1,18 +1,13 @@
use crate::{ use lib::accounts::{csprng, LoginRequest, RegisterRequest, UpdateUser, User, UserResponse, UserFavorite, verify_hash};
account::{SESSION_COOKIE_NAME, Session, verify_hash},
error::Error,
};
use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web}; use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web};
use serde::Deserialize; use serde::Deserialize;
use utoipa::ToSchema; use utoipa::ToSchema;
use utoipa_actix_web::scope; use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig; use utoipa_actix_web::service_config::ServiceConfig;
use lib::error::CoreErrorKind;
use crate::account::email_token::{EmailToken, send_confirm_email, send_password_reset_email}; use lib::state::AppState;
use crate::account::user::{LoginRequest, RegisterRequest, UpdateUser, User, UserResponse}; use crate::accounts::{send_confirm_email, send_password_reset_email, Auth, EmailToken, Session, SESSION_COOKIE_NAME};
use crate::account::user_favorites::UserFavorite; use crate::error::Error;
use crate::account::{Auth, csprng};
use crate::state::AppState;
#[utoipa::path( #[utoipa::path(
tag = "account", tag = "account",
@@ -30,12 +25,12 @@ async fn register(state: web::Data<AppState>, user: web::Json<RegisterRequest>,
let username = register_user.username.clone(); let username = register_user.username.clone();
let email = register_user.email.clone(); let email = register_user.email.clone();
let ip_address = req.peer_addr().unwrap().ip().to_string(); let ip_address = req.peer_addr().unwrap().ip().to_string();
let insert_user: User = match register_user.to_user() { let insert_user: User = match register_user.to_user().map_err(Error::from) {
Ok(user) => user, Ok(user) => user,
Err(err) => return ResponseError::error_response(&err), Err(err) => return ResponseError::error_response(&err),
}; };
match insert_user.insert(&state.pool).await { match insert_user.insert(&state.pool).await.map_err(Error::from) {
Ok(user) => { Ok(user) => {
let user_response: UserResponse = user.into(); let user_response: UserResponse = user.into();
log::info!( log::info!(
@@ -123,7 +118,7 @@ async fn confirm_email_registration(
avatar: None, avatar: None,
}; };
match update_user.update(&state.pool, &user.username).await { match update_user.update(&state.pool, &user.username).await.map_err(Error::from) {
Ok(user) => { Ok(user) => {
let response: UserResponse = user.into(); let response: UserResponse = user.into();
log::info!( log::info!(
@@ -451,7 +446,7 @@ async fn change_password(
avatar: None, avatar: None,
}; };
match update_user.update(&state.pool, &username).await { match update_user.update(&state.pool, &username).await.map_err(Error::from) {
Ok(user) => { Ok(user) => {
let response: UserResponse = user.into(); let response: UserResponse = user.into();
log::info!( log::info!(
@@ -532,10 +527,10 @@ async fn confirm_password_reset(
req: HttpRequest, req: HttpRequest,
) -> HttpResponse { ) -> HttpResponse {
// TODO // TODO
let ip_address = req.peer_addr().unwrap().ip().to_string(); let _ip_address = req.peer_addr().unwrap().ip().to_string();
let token = &request.token; let token = &request.token;
let email_token = match EmailToken::get(&state, token).await { let _email_token = match EmailToken::get(&state, token).await {
Ok(password_reset) => { Ok(password_reset) => {
if let Err(err) = EmailToken::delete(&state, &password_reset.token).await { if let Err(err) = EmailToken::delete(&state, &password_reset.token).await {
return ResponseError::error_response(&err); return ResponseError::error_response(&err);
@@ -563,7 +558,7 @@ async fn confirm_password_reset(
#[get("/profile/favorites")] #[get("/profile/favorites")]
async fn get_favorites(state: web::Data<AppState>, auth: Auth) -> HttpResponse { async fn get_favorites(state: web::Data<AppState>, auth: Auth) -> HttpResponse {
let username = auth.user.username; let username = auth.user.username;
match UserFavorite::select_all(&state.pool, &username).await { match UserFavorite::select_all(&state.pool, &username).await.map_err(Error::from) {
Ok(favorites) => HttpResponse::Ok().json(favorites), Ok(favorites) => HttpResponse::Ok().json(favorites),
Err(err) => ResponseError::error_response(&err), Err(err) => ResponseError::error_response(&err),
} }
@@ -582,7 +577,7 @@ async fn get_favorites(state: web::Data<AppState>, auth: Auth) -> HttpResponse {
#[post("/profile/favorites/{icao}")] #[post("/profile/favorites/{icao}")]
async fn add_favorite(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse { async fn add_favorite(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse {
let username = auth.user.username; let username = auth.user.username;
match UserFavorite::insert(&state.pool, &username, &icao.into_inner()).await { match UserFavorite::insert(&state.pool, &username, &icao.into_inner()).await.map_err(Error::from) {
Ok(_) => HttpResponse::Ok().finish(), Ok(_) => HttpResponse::Ok().finish(),
Err(err) => ResponseError::error_response(&err), Err(err) => ResponseError::error_response(&err),
} }
@@ -601,7 +596,7 @@ async fn add_favorite(state: web::Data<AppState>, icao: web::Path<String>, auth:
#[delete("/profile/favorites/{icao}")] #[delete("/profile/favorites/{icao}")]
async fn remove_favorite(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse { async fn remove_favorite(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse {
let username = auth.user.username; let username = auth.user.username;
match UserFavorite::delete(&state.pool, &username, &icao.into_inner()).await { match UserFavorite::delete(&state.pool, &username, &icao.into_inner()).await.map_err(Error::from) {
Ok(_) => HttpResponse::Ok().finish(), Ok(_) => HttpResponse::Ok().finish(),
Err(err) => ResponseError::error_response(&err), Err(err) => ResponseError::error_response(&err),
} }

View File

@@ -1,17 +1,14 @@
use futures_util::stream::StreamExt as _; use futures_util::stream::StreamExt as _;
use crate::account::ADMIN_ROLE; use lib::{accounts::ADMIN_ROLE, airports::{Airport, AirportQuery, UpdateAirport}};
use crate::airports::{AirportQuery, UpdateAirport};
use crate::{
account::{Auth, verify_role},
airports::Airport,
};
use actix_multipart::Multipart; use actix_multipart::Multipart;
use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web}; use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web};
use utoipa::ToSchema; use utoipa::ToSchema;
use utoipa_actix_web::scope; use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig; use utoipa_actix_web::service_config::ServiceConfig;
use crate::state::AppState; use lib::state::AppState;
use crate::accounts::Auth;
use crate::error::Error;
use crate::utils::Paged; use crate::utils::Paged;
#[derive(ToSchema)] #[derive(ToSchema)]
@@ -36,8 +33,8 @@ struct FileUpload {
)] )]
#[post("/import")] #[post("/import")]
async fn import_airports(state: web::Data<AppState>, mut payload: Multipart, auth: Auth) -> HttpResponse { async fn import_airports(state: web::Data<AppState>, mut payload: Multipart, auth: Auth) -> HttpResponse {
if let Err(err) = verify_role(&auth, ADMIN_ROLE) { if let Err(err) = &auth.verify_role(ADMIN_ROLE).map_err(Error::from) {
return ResponseError::error_response(&err); return ResponseError::error_response(err);
}; };
while let Some(item) = payload.next().await { while let Some(item) = payload.next().await {
@@ -68,7 +65,7 @@ async fn import_airports(state: web::Data<AppState>, mut payload: Multipart, aut
} }
}; };
match Airport::insert_all(&state.pool, airports).await { match Airport::insert_all(&state.pool, airports).await.map_err(Error::from) {
Ok(_) => {} Ok(_) => {}
Err(err) => return ResponseError::error_response(&err), Err(err) => return ResponseError::error_response(&err),
}; };
@@ -104,7 +101,7 @@ async fn get_airports(state: web::Data<AppState>, req: HttpRequest) -> HttpRespo
query.limit = Some(limit); query.limit = Some(limit);
query.page = Some(page); query.page = Some(page);
match Airport::select_all(&state.pool, &query).await { match Airport::select_all(&state.pool, &query).await.map_err(Error::from) {
Ok(airports) => HttpResponse::Ok().json(Paged { Ok(airports) => HttpResponse::Ok().json(Paged {
data: airports, data: airports,
page, page,
@@ -154,11 +151,11 @@ async fn get_airport(state: web::Data<AppState>, icao: web::Path<String>, req: H
)] )]
#[post("")] #[post("")]
async fn insert_airport(state: web::Data<AppState>, airport: web::Json<Airport>, auth: Auth) -> HttpResponse { async fn insert_airport(state: web::Data<AppState>, airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) { let _ = match &auth.verify_role(ADMIN_ROLE) {
Ok(_) => {} Ok(_) => {}
Err(err) => return ResponseError::error_response(&err), Err(err) => return ResponseError::error_response(err),
}; };
match airport.insert(&state.pool).await { match airport.insert(&state.pool).await.map_err(Error::from) {
Ok(a) => HttpResponse::Ok().json(a), Ok(a) => HttpResponse::Ok().json(a),
Err(err) => { Err(err) => {
log::error!("{}", err); log::error!("{}", err);
@@ -184,11 +181,11 @@ async fn update_airport(
airport: web::Json<UpdateAirport>, airport: web::Json<UpdateAirport>,
auth: Auth, auth: Auth,
) -> HttpResponse { ) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) { let _ = match &auth.verify_role(ADMIN_ROLE) {
Ok(_) => {} Ok(_) => {}
Err(err) => return ResponseError::error_response(&err), Err(err) => return ResponseError::error_response(err),
}; };
match Airport::update(&state.pool, &icao.into_inner(), &airport.into_inner()).await { match Airport::update(&state.pool, &icao.into_inner(), &airport.into_inner()).await.map_err(Error::from) {
Ok(a) => HttpResponse::Ok().json(a), Ok(a) => HttpResponse::Ok().json(a),
Err(err) => { Err(err) => {
log::error!("{}", err); log::error!("{}", err);
@@ -209,11 +206,11 @@ async fn update_airport(
)] )]
#[delete("")] #[delete("")]
async fn delete_airports(state: web::Data<AppState>, auth: Auth) -> HttpResponse { async fn delete_airports(state: web::Data<AppState>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) { let _ = match &auth.verify_role(ADMIN_ROLE) {
Ok(_) => {} Ok(_) => {}
Err(err) => return ResponseError::error_response(&err), Err(err) => return ResponseError::error_response(err),
}; };
match Airport::delete_all(&state.pool).await { match Airport::delete_all(&state.pool).await.map_err(Error::from) {
Ok(_) => HttpResponse::NoContent().finish(), Ok(_) => HttpResponse::NoContent().finish(),
Err(err) => { Err(err) => {
log::error!("{}", err); log::error!("{}", err);
@@ -234,11 +231,11 @@ async fn delete_airports(state: web::Data<AppState>, auth: Auth) -> HttpResponse
)] )]
#[delete("/{icao}")] #[delete("/{icao}")]
async fn delete_airport(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse { async fn delete_airport(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) { let _ = match &auth.verify_role(ADMIN_ROLE) {
Ok(_) => {} Ok(_) => {}
Err(err) => return ResponseError::error_response(&err), Err(err) => return ResponseError::error_response(err),
}; };
match Airport::delete(&state.pool, &icao.into_inner()).await { match Airport::delete(&state.pool, &icao.into_inner()).await.map_err(Error::from) {
Ok(_) => HttpResponse::NoContent().finish(), Ok(_) => HttpResponse::NoContent().finish(),
Err(err) => { Err(err) => {
log::error!("{}", err); log::error!("{}", err);

View File

@@ -1,12 +1,13 @@
use crate::AppState; use crate::AppState;
use crate::account::Auth;
use crate::metars::Metar;
use actix_web::{HttpRequest, HttpResponse, get, put, web}; use actix_web::{HttpRequest, HttpResponse, get, put, web};
use log::error; use log::error;
use serde::Deserialize; use serde::Deserialize;
use utoipa::{IntoParams, ToSchema}; use utoipa::{IntoParams, ToSchema};
use utoipa_actix_web::scope; use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig; use utoipa_actix_web::service_config::ServiceConfig;
use lib::metars::Metar;
use crate::accounts::Auth;
use crate::error::Error;
#[derive(Debug, Deserialize, ToSchema, IntoParams)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[into_params(parameter_in = Query)] #[into_params(parameter_in = Query)]
@@ -37,7 +38,7 @@ async fn find_all(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse
}; };
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect(); let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect();
let metars = match Metar::get_all_distinct(&state.pool, &icaos).await { let metars = match Metar::get_all_distinct(&state.pool, &icaos).await.map_err(Error::from) {
Ok(a) => a, Ok(a) => a,
Err(err) => { Err(err) => {
error!("{}", err); error!("{}", err);
@@ -62,7 +63,6 @@ async fn find_all(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse
)] )]
#[put("")] #[put("")]
async fn refresh_metars(state: web::Data<AppState>, req: HttpRequest, _auth: Auth) -> HttpResponse { async fn refresh_metars(state: web::Data<AppState>, req: HttpRequest, _auth: Auth) -> HttpResponse {
let client = state.client.clone();
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap(); let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
let icao_option = &parameters.icaos; let icao_option = &parameters.icaos;
if let None = icao_option { if let None = icao_option {
@@ -75,7 +75,7 @@ async fn refresh_metars(state: web::Data<AppState>, req: HttpRequest, _auth: Aut
}; };
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect(); let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect();
let metars = match Metar::get_or_update_metars(&state, &icaos).await { let metars = match Metar::get_or_update_metars(&state, &icaos).await.map_err(Error::from) {
Ok(a) => a, Ok(a) => a,
Err(err) => { Err(err) => {
error!("{}", err); error!("{}", err);

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

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

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

@@ -1,10 +1,11 @@
use crate::{account::hash, error::ApiResult};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[allow(unused_imports)] // Import is used in schema examples #[allow(unused_imports)] // Import is used in schema examples
use serde_json::json; use serde_json::json;
use sqlx::{Pool, Postgres, QueryBuilder}; use sqlx::{Pool, Postgres, QueryBuilder};
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::accounts::hash;
use crate::error::CoreResult;
pub const ADMIN_ROLE: &str = "ADMIN"; pub const ADMIN_ROLE: &str = "ADMIN";
pub const USER_ROLE: &str = "USER"; pub const USER_ROLE: &str = "USER";
@@ -33,7 +34,7 @@ pub struct RegisterRequest {
} }
impl RegisterRequest { impl RegisterRequest {
pub fn to_user(self) -> ApiResult<User> { pub fn to_user(self) -> CoreResult<User> {
let password_hash = hash(&self.password)?; let password_hash = hash(&self.password)?;
Ok(User { Ok(User {
username: self.username, username: self.username,
@@ -106,7 +107,7 @@ pub struct UpdateUser {
} }
impl UpdateUser { impl UpdateUser {
pub async fn update(&self, pool: &Pool<Postgres>, username: &str) -> ApiResult<User> { pub async fn update(&self, pool: &Pool<Postgres>, username: &str) -> CoreResult<User> {
let mut query_builder: QueryBuilder<Postgres> = let mut query_builder: QueryBuilder<Postgres> =
QueryBuilder::new(&format!("UPDATE {} SET ", TABLE_NAME)); QueryBuilder::new(&format!("UPDATE {} SET ", TABLE_NAME));
@@ -235,7 +236,7 @@ impl User {
.unwrap_or_else(|_| 0) .unwrap_or_else(|_| 0)
} }
pub async fn insert(&self, pool: &Pool<Postgres>) -> ApiResult<User> { pub async fn insert(&self, pool: &Pool<Postgres>) -> CoreResult<User> {
let user: User = sqlx::query_as::<_, Self>(&format!( let user: User = sqlx::query_as::<_, Self>(&format!(
r#" r#"
INSERT INTO {} ( INSERT INTO {} (

View File

@@ -1,4 +1,4 @@
use crate::error::ApiResult; use crate::error::CoreResult;
use serde::Deserialize; use serde::Deserialize;
use sqlx::{Pool, Postgres}; use sqlx::{Pool, Postgres};
@@ -11,7 +11,7 @@ pub struct UserFavorite {
} }
impl UserFavorite { impl UserFavorite {
pub async fn select_all(pool: &Pool<Postgres>, username: &str) -> ApiResult<Vec<String>> { pub async fn select_all(pool: &Pool<Postgres>, username: &str) -> CoreResult<Vec<String>> {
let user_favorites: Vec<UserFavorite> = sqlx::query_as::<_, UserFavorite>(&format!( let user_favorites: Vec<UserFavorite> = sqlx::query_as::<_, UserFavorite>(&format!(
r#" r#"
SELECT * FROM {} WHERE username = $1 SELECT * FROM {} WHERE username = $1
@@ -27,7 +27,7 @@ impl UserFavorite {
Ok(favorites) Ok(favorites)
} }
pub async fn insert(pool: &Pool<Postgres>, username: &str, icao: &str) -> ApiResult<()> { pub async fn insert(pool: &Pool<Postgres>, username: &str, icao: &str) -> CoreResult<()> {
sqlx::query(&format!( sqlx::query(&format!(
r#" r#"
INSERT INTO {} ( INSERT INTO {} (
@@ -44,7 +44,7 @@ impl UserFavorite {
Ok(()) Ok(())
} }
pub async fn delete(pool: &Pool<Postgres>, username: &str, icao: &str) -> ApiResult<()> { pub async fn delete(pool: &Pool<Postgres>, username: &str, icao: &str) -> CoreResult<()> {
sqlx::query(&format!( sqlx::query(&format!(
r#" r#"
DELETE FROM {} WHERE username = $1 AND icao = $2 DELETE FROM {} WHERE username = $1 AND icao = $2

View File

@@ -1,25 +1,11 @@
use argon2::{ use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
Argon2, PasswordHash, PasswordHasher, PasswordVerifier, use argon2::password_hash::rand_core::OsRng;
password_hash::{SaltString, rand_core::OsRng}, use argon2::password_hash::SaltString;
};
use rand::distr::Alphanumeric; use rand::distr::Alphanumeric;
use rand::prelude::*; use rand::Rng;
use rand_chacha::ChaCha20Rng; use rand_chacha::ChaCha20Rng;
use rand_chacha::rand_core::SeedableRng;
mod auth; use crate::error::CoreResult;
mod email_token;
mod model;
mod routes;
mod session;
mod user;
mod user_favorites;
pub use auth::*;
pub use routes::init_routes;
pub use session::*;
pub use user::*;
use crate::error::{ApiResult, Error};
pub fn csprng(take: usize) -> String { pub fn csprng(take: usize) -> String {
// Generate a CSPRNG 128-bit (16 byte) ID using alphanumeric characters (a-z, A-Z, 0-9) // Generate a CSPRNG 128-bit (16 byte) ID using alphanumeric characters (a-z, A-Z, 0-9)
@@ -31,7 +17,7 @@ pub fn csprng(take: usize) -> String {
.collect() .collect()
} }
pub fn hash(string: &str) -> ApiResult<String> { pub fn hash(string: &str) -> CoreResult<String> {
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default() let hash = Argon2::default()
.hash_password(string.as_bytes(), &salt)? .hash_password(string.as_bytes(), &salt)?
@@ -57,17 +43,6 @@ pub fn verify_hash(string: &str, hashed_string: &str) -> bool {
.is_ok() .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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -2,8 +2,6 @@ use crate::airports::{
AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication, AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication,
UpdateRunway, UpdateRunway,
}; };
use crate::error::{ApiResult, Error};
use crate::metars::Metar;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use futures_util::try_join; use futures_util::try_join;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -11,7 +9,8 @@ use sqlx::{Pool, Postgres, QueryBuilder};
use std::collections::HashMap; use std::collections::HashMap;
use std::str::FromStr; use std::str::FromStr;
use utoipa::{IntoParams, ToSchema}; use utoipa::{IntoParams, ToSchema};
use crate::state::AppState; use crate::error::{CoreError, CoreErrorKind, CoreResult};
use crate::metars::Metar;
const TABLE_NAME: &str = "airports"; const TABLE_NAME: &str = "airports";
const DEFAULT_COLUMNS: &str = "icao, iata, local, name, category, iso_country, \ const DEFAULT_COLUMNS: &str = "icao, iata, local, name, category, iso_country, \
@@ -137,11 +136,11 @@ pub struct Bounds {
} }
impl Bounds { impl Bounds {
fn parse(input: &str) -> ApiResult<Bounds> { fn parse(input: &str) -> CoreResult<Bounds> {
let parts: Vec<&str> = input.split(',').collect(); let parts: Vec<&str> = input.split(',').collect();
if parts.len() != 4 { if parts.len() != 4 {
return Err(Error::new( return Err(CoreError::new(
400, CoreErrorKind::InvalidInput,
format!("Expected 4 fields in bounds but received {}", parts.len()), format!("Expected 4 fields in bounds but received {}", parts.len()),
)); ));
} }
@@ -332,7 +331,7 @@ impl Airport {
}) })
} }
pub async fn select_all(pool: &Pool<Postgres>, query: &AirportQuery) -> ApiResult<Vec<Self>> { pub async fn select_all(pool: &Pool<Postgres>, query: &AirportQuery) -> CoreResult<Vec<Self>> {
let mut builder = let mut builder =
QueryBuilder::<Postgres>::new(format!("SELECT {} FROM {}", DEFAULT_COLUMNS, TABLE_NAME)); QueryBuilder::<Postgres>::new(format!("SELECT {} FROM {}", DEFAULT_COLUMNS, TABLE_NAME));
@@ -487,7 +486,7 @@ impl Airport {
sql_query.fetch_one(pool).await.unwrap_or_else(|_| 0) sql_query.fetch_one(pool).await.unwrap_or_else(|_| 0)
} }
pub async fn insert(&self, pool: &Pool<Postgres>) -> ApiResult<Self> { pub async fn insert(&self, pool: &Pool<Postgres>) -> CoreResult<Self> {
let mut all_runway_rows: Vec<RunwayRow> = Vec::new(); let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
let mut all_frequency_rows: Vec<CommunicationRow> = Vec::new(); let mut all_frequency_rows: Vec<CommunicationRow> = Vec::new();
for runway in &self.runways { for runway in &self.runways {
@@ -535,7 +534,7 @@ impl Airport {
Ok(airport.into()) Ok(airport.into())
} }
pub async fn insert_all(pool: &Pool<Postgres>, airports: Vec<Self>) -> ApiResult<()> { pub async fn insert_all(pool: &Pool<Postgres>, airports: Vec<Self>) -> CoreResult<()> {
let chunk_size = 1000; let chunk_size = 1000;
let mut all_runway_rows: Vec<RunwayRow> = Vec::new(); let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
let mut all_frequency_rows: Vec<CommunicationRow> = Vec::new(); let mut all_frequency_rows: Vec<CommunicationRow> = Vec::new();
@@ -592,7 +591,7 @@ impl Airport {
} }
// TODO // TODO
pub async fn update(pool: &Pool<Postgres>, icao: &str, airport: &UpdateAirport) -> ApiResult<()> { pub async fn update(pool: &Pool<Postgres>, icao: &str, airport: &UpdateAirport) -> CoreResult<()> {
let mut query_builder: QueryBuilder<Postgres> = let mut query_builder: QueryBuilder<Postgres> =
QueryBuilder::new(format!("UPDATE {} SET ", TABLE_NAME)); QueryBuilder::new(format!("UPDATE {} SET ", TABLE_NAME));
if let Some(latest_metar_observation) = airport.latest_metar_observation { if let Some(latest_metar_observation) = airport.latest_metar_observation {
@@ -607,7 +606,7 @@ impl Airport {
Ok(()) Ok(())
} }
pub async fn delete(pool: &Pool<Postgres>, icao: &str) -> ApiResult<()> { pub async fn delete(pool: &Pool<Postgres>, icao: &str) -> CoreResult<()> {
sqlx::query(&format!( sqlx::query(&format!(
r#" r#"
DELETE FROM {} WHERE icao = $1 DELETE FROM {} WHERE icao = $1
@@ -621,7 +620,7 @@ impl Airport {
Ok(()) Ok(())
} }
pub async fn delete_all(pool: &Pool<Postgres>) -> ApiResult<()> { pub async fn delete_all(pool: &Pool<Postgres>) -> CoreResult<()> {
sqlx::query(&format!( sqlx::query(&format!(
r#" r#"
DELETE FROM {} WHERE true DELETE FROM {} WHERE true
@@ -688,7 +687,7 @@ impl Airport {
builder: &mut QueryBuilder<'a, Postgres>, builder: &mut QueryBuilder<'a, Postgres>,
has_where: &mut bool, has_where: &mut bool,
field: &'a Option<String>, field: &'a Option<String>,
) -> ApiResult<()> { ) -> CoreResult<()> {
// Query bounds // Query bounds
if let Some(bounds_string) = field { if let Some(bounds_string) = field {
if !*has_where { if !*has_where {

View File

@@ -1,9 +1,9 @@
use crate::error::ApiResult;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{Pool, Postgres, QueryBuilder}; use sqlx::{Pool, Postgres, QueryBuilder};
use std::collections::HashMap; use std::collections::HashMap;
use utoipa::ToSchema; use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::error::CoreResult;
const TABLE_NAME: &str = "communications"; const TABLE_NAME: &str = "communications";
@@ -64,7 +64,7 @@ impl Communication {
} }
} }
pub async fn select_all_map(pool: &Pool<Postgres>, icaos: &Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> { 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!( let frequency_rows: Vec<CommunicationRow> = sqlx::query_as(&format!(
r#"SELECT * FROM {} WHERE icao = ANY($1)"#, r#"SELECT * FROM {} WHERE icao = ANY($1)"#,
TABLE_NAME TABLE_NAME
@@ -86,7 +86,7 @@ impl Communication {
Ok(frequency_map) Ok(frequency_map)
} }
pub async fn select_all(pool: &Pool<Postgres>, icao: &str) -> ApiResult<Vec<Self>> { pub async fn select_all(pool: &Pool<Postgres>, icao: &str) -> CoreResult<Vec<Self>> {
let frequency_row: Vec<CommunicationRow> = sqlx::query_as(&format!( let frequency_row: Vec<CommunicationRow> = sqlx::query_as(&format!(
r#" r#"
SELECT * FROM {} WHERE icao = $1 SELECT * FROM {} WHERE icao = $1
@@ -99,7 +99,7 @@ impl Communication {
Ok(frequency_row.into_iter().map(From::from).collect()) Ok(frequency_row.into_iter().map(From::from).collect())
} }
pub async fn insert_all(pool: &Pool<Postgres>, communications: &Vec<CommunicationRow>) -> ApiResult<()> { pub async fn insert_all(pool: &Pool<Postgres>, communications: &Vec<CommunicationRow>) -> CoreResult<()> {
let chunk_size = 1000; let chunk_size = 1000;
for chunk in communications.chunks(chunk_size) { for chunk in communications.chunks(chunk_size) {

View File

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

@@ -1,4 +1,4 @@
use crate::error::{ApiResult, Error}; use crate::error::{CoreResult, CoreError, CoreErrorKind};
use governor::clock::DefaultClock; use governor::clock::DefaultClock;
use governor::state::{InMemoryState, NotKeyed}; use governor::state::{InMemoryState, NotKeyed};
use governor::{Quota, RateLimiter}; use governor::{Quota, RateLimiter};
@@ -18,7 +18,7 @@ pub struct HttpClient {
} }
impl HttpClient { impl HttpClient {
pub fn new(default_retry_after: u64) -> ApiResult<Self> { pub fn new(default_retry_after: u64) -> CoreResult<Self> {
let mut client_builder = Client::builder() let mut client_builder = Client::builder()
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
.tls_built_in_root_certs(true); .tls_built_in_root_certs(true);
@@ -45,11 +45,11 @@ impl HttpClient {
}) })
} }
pub fn default() -> ApiResult<Self> { pub fn default() -> CoreResult<Self> {
Self::new(60) Self::new(60)
} }
pub async fn get(&self, url: &str, etag: Option<String>) -> ApiResult<Response> { pub async fn get(&self, url: &str, etag: Option<String>) -> CoreResult<Response> {
self.limiter.until_ready().await; self.limiter.until_ready().await;
let mut request = self.client.get(url); let mut request = self.client.get(url);
@@ -81,8 +81,8 @@ impl HttpClient {
} }
if response.status() != 200 { if response.status() != 200 {
return Err(Error::new( return Err(CoreError::new(
response.status().as_u16(), CoreErrorKind::External,
format!("Request returned status {}", response.status()), format!("Request returned status {}", response.status()),
)); ));
} }

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

@@ -1,7 +1,7 @@
use crate::error::ApiResult;
use crate::metars::Metar;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::CoreResult;
use crate::metars::model::Metar;
use crate::state::AppState; use crate::state::AppState;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -31,7 +31,7 @@ impl MetarCheck {
} }
pub async fn get(state: &AppState, icao: &str) -> Option<MetarCheck> { pub async fn get(state: &AppState, icao: &str) -> Option<MetarCheck> {
let result: ApiResult<Option<String>> = state.get(icao).await; let result: CoreResult<Option<String>> = state.get(icao).await;
match result { match result {
Ok(Some(value)) => match serde_json::from_str(&value) { Ok(Some(value)) => match serde_json::from_str(&value) {
Ok(result) => Some(result), Ok(result) => Some(result),
@@ -48,7 +48,7 @@ impl MetarCheck {
} }
} }
pub async fn insert(&self, state: &AppState) -> ApiResult<()> { pub async fn insert(&self, state: &AppState) -> CoreResult<()> {
let value = serde_json::to_string(&self)?; let value = serde_json::to_string(&self)?;
state.set(self.icao.as_str(), &value).await?; state.set(self.icao.as_str(), &value).await?;

View File

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

View File

@@ -1,8 +1,6 @@
use crate::airports::{Airport, UpdateAirport}; use crate::airports::{Airport, UpdateAirport};
use crate::error::Error; use crate::error::{CoreError, CoreErrorKind, CoreResult};
use crate::metars::MetarCheck;
use crate::metars::utils::parse_metar_time; use crate::metars::utils::parse_metar_time;
use crate::error::ApiResult;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use flate2::read::GzDecoder; use flate2::read::GzDecoder;
use reqwest::header::ETAG; use reqwest::header::ETAG;
@@ -16,6 +14,7 @@ use std::str::FromStr;
use std::sync::OnceLock; use std::sync::OnceLock;
use regex::Regex; use regex::Regex;
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::metars::metar_check::MetarCheck;
use crate::state::AppState; use crate::state::AppState;
static TIME_OFFSET: OnceLock<i64> = OnceLock::new(); static TIME_OFFSET: OnceLock<i64> = OnceLock::new();
@@ -87,12 +86,12 @@ pub enum ReportModifier {
} }
impl FromStr for ReportModifier { impl FromStr for ReportModifier {
type Err = Error; type Err = CoreError;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s { match s {
"AUTO" => Ok(ReportModifier::Auto), "AUTO" => Ok(ReportModifier::Auto),
"COR" => Ok(ReportModifier::Corrected), "COR" => Ok(ReportModifier::Corrected),
_ => Err(Error::new(400, format!("Invalid report modifier '{}'", s))), _ => Err(CoreError::new(CoreErrorKind::InvalidInput, format!("Invalid report modifier '{}'", s))),
} }
} }
} }
@@ -137,13 +136,13 @@ pub enum AutomatedStationType {
} }
impl FromStr for AutomatedStationType { impl FromStr for AutomatedStationType {
type Err = Error; type Err = CoreError;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s { match s {
"AO1" => Ok(AutomatedStationType::WithoutPrecipitationDiscriminator), "AO1" => Ok(AutomatedStationType::WithoutPrecipitationDiscriminator),
"AO2" => Ok(AutomatedStationType::WithPrecipitationDiscriminator), "AO2" => Ok(AutomatedStationType::WithPrecipitationDiscriminator),
_ => Err(Error::new( _ => Err(CoreError::new(
400, CoreErrorKind::InvalidInput,
format!("Invalid automated station type '{}'", s), format!("Invalid automated station type '{}'", s),
)), )),
} }
@@ -279,7 +278,7 @@ struct MetarRow {
} }
impl MetarRow { impl MetarRow {
async fn insert(&self, pool: &Pool<Postgres>) -> ApiResult<()> { async fn insert(&self, pool: &Pool<Postgres>) -> CoreResult<()> {
sqlx::query(&format!( sqlx::query(&format!(
r#" r#"
INSERT INTO {} ( INSERT INTO {} (
@@ -305,7 +304,7 @@ impl MetarRow {
Ok(()) Ok(())
} }
async fn insert_all(pool: &Pool<Postgres>, metars: Vec<Metar>) -> ApiResult<()> { async fn insert_all(pool: &Pool<Postgres>, metars: Vec<Metar>) -> CoreResult<()> {
let chunk_size = 1000; let chunk_size = 1000;
for chunk in metars.chunks(chunk_size) { for chunk in metars.chunks(chunk_size) {
@@ -341,7 +340,7 @@ impl MetarRow {
} }
impl Metar { impl Metar {
fn parse_multiple(pool: &Pool<Postgres>, metar_strings: &Vec<&str>) -> ApiResult<Vec<Self>> { fn parse_multiple(pool: &Pool<Postgres>, metar_strings: &Vec<&str>) -> CoreResult<Vec<Self>> {
let mut metars: Vec<Self> = vec![]; let mut metars: Vec<Self> = vec![];
for metar_string in metar_strings { for metar_string in metar_strings {
match Self::parse(pool, metar_string) { match Self::parse(pool, metar_string) {
@@ -356,25 +355,26 @@ impl Metar {
Ok(metars) Ok(metars)
} }
fn parse(pool: &Pool<Postgres>, metar_string: &str) -> ApiResult<Self> { fn parse(pool: &Pool<Postgres>, metar_string: &str) -> CoreResult<Self> {
if metar_string.is_empty() { if metar_string.is_empty() {
return Err(Error::new( return Err(CoreError::new(
404, CoreErrorKind::InvalidInput,
"Unable to parse empty METAR data".to_string(), "Unable to parse empty METAR data".to_string(),
)); ));
} }
let metar_string = metar_string
.trim()
.trim_matches(|c| c == '"' || c == '\'' || c == '“' || c == '”' || c == '' || c == '')
.trim();
log::trace!("Parsing METAR data: {}", metar_string); log::trace!("Parsing METAR data: {}", metar_string);
let mut metar: Self = Self::default(); let mut metar: Self = Self::default();
metar.raw_text = metar_string.to_owned(); metar.raw_text = metar_string.to_owned();
let mut metar_parts: Vec<&str> = metar_string let mut metar_parts: Vec<&str> = metar_string.split_whitespace().collect();
.trim()
.trim_matches(|c| c == '"' || c == '\'' || c == '“' || c == '”' || c == '' || c == '')
.trim()
.split_whitespace().collect();
if metar_parts.len() < 4 { if metar_parts.len() < 4 {
return Err(Error::new( return Err(CoreError::new(
500, CoreErrorKind::InvalidInput,
format!( format!(
"Unable to parse METAR data in an unexpected format: {}", "Unable to parse METAR data in an unexpected format: {}",
metar_string metar_string
@@ -390,7 +390,8 @@ impl Metar {
if metar_re.is_match(token) { if metar_re.is_match(token) {
metar_parts.remove(0); metar_parts.remove(0);
} else if speci_re.is_match(token) { } else if speci_re.is_match(token) {
return Err(Error::new(500, format!("Unable to parse SPECI data: {}", metar_string))); // TODO: Handle SPECI data
return Err(CoreError::new(CoreErrorKind::InvalidInput, format!("Unable to parse SPECI data: {}", metar_string)));
} }
// Station Identifier // Station Identifier
@@ -408,8 +409,8 @@ impl Metar {
}; };
} }
Err(err) => { Err(err) => {
return Err(Error::new( return Err(CoreError::new(
err.status, CoreErrorKind::InvalidInput,
format!( format!(
"Unexpected observation time field '{}': {}; {}", "Unexpected observation time field '{}': {}; {}",
observation_time, metar_string, err observation_time, metar_string, err
@@ -722,8 +723,8 @@ impl Metar {
if metar_parts.is_empty() { if metar_parts.is_empty() {
break; break;
} }
let slp_re = regex::Regex::new(r"^SLP([0-9]{3})$")?; let slp_re = Regex::new(r"^SLP([0-9]{3})$")?;
let hourly_temp_re = regex::Regex::new(r"^T[01][0-9]{3}[01][0-9]{3}$")?; let hourly_temp_re = Regex::new(r"^T[01][0-9]{3}[01][0-9]{3}$")?;
let remark = metar_parts[0]; let remark = metar_parts[0];
metar_parts.remove(0); metar_parts.remove(0);
if remark == "AO1" || remark == "AO2" { if remark == "AO1" || remark == "AO2" {
@@ -757,8 +758,8 @@ impl Metar {
minutes, minutes,
}); });
} else { } else {
return Err(Error::new( return Err(CoreError::new(
500, CoreErrorKind::InvalidInput,
"Input string format is invalid".to_string(), "Input string format is invalid".to_string(),
)); ));
} }
@@ -995,49 +996,45 @@ impl Metar {
pub async fn get_cached_remote_metars( pub async fn get_cached_remote_metars(
state: &AppState, state: &AppState,
etag: Option<String>, etag: Option<String>,
) -> ApiResult<(Vec<Self>, String)> { ) -> CoreResult<(Vec<Self>, String)> {
let base_url = env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set"); let base_url = env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set");
let url = format!("{}/data/cache/metars.cache.csv.gz", base_url); let url = format!("{}/data/cache/metars.cache.csv.gz", base_url);
match state.client.get(&url, etag.clone()).await { let response = state.client.get(&url, etag.clone()).await?;
Ok(r) => { let new_etag = response
let new_etag = r .headers()
.headers() .get(ETAG)
.get(ETAG) .and_then(|h| h.to_str().ok())
.and_then(|h| h.to_str().ok()) .map(|s| s.to_string());
.map(|s| s.to_string());
let bytes = r.bytes().await?; let bytes = response.bytes().await?;
let mut gz = GzDecoder::new(Cursor::new(bytes)); let mut gz = GzDecoder::new(Cursor::new(bytes));
let mut text = String::new(); let mut text = String::new();
gz.read_to_string(&mut text)?; gz.read_to_string(&mut text)?;
let mut output: Vec<Metar> = Vec::new(); let mut output: Vec<Metar> = Vec::new();
for line in text.lines() { for line in text.lines() {
// Split off the first column // Split off the first column
let raw_text = line.splitn(2, ',').next().unwrap(); let raw_text = line.splitn(2, ',').next().unwrap();
match Metar::parse(&state.pool, raw_text) { match Metar::parse(&state.pool, raw_text) {
Ok(m) => output.push(m), Ok(m) => output.push(m),
Err(err) => { Err(err) => {
log::warn!("{}", err); log::warn!("{}", err);
}
};
} }
};
}
match new_etag { match new_etag {
Some(etag) => Ok((output, etag)), Some(etag) => Ok((output, etag)),
None => match etag { None => match etag {
Some(etag) => Ok((output, etag.to_string())), Some(etag) => Ok((output, etag.to_string())),
None => Ok((output, String::new())), None => Ok((output, String::new())),
}, },
}
}
Err(err) => Err(err.into()),
} }
} }
pub async fn get_remote_metars(state: &AppState, icaos: &Vec<String>) -> ApiResult<Vec<Self>> { pub async fn get_remote_metars(state: &AppState, icaos: &Vec<String>) -> CoreResult<Vec<Self>> {
let base_url = env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set"); let base_url = env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set");
// Query the remote API for the missing METAR data 10 at a time // Query the remote API for the missing METAR data 10 at a time
let icao_chunks = icaos let icao_chunks = icaos
@@ -1063,7 +1060,7 @@ impl Metar {
Err(err) => return Err(err), Err(err) => return Err(err),
} }
} }
Err(err) => return Err(Error::new(500, format!("METAR parse failed: {}", err))), Err(err) => return Err(CoreError::new(CoreErrorKind::InvalidInput, format!("METAR parse failed: {}", err))),
}, },
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
}; };
@@ -1072,12 +1069,12 @@ impl Metar {
Ok(metars) Ok(metars)
} }
fn from_row(row: MetarRow) -> ApiResult<Self> { fn from_row(row: MetarRow) -> CoreResult<Self> {
let metar: Self = serde_json::from_value(row.data)?; let metar: Self = serde_json::from_value(row.data)?;
Ok(metar) Ok(metar)
} }
fn to_row(&self) -> ApiResult<MetarRow> { fn to_row(&self) -> CoreResult<MetarRow> {
let data = serde_json::to_value(self)?; let data = serde_json::to_value(self)?;
Ok(MetarRow { Ok(MetarRow {
icao: self.icao.to_uppercase(), icao: self.icao.to_uppercase(),
@@ -1087,7 +1084,7 @@ impl Metar {
}) })
} }
pub async fn get_all_distinct(pool: &Pool<Postgres>, icao_list: &Vec<String>) -> ApiResult<Vec<Self>> { pub async fn get_all_distinct(pool: &Pool<Postgres>, icao_list: &Vec<String>) -> CoreResult<Vec<Self>> {
if icao_list.is_empty() { if icao_list.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@@ -1113,7 +1110,7 @@ impl Metar {
pub async fn get_or_update_metars( pub async fn get_or_update_metars(
state: &AppState, state: &AppState,
icaos: &Vec<String>, icaos: &Vec<String>,
) -> ApiResult<Vec<Self>> { ) -> CoreResult<Vec<Self>> {
let metars = Self::get_all_distinct(&state.pool, &icaos).await?; let metars = Self::get_all_distinct(&state.pool, &icaos).await?;
let current_time = Utc::now().timestamp(); let current_time = Utc::now().timestamp();
@@ -1215,7 +1212,7 @@ impl Metar {
Ok(updated_metars) Ok(updated_metars)
} }
pub async fn update_metars(state: &AppState, etag: Option<String>) -> ApiResult<String> { pub async fn update_metars(state: &AppState, etag: Option<String>) -> CoreResult<String> {
let (remote_metars, etag) = Self::get_cached_remote_metars(state, etag) let (remote_metars, etag) = Self::get_cached_remote_metars(state, etag)
.await .await
.unwrap_or_else(|err| { .unwrap_or_else(|err| {
@@ -1227,7 +1224,7 @@ impl Metar {
Ok(etag) Ok(etag)
} }
pub async fn insert(&self, pool: &Pool<Postgres>) -> ApiResult<()> { pub async fn insert(&self, pool: &Pool<Postgres>) -> CoreResult<()> {
log::trace!( log::trace!(
"Inserting metar {} with observation time {}", "Inserting metar {} with observation time {}",
self.icao, self.icao,

View File

@@ -1,10 +1,10 @@
use crate::error::{ApiResult, Error}; use crate::error::{CoreError, CoreErrorKind, CoreResult};
use chrono::{Datelike, NaiveDate, Utc}; use chrono::{Datelike, NaiveDate, Utc};
pub fn parse_metar_time(observation_time: &str) -> ApiResult<String> { pub fn parse_metar_time(observation_time: &str) -> CoreResult<String> {
if observation_time.len() != 7 { if observation_time.len() != 7 {
return Err(Error::new( return Err(CoreError::new(
500, CoreErrorKind::InvalidInput,
format!("Unable to parse observation time in {}", observation_time), format!("Unable to parse observation time in {}", observation_time),
)); ));
} }
@@ -25,8 +25,8 @@ pub fn parse_metar_time(observation_time: &str) -> ApiResult<String> {
let current_month = current_time.month(); let current_month = current_time.month();
let candidate_date = NaiveDate::from_ymd_opt(current_year, current_month, observation_day) let candidate_date = NaiveDate::from_ymd_opt(current_year, current_month, observation_day)
.ok_or_else(|| { .ok_or_else(|| {
Error::new( CoreError::new(
500, CoreErrorKind::InvalidInput,
format!( format!(
"Invalid date with day {} for current month", "Invalid date with day {} for current month",
observation_day observation_day
@@ -36,8 +36,8 @@ pub fn parse_metar_time(observation_time: &str) -> ApiResult<String> {
let candidate_date = match candidate_date.and_hms_opt(observation_hour, observation_minute, 0) { let candidate_date = match candidate_date.and_hms_opt(observation_hour, observation_minute, 0) {
Some(date) => date, Some(date) => date,
None => { None => {
return Err(Error::new( return Err(CoreError::new(
500, CoreErrorKind::InvalidInput,
format!( format!(
"Invalid time for time '{}': hour {}, minute {}", "Invalid time for time '{}': hour {}, minute {}",
observation_time, observation_hour, observation_minute observation_time, observation_hour, observation_minute
@@ -55,8 +55,8 @@ pub fn parse_metar_time(observation_time: &str) -> ApiResult<String> {
}; };
let adjusted_date = NaiveDate::from_ymd_opt(year, month, observation_day).ok_or_else(|| { let adjusted_date = NaiveDate::from_ymd_opt(year, month, observation_day).ok_or_else(|| {
Error::new( CoreError::new(
500, CoreErrorKind::InvalidInput,
format!( format!(
"Invalid date with day {} for month {}", "Invalid date with day {} for month {}",
observation_day, month observation_day, month

View File

@@ -7,7 +7,7 @@ use s3::{Bucket, BucketConfiguration, Region};
use s3::creds::Credentials; use s3::creds::Credentials;
use sqlx::{Pool, Postgres}; use sqlx::{Pool, Postgres};
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use crate::error::ApiResult; use crate::error::CoreResult;
use crate::http_client::HttpClient; use crate::http_client::HttpClient;
#[derive(Clone)] #[derive(Clone)]
@@ -19,7 +19,7 @@ pub struct AppState {
} }
impl AppState { impl AppState {
pub async fn new() -> ApiResult<Self> { pub async fn new() -> CoreResult<Self> {
let client = HttpClient::default()?; let client = HttpClient::default()?;
let pool: Pool<Postgres> = { let pool: Pool<Postgres> = {
@@ -139,19 +139,19 @@ impl AppState {
}) })
} }
pub async fn set(&self, key: &str, value: &str) -> ApiResult<()> { pub async fn set(&self, key: &str, value: &str) -> CoreResult<()> {
let mut connection_manager = self.connection_manager.lock()?; let mut connection_manager = self.connection_manager.lock()?;
connection_manager.set(key, value).await?; connection_manager.set(key, value).await?;
Ok(()) Ok(())
} }
pub async fn set_ex(&self, key: &str, value: &str, seconds: u64) -> ApiResult<()> { pub async fn set_ex(&self, key: &str, value: &str, seconds: u64) -> CoreResult<()> {
let mut connection_manager = self.connection_manager.lock()?; let mut connection_manager = self.connection_manager.lock()?;
connection_manager.set_ex(key, value, seconds).await?; connection_manager.set_ex(key, value, seconds).await?;
Ok(()) Ok(())
} }
pub async fn get(&self, key: &str) -> ApiResult<Option<String>> { pub async fn get(&self, key: &str) -> CoreResult<Option<String>> {
let mut connection_manager = self.connection_manager.lock()?; let mut connection_manager = self.connection_manager.lock()?;
match connection_manager.get(key).await { match connection_manager.get(key).await {
Ok(value) => Ok(value), Ok(value) => Ok(value),
@@ -159,7 +159,7 @@ impl AppState {
} }
} }
pub async fn del(&self, key: &str) -> ApiResult<()> { pub async fn del(&self, key: &str) -> CoreResult<()> {
let mut connection_manager = self.connection_manager.lock()?; let mut connection_manager = self.connection_manager.lock()?;
connection_manager.del(key).await?; connection_manager.del(key).await?;
Ok(()) 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
);
}
}

View File

@@ -14,7 +14,7 @@ services:
container_name: aviation-nginx container_name: aviation-nginx
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: nginx/Dockerfile
env_file: *env env_file: *env
environment: environment:
SSL_CERT_PATH: /etc/nginx/ssl/localhost.crt SSL_CERT_PATH: /etc/nginx/ssl/localhost.crt
@@ -84,7 +84,7 @@ services:
container_name: aviation-api container_name: aviation-api
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: crates/api/Dockerfile
env_file: *env env_file: *env
environment: environment:
SSL_CA_PATH: /ssl/ca.pem SSL_CA_PATH: /ssl/ca.pem
@@ -109,6 +109,22 @@ services:
- api - api
<<: *default_restart <<: *default_restart
scheduler:
image: gitea.bensherriff.com/bsherriff/aviation-scheduler:latest
container_name: aviation-scheduler
build:
context: .
dockerfile: crates/scheduler/Dockerfile
env_file: *env
environment:
POSTGRES_HOST: aviation-postgres
POSTGRES_PORT: 5432
depends_on:
- postgres
profiles:
- api
<<: *default_restart
mailpit: mailpit:
image: axllent/mailpit image: axllent/mailpit
container_name: mailpit container_name: mailpit